Skip to content

Commit 3747d69

Browse files
committed
test(integ): harden the aws-command extractor against review findings
Review of the first pass found three real defects, all verified against the current tree. FALSE POSITIVES (the worst of the three). Only `#` comments were stripped, so prose and data parsed as invocations: echo "==> Verifying deploy via aws cli (sanity)" -> aws cli sanity arn:aws:s3:::my-bucket -> aws s3 my-bucket Tags[?Key==`aws:cdk:path`] -> aws cdk path Harmless only because those verbs are not in the removal table — a fixture's own `echo "... aws emr list-instance-groups ..."` explanation would have become a CI-blocking violation curable only by DELETING the explanation, the exact outcome comment-stripping exists to prevent. They also inflated the coverage floors with non-invocations (2687 -> 2640 real; services 70 -> 68, verbs 362 -> 355). Fixed by blanking quoted spans while PRESERVING $( ) command substitutions — both halves load-bearing, since the capture form lives inside double quotes and is the largest shape in the tree (1028 invocations, unchanged by the fix) — and by keeping `:` inside tokens so an ARN or a JMESPath backtick literal stays one token instead of splitting out a bare `aws`. FALSE NEGATIVE: `aws --region us-east-1 emr list-instance-groups` vanished SILENTLY — the service slot held `--region`, NAME rejected it, and the call went unchecked with no diagnostic. Now steps past leading global options, consuming the value of the value-taking ones. ESCAPE-HATCH LEAKS: the marker was matched on raw line text, so it counted inside a string literal, and "the line above" accepted a marker trailing the PREVIOUS command. Now read only from the stripped comment, and the line-above form requires a whole-line comment. Also in refresh-aws-cli-removals.ts: `command -v aws` THROWS when absent so the actionable not-found message was unreachable; `--check` threw on a corrupt fixture instead of reporting stale; `--aws-root` with no value silently fell back to PATH (or took `--check` as the path); and the parser now reconciles against an independent count of building-command-table events, so a reordered remove() call fails loudly instead of dropping a service silently. Real-code fail probe re-run through the hardened parser: injecting BOTH the capture form and the leading-global-option form into the real emr-cluster/verify.sh flagged both, each named by file:line; restored after.
1 parent 67cbe52 commit 3747d69

3 files changed

Lines changed: 230 additions & 21 deletions

File tree

scripts/check-integ-aws-commands.ts

Lines changed: 129 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,96 @@ const NAME = /^[a-z][a-z0-9-]*$/;
8686
* being read as a verb; keeping `/` is what stops `s3://bucket` from splitting
8787
* into a service-looking `s3`.
8888
*/
89-
const TOKEN_SEPARATOR = /[^A-Za-z0-9._/-]+/;
89+
const TOKEN_SEPARATOR = /[^A-Za-z0-9._/:-]+/;
90+
91+
/**
92+
* AWS CLI global options that CONSUME the following token, so a leading
93+
* `aws --region us-east-1 emr <verb>` is not read as service `us-east-1`.
94+
* Boolean globals (`--debug`, `--no-cli-pager`, ...) need no entry — they are
95+
* skipped by the generic "starts with `-`" rule.
96+
*/
97+
const VALUE_TAKING_GLOBAL_OPTIONS: ReadonlySet<string> = new Set([
98+
'--region',
99+
'--profile',
100+
'--endpoint-url',
101+
'--output',
102+
'--query',
103+
'--color',
104+
'--ca-bundle',
105+
'--cli-read-timeout',
106+
'--cli-connect-timeout',
107+
'--cli-binary-format',
108+
]);
109+
110+
/**
111+
* Blanks out quoted spans so their contents are not mistaken for commands,
112+
* while PRESERVING `$( ... )` command substitutions inside them.
113+
*
114+
* Both halves are load-bearing. Without blanking, prose and data parse as
115+
* invocations — confirmed live in this tree: `echo "==> Verifying deploy via
116+
* aws cli (sanity)"` read as `aws cli sanity`, and `arn:aws:s3:::bucket` read
117+
* as `aws s3 bucket`. That inflates the coverage floors with non-invocations
118+
* and, worse, would make a fixture's own `echo "... aws emr
119+
* list-instance-groups ..."` explanation a CI-blocking violation whose only
120+
* cure is deleting the explanation — the outcome comment-stripping exists to
121+
* prevent. And without preserving `$( )`, the capture form
122+
* `IDS="$(aws emr list-clusters)"` — over a thousand invocations, the single
123+
* largest shape in the tree — would vanish wholesale.
124+
*
125+
* Note `:` is INSIDE {@link TOKEN_SEPARATOR}'s allowed set for the same
126+
* family of reasons: it keeps `arn:aws:...` and a JMESPath `` `aws:cdk:path` ``
127+
* as ONE token rather than splitting out a bare `aws`.
128+
*/
129+
export function blankQuotedSpans(line: string): string {
130+
let out = '';
131+
let i = 0;
132+
while (i < line.length) {
133+
const ch = line[i]!;
134+
if (ch !== '"' && ch !== "'") {
135+
out += ch;
136+
i++;
137+
continue;
138+
}
139+
const quote = ch;
140+
out += ch;
141+
i++;
142+
while (i < line.length && line[i] !== quote) {
143+
// `\"` inside a double-quoted span is an escaped quote, not the closer.
144+
if (quote === '"' && line[i] === '\\' && i + 1 < line.length) {
145+
out += ' ';
146+
i += 2;
147+
continue;
148+
}
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
151+
// double-quoted case substitutes.
152+
if (quote === '"' && line[i] === '$' && line[i + 1] === '(') {
153+
let depth = 0;
154+
while (i < line.length) {
155+
if (line[i] === '(') depth++;
156+
else if (line[i] === ')') {
157+
depth--;
158+
if (depth === 0) {
159+
out += line[i]!;
160+
i++;
161+
break;
162+
}
163+
}
164+
out += line[i]!;
165+
i++;
166+
}
167+
continue;
168+
}
169+
out += ' ';
170+
i++;
171+
}
172+
if (i < line.length) {
173+
out += line[i]!;
174+
i++;
175+
}
176+
}
177+
return out;
178+
}
90179

91180
/**
92181
* Pulls each `aws <service> <verb>` out of a verify.sh.
@@ -109,23 +198,50 @@ export function extractAwsInvocations(content: string): AwsInvocation[] {
109198

110199
for (let i = 0; i < joined.length; i++) {
111200
const { text, line } = joined[i]!;
112-
// The hatch lives in a COMMENT, so it has to be read off the raw text
113-
// before the comment is stripped. Accept it on the invocation's own line or
114-
// the line immediately above (the idiomatic placement for a long rationale).
115-
const allowed = text.includes(ALLOW_MARKER) || (joined[i - 1]?.text.includes(ALLOW_MARKER) ?? false);
116-
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.
117204
const stripped = stripTrailingComment(text);
205+
const ownComment = text.slice(stripped.length);
206+
const prevText = joined[i - 1]?.text ?? '';
207+
// On the line above, require a WHOLE-line comment: a marker trailing a
208+
// previous command is about that command, not this one.
209+
const prevIsCommentLine = prevText.trimStart().startsWith('#');
210+
const allowed =
211+
ownComment.includes(ALLOW_MARKER) ||
212+
(prevIsCommentLine && prevText.includes(ALLOW_MARKER));
118213

119-
for (const segment of splitShellCommands(stripped)) {
214+
for (const segment of splitShellCommands(blankQuotedSpans(stripped))) {
120215
const tokens = segment.split(TOKEN_SEPARATOR).filter(Boolean);
121216
for (let t = 0; t < tokens.length - 2; t++) {
122217
if (tokens[t] !== 'aws') continue;
123-
const service = tokens[t + 1]!;
124-
const verb = tokens[t + 2]!;
125-
// A flag or a variable right after `aws` means this is not a plain
126-
// `aws <service> <verb>` we can classify statically — skip rather than
127-
// guess (an unclassifiable invocation is reported by the coverage
128-
// floors, not by a wrong verdict).
218+
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+
}
238+
239+
const service = tokens[k];
240+
const verb = tokens[k + 1];
241+
// A variable or otherwise unclassifiable token means this is not a
242+
// plain `aws <service> <verb>` we can judge statically — skip rather
243+
// than guess.
244+
if (service === undefined || verb === undefined) continue;
129245
if (!NAME.test(service) || !NAME.test(verb)) continue;
130246
invocations.push({ line, service, verb, raw: segment.trim(), allowed });
131247
}

scripts/refresh-aws-cli-removals.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,15 @@ export function findAwsCliPackageRoot(explicitRoot?: string): string {
7878
return explicitRoot;
7979
}
8080

81-
const awsBin = execFileSync('sh', ['-c', 'command -v aws'], { encoding: 'utf8' }).trim();
81+
// `command -v` EXITS 1 when the binary is absent, so execFileSync throws
82+
// before any empty-string check could run — catch it and surface the
83+
// actionable message instead of a raw `Command failed: sh -c command -v aws`.
84+
let awsBin = '';
85+
try {
86+
awsBin = execFileSync('sh', ['-c', 'command -v aws'], { encoding: 'utf8' }).trim();
87+
} catch {
88+
awsBin = '';
89+
}
8290
if (!awsBin) throw new Error('aws CLI not found on PATH — pass --aws-root <awscli package dir>');
8391
// `aws` is usually a symlink into the versioned install; resolve it, then walk
8492
// up looking for the packaged `awscli` directory.
@@ -116,9 +124,24 @@ function pythonSitePackages(dir: string): string[] {
116124
export function buildFixture(pkgRoot: string): RemovedCommandsFixture {
117125
const source = readFileSync(join(pkgRoot, 'customizations', 'removals.py'), 'utf8');
118126
const parsed = parseRemovalsSource(source);
127+
// Reconcile against an INDEPENDENT count of the events in the file. The
128+
// regex requires `on_event` to be immediately followed by `remove_commands`,
129+
// so a reordered or interleaved `remove()` call would drop that service
130+
// SILENTLY; a bare `size === 0` guard only catches total failure, which is
131+
// the vacuous-pass shape `.claude/rules/testing.md` warns about.
132+
const declaredEvents = new Set(
133+
[...source.matchAll(/building-command-table\.([a-z0-9-]+)/g)].map((m) => m[1]!)
134+
);
119135
if (parsed.size === 0) {
120136
throw new Error(`parsed 0 removals from ${pkgRoot}/customizations/removals.py — parser is stale`);
121137
}
138+
const missed = [...declaredEvents].filter((s) => !parsed.has(s));
139+
if (missed.length > 0) {
140+
throw new Error(
141+
`parser missed ${missed.length} command-table removal event(s) (${missed.join(', ')}) in ` +
142+
`${pkgRoot}/customizations/removals.py — the AWS CLI changed the call shape, update parseRemovalsSource`
143+
);
144+
}
122145
let version = 'unknown';
123146
try {
124147
version = execFileSync('aws', ['--version'], { encoding: 'utf8' }).trim();
@@ -143,19 +166,33 @@ function main(): void {
143166
const argv = process.argv.slice(2);
144167
const check = argv.includes('--check');
145168
const rootIdx = argv.indexOf('--aws-root');
146-
const explicitRoot = rootIdx >= 0 ? argv[rootIdx + 1] : undefined;
169+
const rootValue = rootIdx >= 0 ? argv[rootIdx + 1] : undefined;
170+
// `--aws-root` with no value (or followed by another flag) must ERROR, not
171+
// silently fall back to PATH — or worse, take `--check` as the root path.
172+
if (rootIdx >= 0 && (rootValue === undefined || rootValue.startsWith('-'))) {
173+
console.error('--aws-root requires a path argument (the awscli package directory)');
174+
process.exit(1);
175+
}
176+
const explicitRoot = rootValue;
147177

148178
const fixture = buildFixture(findAwsCliPackageRoot(explicitRoot));
149179
const serialized = `${JSON.stringify(fixture, null, 2)}\n`;
150180

151181
if (check) {
152182
const current = existsSync(FIXTURE_PATH) ? readFileSync(FIXTURE_PATH, 'utf8') : '';
153183
// Compare the removal DATA only: the captured `aws --version` differs per
154-
// machine and must not make a same-data capture read as drift.
184+
// machine and must not make a same-data capture read as drift. A corrupt
185+
// or missing fixture reports STALE (the actionable verdict) rather than
186+
// throwing a parse error at the user.
187+
let currentRemoved: unknown;
188+
try {
189+
currentRemoved = current === '' ? undefined : (JSON.parse(current) as RemovedCommandsFixture).removed;
190+
} catch {
191+
currentRemoved = undefined;
192+
}
155193
const sameData =
156-
current !== '' &&
157-
JSON.stringify((JSON.parse(current) as RemovedCommandsFixture).removed) ===
158-
JSON.stringify(fixture.removed);
194+
currentRemoved !== undefined &&
195+
JSON.stringify(currentRemoved) === JSON.stringify(fixture.removed);
159196
if (!sameData) {
160197
console.error(
161198
`aws-cli-removed-commands.json is stale vs ${fixture.$awsCliVersion} — re-run: node scripts/refresh-aws-cli-removals.ts`

tests/unit/scripts/integ-aws-commands.test.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,46 @@ describe('extractAwsInvocations', () => {
167167
expect(extractAwsInvocations('aws --version\n')).toEqual([]);
168168
});
169169

170+
// False positives found by review against the REAL tree. None of these is an
171+
// invocation, and before the quoted-span blanking + `:`-in-token change all
172+
// three parsed as one — inflating the coverage floors with non-invocations
173+
// and, worse, making a fixture's own `echo "... aws emr list-instance-groups
174+
// ..."` explanation a CI-blocking violation curable only by deleting the
175+
// explanation.
176+
it.each([
177+
['prose in a double-quoted echo', 'echo "==> Verifying deploy via aws cli (sanity)"'],
178+
['an ARN literal', 'ARN="arn:aws:s3:::my-bucket"'],
179+
['an unquoted ARN literal', 'aws s3 rm arn:aws:s3:::my-bucket'],
180+
['a JMESPath backtick literal', "QUERY=\"Tags[?Key==\\`aws:cdk:path\\`]\""],
181+
['a single-quoted explanation', "echo 'do not call aws emr list-instance-groups'"],
182+
])('does not parse %s as an invocation', (_label, body) => {
183+
const found = extractAwsInvocations(`${body}\n`).map((i) => `${i.service} ${i.verb}`);
184+
// `aws s3 rm ...` IS a real invocation in one case — assert only that the
185+
// ARN did not additionally yield a bogus `s3 my-bucket`.
186+
expect(found.filter((f) => f !== 's3 rm')).toEqual([]);
187+
});
188+
189+
// The capture form must SURVIVE quoted-span blanking — it is the single
190+
// largest shape in the tree (~1000 invocations) and lives inside double
191+
// quotes, so blanking without preserving `$( )` would delete it wholesale.
192+
it('still sees an invocation inside a double-quoted command substitution', () => {
193+
const inv = extractAwsInvocations('IDS="$(aws emr list-clusters --active)"\n');
194+
expect(inv.map((i) => `${i.service} ${i.verb}`)).toEqual(['emr list-clusters']);
195+
});
196+
197+
// Without the global-option skip this invocation VANISHED silently — the
198+
// service slot held `--region`, `NAME` rejected it, and the call went
199+
// unchecked with no diagnostic.
200+
it.each([
201+
['--region', 'aws --region us-east-1 emr list-instance-groups --cluster-id j-A'],
202+
['--region=value', 'aws --region=us-east-1 emr list-instance-groups --cluster-id j-A'],
203+
['a boolean global', 'aws --no-cli-pager emr list-instance-groups --cluster-id j-A'],
204+
['two globals', 'aws --profile p --debug emr list-instance-groups --cluster-id j-A'],
205+
])('sees the invocation behind a leading global option: %s', (_label, body) => {
206+
const inv = extractAwsInvocations(`${body}\n`);
207+
expect(inv.map((i) => `${i.service} ${i.verb}`)).toEqual(['emr list-instance-groups']);
208+
});
209+
170210
it('marks the escape hatch on the same line and the line above', () => {
171211
const same = extractAwsInvocations(
172212
`aws emr list-instance-groups --cluster-id x # ${ALLOW_MARKER} proven to work here\n`
@@ -177,6 +217,22 @@ describe('extractAwsInvocations', () => {
177217
`# ${ALLOW_MARKER} proven to work here\naws emr list-instance-groups --cluster-id x\n`
178218
);
179219
expect(above[0]!.allowed).toBe(true);
220+
221+
// The marker must be a COMMENT, not any occurrence of the string. A
222+
// fixture that merely PRINTS the marker text must not silently disarm the
223+
// check for the command on that line.
224+
const inString = extractAwsInvocations(
225+
`echo "${ALLOW_MARKER} nope"; aws emr list-instance-groups --cluster-id x\n`
226+
);
227+
expect(inString.some((i) => i.allowed)).toBe(false);
228+
229+
// "The line above" means a WHOLE-line comment. A marker trailing the
230+
// PREVIOUS command is about that command, not the next one.
231+
const trailingAbove = extractAwsInvocations(
232+
`aws emr list-clusters # ${ALLOW_MARKER} about this line only\naws emr list-instance-groups --cluster-id x\n`
233+
);
234+
const leaked = trailingAbove.find((i) => i.verb === 'list-instance-groups');
235+
expect(leaked?.allowed).toBe(false);
180236
});
181237
});
182238

@@ -232,7 +288,7 @@ describe('integ fixture aws invocations (#1402)', () => {
232288
it('parses a substantial share of the fixture tree', () => {
233289
const stats = collectStats();
234290
expect(stats.fixtures).toBeGreaterThan(150);
235-
// Current: 2687 invocations across 221 fixtures, 70 services, 362 verbs.
291+
// Current: 2640 invocations across 221 fixtures, 68 services, 355 verbs.
236292
expect(stats.total).toBeGreaterThan(2200);
237293
expect(stats.services.size).toBeGreaterThan(55);
238294
expect(stats.verbs.size).toBeGreaterThan(290);
@@ -249,7 +305,7 @@ describe('integ fixture aws invocations (#1402)', () => {
249305
it('parses every invocation shape it claims to support', () => {
250306
const stats = collectStats();
251307
const floors: Record<keyof ReturnType<typeof collectStats>['shapes'], number> = {
252-
// Current: plain 1012, substitution 1030, helperArgument 414,
308+
// Current: plain 1008, substitution 1028, helperArgument 414,
253309
// condition 211. Floors sit ~20% under, low enough for fixture churn and
254310
// high enough that losing a shape outright cannot slip through.
255311
// `aws ...` at the start of a segment — the common form.

0 commit comments

Comments
 (0)