Skip to content

Commit 695a90c

Browse files
committed
test(integ): fix cross-line quote state and quoted global values in the aws-command lint
Second review round (spec + code + test, 3-axis) found two more should-fix defects and a set of nits. Both defects were reproduced against REAL fixture content, not synthetic strings. 1. A QUOTED global-option value made the whole invocation vanish. Blanking replaced a quoted span with SPACES, so `"${REGION}"` stopped being a token and the option-skip loop consumed `emr` as the value instead: aws --region "${REGION}" emr list-instance-groups -> missed aws --region us-east-1 emr list-instance-groups -> found `"${REGION}"` is the spelling every fixture in this tree uses, so the first fix was blind to the form it actually meets. Blanking now fills with `_`, which is inside the token charset and can never start a NAME, so a blanked span stays exactly one token. 2. Quote state was per-LINE, so the body of a multi-line string parsed as CODE. This PR itself adds that shape (the `node --input-type=module -e "` blocks in the EMR fixtures), and a JS comment inside one mentioning the removed verb became a CI-blocking violation curable only by deleting the comment — exactly the outcome the blanking exists to prevent, one layer up. Scan state (`ScanState`) now spans lines, and heredoc bodies are skipped too. Test-review findings, all addressed: - The tree walk was UNCOVERED: the floors re-implemented readdirSync instead of using the lint's walk, so breaking the real filter left every floor green while the lint scanned nothing. `readFixtureScripts` is now exported and both sides drive off it. - Added a CEILING (`total < 3000`) — floors catch a parser that stops seeing things, only a ceiling catches one that starts seeing things that are not there, which is what the false positives were. - The prose fence gained a POSITIVE CONTROL: uncommenting the mention in the same real fixture must now flag, so the fence proves comment-stripping rather than general silence. - The manual real-code fail probe is now also an automated test (splices a removed verb into real emr-cluster/verify.sh content in memory, asserts file:line) so it cannot bit-rot between manual runs. - `globalOption` shape counter asserted exactly 0, to be converted to a floor the day a fixture uses one. - Direct `blankQuotedSpans` tests incl. pinned known limitations. - Corrected a factual claim: TWO fixtures discuss the verb in prose, not three. - Removed a dead `--flag=value` branch (`=` is a token separator, so it was unreachable) and completed the value-taking global set — an unrecognised one was skipped as boolean, putting its VALUE in the service slot. refresh-aws-cli-removals.ts nits: the event reconciliation no longer greps comments (a doc mention would have thrown a misleading "parser is stale"), `--aws-root=<path>` is recognised, and a dangling `aws` symlink no longer throws past the actionable error. Floors re-measured after each change: 2635 invocations / 221 fixtures / 68 services / 355 verbs; plain 1008, capture 1023, helper-arg 414, condition 211.
1 parent 3747d69 commit 695a90c

3 files changed

Lines changed: 370 additions & 63 deletions

File tree

scripts/check-integ-aws-commands.ts

Lines changed: 175 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,37 @@ const VALUE_TAKING_GLOBAL_OPTIONS: ReadonlySet<string> = new Set([
105105
'--cli-read-timeout',
106106
'--cli-connect-timeout',
107107
'--cli-binary-format',
108+
// Completing the documented set matters: an unrecognised value-taking global
109+
// is skipped as if it were boolean, so its VALUE lands in the service slot,
110+
// fails NAME, and the whole invocation vanishes with no diagnostic.
111+
'--metadata-service-timeout',
112+
'--metadata-service-num-attempts',
108113
]);
109114

115+
/**
116+
* Index of the first token at or after `start` that is not an option (nor an
117+
* option's consumed value). Used at BOTH the service and the verb slot, since
118+
* the AWS CLI accepts a global option in either position.
119+
*/
120+
function skipOptions(tokens: string[], start: number): number {
121+
let k = start;
122+
while (k < tokens.length && tokens[k]!.startsWith('-')) {
123+
// NOTE `=` is a TOKEN_SEPARATOR, so `--region=us-east-1` already arrived
124+
// as two tokens and needs no special case here — the value is consumed by
125+
// the same branch as the space-separated form.
126+
const flag = tokens[k]!;
127+
k++;
128+
if (
129+
VALUE_TAKING_GLOBAL_OPTIONS.has(flag) &&
130+
k < tokens.length &&
131+
!tokens[k]!.startsWith('-')
132+
) {
133+
k++;
134+
}
135+
}
136+
return k;
137+
}
138+
110139
/**
111140
* Blanks out quoted spans so their contents are not mistaken for commands,
112141
* while PRESERVING `$( ... )` command substitutions inside them.
@@ -126,52 +155,141 @@ const VALUE_TAKING_GLOBAL_OPTIONS: ReadonlySet<string> = new Set([
126155
* family of reasons: it keeps `arn:aws:...` and a JMESPath `` `aws:cdk:path` ``
127156
* as ONE token rather than splitting out a bare `aws`.
128157
*/
129-
export function blankQuotedSpans(line: string): string {
158+
export interface ScanState {
159+
/** Quote character of a span left OPEN at the end of the previous line. */
160+
openQuote: '"' | "'" | null;
161+
/** Delimiter of a heredoc whose body we are inside, or null. */
162+
heredoc: string | null;
163+
}
164+
165+
export function newScanState(): ScanState {
166+
return { openQuote: null, heredoc: null };
167+
}
168+
169+
/** Blanking filler. See {@link blankQuotedSpans} for why it is not a space. */
170+
const BLANK = '_';
171+
172+
/**
173+
* Blanks non-code spans of a line — quoted strings and heredoc bodies — so
174+
* their contents are not mistaken for commands, while PRESERVING `$( ... )`
175+
* command substitutions inside double quotes.
176+
*
177+
* `state` carries quote / heredoc context ACROSS lines and is mutated. That is
178+
* load-bearing, not tidiness: this repo's fixtures embed multi-line
179+
* `node --input-type=module -e "` programs (see
180+
* `tests/integration/emr-instance-configs/verify.sh`), and with per-line state
181+
* a JS comment inside such a block — `// do not use aws emr
182+
* list-instance-groups here` — parses as a command and becomes a CI-blocking
183+
* violation whose only cure is deleting the explanation. That is precisely the
184+
* outcome this blanking exists to prevent, one layer up. Same for a
185+
* `cat <<EOF ... EOF` body.
186+
*
187+
* The filler is `_`, NOT a space, and that choice is also load-bearing. A
188+
* blanked span must remain ONE token: `_` is inside {@link TOKEN_SEPARATOR}'s
189+
* allowed set (and can never start a {@link NAME}), whereas spaces would make
190+
* the span vanish as a token entirely — and then
191+
* `aws --region "${REGION}" emr list-clusters` loses its option VALUE, the skip
192+
* loop consumes `emr` in its place, and the whole invocation disappears with no
193+
* diagnostic. `"${REGION}"` is the spelling every fixture here uses, so
194+
* space-filling would have been blind to the form it actually meets.
195+
*
196+
* Length is preserved so downstream line/column accounting is unaffected.
197+
*/
198+
export function blankQuotedSpans(line: string, state: ScanState = newScanState()): string {
199+
// Inside a heredoc body: the whole line is data until the delimiter line.
200+
if (state.heredoc !== null) {
201+
if (line.trim() === state.heredoc) state.heredoc = null;
202+
return BLANK.repeat(line.length);
203+
}
204+
130205
let out = '';
131206
let i = 0;
207+
208+
// Finish a quoted span left open by the previous line.
209+
if (state.openQuote !== null) {
210+
const quote = state.openQuote;
211+
while (i < line.length && line[i] !== quote) {
212+
out += BLANK;
213+
i++;
214+
}
215+
if (i < line.length) {
216+
out += line[i]!;
217+
i++;
218+
state.openQuote = null;
219+
} else {
220+
return out;
221+
}
222+
}
223+
132224
while (i < line.length) {
133225
const ch = line[i]!;
226+
227+
// Heredoc introducer: `<<EOF`, `<<-EOF`, `<<'EOF'`, `<<"EOF"`. The body
228+
// starts on the NEXT line, so record it and keep scanning this one.
229+
if (ch === '<' && line[i + 1] === '<') {
230+
const m = /^<<-?\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line.slice(i));
231+
if (m) {
232+
state.heredoc = m[2]!;
233+
out += line.slice(i, i + m[0].length);
234+
i += m[0].length;
235+
continue;
236+
}
237+
}
238+
134239
if (ch !== '"' && ch !== "'") {
135240
out += ch;
136241
i++;
137242
continue;
138243
}
244+
139245
const quote = ch;
140246
out += ch;
141247
i++;
142248
while (i < line.length && line[i] !== quote) {
143249
// `\"` inside a double-quoted span is an escaped quote, not the closer.
144250
if (quote === '"' && line[i] === '\\' && i + 1 < line.length) {
145-
out += ' ';
251+
out += BLANK + BLANK;
146252
i += 2;
147253
continue;
148254
}
149-
// A command substitution runs a real command — keep it verbatim and let
150-
// the tokenizer see it. Single quotes suppress expansion, so only the
255+
// A command substitution runs a real command — keep it verbatim so the
256+
// tokenizer sees it. Single quotes suppress expansion, so only the
151257
// double-quoted case substitutes.
152258
if (quote === '"' && line[i] === '$' && line[i + 1] === '(') {
153259
let depth = 0;
260+
let inner: '"' | "'" | null = null;
154261
while (i < line.length) {
155-
if (line[i] === '(') depth++;
156-
else if (line[i] === ')') {
262+
const c = line[i]!;
263+
// Track quotes INSIDE the substitution so a `)` in a quoted argument
264+
// (`--query "a)b"`) does not close it early and blank the remainder.
265+
if (inner !== null) {
266+
if (c === inner) inner = null;
267+
} else if (c === '"' || c === "'") {
268+
inner = c;
269+
} else if (c === '(') {
270+
depth++;
271+
} else if (c === ')') {
157272
depth--;
158273
if (depth === 0) {
159-
out += line[i]!;
274+
out += c;
160275
i++;
161276
break;
162277
}
163278
}
164-
out += line[i]!;
279+
out += c;
165280
i++;
166281
}
167282
continue;
168283
}
169-
out += ' ';
284+
out += BLANK;
170285
i++;
171286
}
172287
if (i < line.length) {
173288
out += line[i]!;
174289
i++;
290+
} else {
291+
// Unterminated on this line — the span continues onto the next.
292+
state.openQuote = quote;
175293
}
176294
}
177295
return out;
@@ -183,25 +301,34 @@ export function blankQuotedSpans(line: string): string {
183301
* Deliberately NOT anchored at a command start. The repo's canonical
184302
* gone-probe helpers take the probe as ARGUMENTS —
185303
* `assert_gone "<desc>" aws s3api head-object ...` — so a command-start anchor
186-
* would skip every destroy assertion in the tree, which is a large and
187-
* high-value share of the `aws` calls. Scanning for the `aws` token anywhere in
188-
* a segment covers the plain form, the helper-argument form, `$( ... )`
189-
* substitutions, and `if ! aws ...` conditions with one rule.
304+
* would skip every destroy assertion in the tree (414 invocations), a large and
305+
* high-value share. Scanning for the `aws` token anywhere in a segment covers
306+
* the plain form, the helper-argument form, `$( ... )` substitutions, and
307+
* `if ! aws ...` conditions with one rule.
190308
*
191-
* Comments are stripped BEFORE matching (both trailing and whole-line): three
309+
* Comments are stripped BEFORE matching (both trailing and whole-line): two
192310
* `verify.sh` files discuss `aws emr list-instance-groups` in prose explaining
193311
* why they avoid it, and flagging those would make the check unusable.
194312
*/
195313
export function extractAwsInvocations(content: string): AwsInvocation[] {
196314
const joined = joinContinuedLines(content);
197315
const invocations: AwsInvocation[] = [];
316+
// ONE state for the whole file: quote / heredoc context spans lines (the
317+
// multi-line `node -e "..."` blocks these fixtures embed).
318+
const scan = newScanState();
198319

199320
for (let i = 0; i < joined.length; i++) {
200321
const { text, line } = joined[i]!;
201-
// The hatch lives in a COMMENT, so it is read from the part
202-
// `stripTrailingComment` removes — NOT from the raw line, which would also
203-
// honor the marker when it merely appears inside a string literal.
204-
const stripped = stripTrailingComment(text);
322+
323+
// Inside a multi-line string or heredoc there is no shell comment to
324+
// strip — `#` is data there — so blank first and skip the comment logic.
325+
const insideNonCode = scan.openQuote !== null || scan.heredoc !== null;
326+
const stripped = insideNonCode ? text : stripTrailingComment(text);
327+
const blanked = blankQuotedSpans(stripped, scan);
328+
if (insideNonCode) continue;
329+
330+
// The hatch is read from the part `stripTrailingComment` removed — NOT
331+
// from the raw line, which would also honor a marker inside a string.
205332
const ownComment = text.slice(stripped.length);
206333
const prevText = joined[i - 1]?.text ?? '';
207334
// On the line above, require a WHOLE-line comment: a marker trailing a
@@ -211,33 +338,18 @@ export function extractAwsInvocations(content: string): AwsInvocation[] {
211338
ownComment.includes(ALLOW_MARKER) ||
212339
(prevIsCommentLine && prevText.includes(ALLOW_MARKER));
213340

214-
for (const segment of splitShellCommands(blankQuotedSpans(stripped))) {
341+
for (const segment of splitShellCommands(blanked)) {
215342
const tokens = segment.split(TOKEN_SEPARATOR).filter(Boolean);
216343
for (let t = 0; t < tokens.length - 2; t++) {
217344
if (tokens[t] !== 'aws') continue;
218345

219-
// Step past any leading GLOBAL options — `aws --region us-east-1 emr
220-
// list-clusters` must resolve to (emr, list-clusters), not
221-
// (us-east-1, emr). Without this the invocation silently vanishes:
222-
// `NAME` rejects `--region` and the whole call goes unchecked.
223-
let k = t + 1;
224-
while (k < tokens.length && tokens[k]!.startsWith('-')) {
225-
const flag = tokens[k]!.split('=')[0]!;
226-
k++;
227-
// `--flag=value` already carries its value; a bare value-taking flag
228-
// consumes the next token.
229-
if (
230-
VALUE_TAKING_GLOBAL_OPTIONS.has(flag) &&
231-
!tokens[k - 1]!.includes('=') &&
232-
k < tokens.length &&
233-
!tokens[k]!.startsWith('-')
234-
) {
235-
k++;
236-
}
237-
}
346+
// Step past GLOBAL options, which the AWS CLI accepts BOTH before the
347+
// service (`aws --region us-east-1 emr list-clusters`) and between the
348+
// service and the verb (`aws emr --region us-east-1 list-clusters`).
349+
const serviceIdx = skipOptions(tokens, t + 1);
350+
const service = tokens[serviceIdx];
351+
const verb = tokens[skipOptions(tokens, serviceIdx + 1)];
238352

239-
const service = tokens[k];
240-
const verb = tokens[k + 1];
241353
// A variable or otherwise unclassifiable token means this is not a
242354
// plain `aws <service> <verb>` we can judge statically — skip rather
243355
// than guess.
@@ -315,19 +427,33 @@ export function lintScriptAwsCommands(
315427
return violations;
316428
}
317429

430+
/**
431+
* Every fixture that has a `verify.sh`, as `{ fixture, content }`.
432+
*
433+
* Exported so the coverage floors measure the SAME enumeration the lint walks.
434+
* When the test re-implemented this walk, breaking the filter here left the
435+
* suite fully green: `lintFixtureTreeAwsCommands` would yield zero violations
436+
* (vacuously satisfying the "no fixture calls a removed verb" assertion) while
437+
* the floors kept passing against their private copy of the walk. That is the
438+
* "0 violations and parsed nothing at all look identical" failure mode
439+
* `.claude/rules/testing.md` exists to prevent, reintroduced one level up.
440+
*/
441+
export function readFixtureScripts(integRoot: string): { fixture: string; content: string }[] {
442+
return readdirSync(integRoot, { withFileTypes: true })
443+
.filter((e) => e.isDirectory() && existsSync(join(integRoot, e.name, 'verify.sh')))
444+
.map((e) => ({
445+
fixture: e.name,
446+
content: readFileSync(join(integRoot, e.name, 'verify.sh'), 'utf8'),
447+
}));
448+
}
449+
318450
export function lintFixtureTreeAwsCommands(
319451
integRoot: string,
320452
table: RemovedCommands
321453
): AwsCommandViolation[] {
322-
return readdirSync(integRoot, { withFileTypes: true })
323-
.filter((e) => e.isDirectory() && existsSync(join(integRoot, e.name, 'verify.sh')))
324-
.flatMap((e) =>
325-
lintScriptAwsCommands(
326-
e.name,
327-
readFileSync(join(integRoot, e.name, 'verify.sh'), 'utf8'),
328-
table
329-
)
330-
);
454+
return readFixtureScripts(integRoot).flatMap(({ fixture, content }) =>
455+
lintScriptAwsCommands(fixture, content, table)
456+
);
331457
}
332458

333459
export function formatAwsCommandViolation(v: AwsCommandViolation): string {

scripts/refresh-aws-cli-removals.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,14 @@ export function findAwsCliPackageRoot(explicitRoot?: string): string {
9090
if (!awsBin) throw new Error('aws CLI not found on PATH — pass --aws-root <awscli package dir>');
9191
// `aws` is usually a symlink into the versioned install; resolve it, then walk
9292
// up looking for the packaged `awscli` directory.
93-
const real = realpathSync(awsBin);
93+
// A dangling symlink makes realpathSync throw — right after the careful
94+
// try/catch above, which would be an odd place to lose the actionable error.
95+
let real: string;
96+
try {
97+
real = realpathSync(awsBin);
98+
} catch {
99+
real = awsBin;
100+
}
94101
let dir = dirname(real);
95102
for (let i = 0; i < 8; i++) {
96103
const candidates = [
@@ -129,8 +136,14 @@ export function buildFixture(pkgRoot: string): RemovedCommandsFixture {
129136
// so a reordered or interleaved `remove()` call would drop that service
130137
// SILENTLY; a bare `size === 0` guard only catches total failure, which is
131138
// the vacuous-pass shape `.claude/rules/testing.md` warns about.
139+
// Scoped to lines that actually pass the event to `remove(...)`: a future
140+
// DOC mention of `building-command-table.x` in a comment or docstring would
141+
// otherwise throw a misleading "parser is stale" and block the refresh.
132142
const declaredEvents = new Set(
133-
[...source.matchAll(/building-command-table\.([a-z0-9-]+)/g)].map((m) => m[1]!)
143+
source
144+
.split('\n')
145+
.filter((l) => l.includes('on_event') && !l.trimStart().startsWith('#'))
146+
.flatMap((l) => [...l.matchAll(/building-command-table\.([a-z0-9-]+)/g)].map((m) => m[1]!))
134147
);
135148
if (parsed.size === 0) {
136149
throw new Error(`parsed 0 removals from ${pkgRoot}/customizations/removals.py — parser is stale`);
@@ -165,11 +178,19 @@ const FIXTURE_PATH = join(import.meta.dirname, '../tests/fixtures/aws-cli-remove
165178
function main(): void {
166179
const argv = process.argv.slice(2);
167180
const check = argv.includes('--check');
181+
// Accept BOTH `--aws-root <path>` and `--aws-root=<path>`; recognising only
182+
// the bare flag meant the `=` form fell through to the PATH lookup, silently
183+
// defeating the validation below.
184+
const inlineRoot = argv.find((a) => a.startsWith('--aws-root='));
168185
const rootIdx = argv.indexOf('--aws-root');
169-
const rootValue = rootIdx >= 0 ? argv[rootIdx + 1] : undefined;
186+
const rootValue = inlineRoot
187+
? inlineRoot.slice('--aws-root='.length)
188+
: rootIdx >= 0
189+
? argv[rootIdx + 1]
190+
: undefined;
170191
// `--aws-root` with no value (or followed by another flag) must ERROR, not
171192
// silently fall back to PATH — or worse, take `--check` as the root path.
172-
if (rootIdx >= 0 && (rootValue === undefined || rootValue.startsWith('-'))) {
193+
if ((rootIdx >= 0 || inlineRoot) && (!rootValue || rootValue.startsWith('-'))) {
173194
console.error('--aws-root requires a path argument (the awscli package directory)');
174195
process.exit(1);
175196
}

0 commit comments

Comments
 (0)