Skip to content

Commit 7a81b88

Browse files
committed
feat(cli): --ci aggregate flag on query + audit (Slice 1b of #73 plan)
Second half of Slice 1. `--ci` is the GitHub-Action-shaped CI flag: - Aliases `--format sarif` - Sets process.exitCode = 1 when ≥1 finding/addition surfaced - Suppresses the no-locatable-rows stderr warning (CI templates would surface it as red noise; the row-set itself is the gating signal) Lands on both `query` and `audit` (the two finding-producing verbs). Same parser / resolver semantics on both: - Mutually exclusive with `--json` (different format aliases) - Mutually exclusive with `--format <other>` (contradicts the alias) - `--ci --format sarif` redundant but accepted (consumers may set both for clarity in CI templates) Wiring: - `parseQueryRest` / `parseAuditRest` gain `--ci` token + `ci: boolean` in the run-shape union. - `runQueryCmd` / `runAuditCmd` gain `ci?: boolean` opt; threaded through to `printFormattedQuery` (query) and the post-render exit-code branch (audit). - `query`: exit 1 if `rows.length > 0` after SARIF emit. - `audit`: exit 1 if any delta has `added.length > 0` after SARIF emit. Tests: - 4 new `cmd-query` parser tests (--ci alias; --ci+--json reject; --ci+--format json reject; --ci+--format sarif accept). - 4 new `cmd-audit` parser tests (same matrix). - All existing toEqual tests updated for the `ci: false` field default. Smoke verified end-to-end: - `query --ci` with results → SARIF stdout, exit 1. - `audit --baseline X --ci` against identical baseline → 0 additions, exit 0. Diff with adds → exit 1. - Contradiction tests (`--ci --json`) emit clear error + exit 1.
1 parent b65df6a commit 7a81b88

5 files changed

Lines changed: 217 additions & 9 deletions

File tree

src/cli/cmd-audit.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe("parseAuditRest", () => {
4545
base: undefined,
4646
perDelta: {},
4747
format: "text",
48+
ci: false,
4849
summary: false,
4950
noIndex: false,
5051
});
@@ -58,6 +59,7 @@ describe("parseAuditRest", () => {
5859
base: undefined,
5960
perDelta: {},
6061
format: "text",
62+
ci: false,
6163
summary: false,
6264
noIndex: false,
6365
});
@@ -79,6 +81,7 @@ describe("parseAuditRest", () => {
7981
base: undefined,
8082
perDelta: { files: "X", dependencies: "Y", deprecated: "Z" },
8183
format: "text",
84+
ci: false,
8285
summary: false,
8386
noIndex: false,
8487
});
@@ -98,6 +101,7 @@ describe("parseAuditRest", () => {
98101
base: undefined,
99102
perDelta: { dependencies: "experimental-deps" },
100103
format: "text",
104+
ci: false,
101105
summary: false,
102106
noIndex: false,
103107
});
@@ -175,6 +179,46 @@ describe("parseAuditRest", () => {
175179
expect(r.format).toBe("json");
176180
});
177181

182+
it("parses --ci as alias for --format sarif + ci flag", () => {
183+
const r = parseAuditRest(["audit", "--ci", "--baseline", "base"]);
184+
if (r.kind !== "run") throw new Error("expected run");
185+
expect(r.format).toBe("sarif");
186+
expect(r.ci).toBe(true);
187+
});
188+
189+
it("rejects --ci + --json (mutually exclusive aliases)", () => {
190+
const r = parseAuditRest(["audit", "--ci", "--json", "--baseline", "base"]);
191+
expect(r.kind).toBe("error");
192+
if (r.kind === "error") expect(r.message).toContain("--ci");
193+
});
194+
195+
it("rejects --ci + --format json (contradicting alias)", () => {
196+
const r = parseAuditRest([
197+
"audit",
198+
"--ci",
199+
"--format",
200+
"json",
201+
"--baseline",
202+
"base",
203+
]);
204+
expect(r.kind).toBe("error");
205+
if (r.kind === "error") expect(r.message).toContain("--ci");
206+
});
207+
208+
it("accepts --ci + --format sarif (redundant but consistent)", () => {
209+
const r = parseAuditRest([
210+
"audit",
211+
"--ci",
212+
"--format",
213+
"sarif",
214+
"--baseline",
215+
"base",
216+
]);
217+
if (r.kind !== "run") throw new Error("expected run");
218+
expect(r.format).toBe("sarif");
219+
expect(r.ci).toBe(true);
220+
});
221+
178222
it("errors when --baseline has no value", () => {
179223
const r = parseAuditRest(["audit", "--baseline"]);
180224
expect(r.kind).toBe("error");
@@ -219,6 +263,7 @@ describe("parseAuditRest", () => {
219263
base: "origin/main",
220264
perDelta: {},
221265
format: "text",
266+
ci: false,
222267
summary: false,
223268
noIndex: false,
224269
});

src/cli/cmd-audit.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export function parseAuditRest(rest: string[]):
4444
base: string | undefined;
4545
perDelta: Record<string, string>;
4646
format: AuditOutputFormat;
47+
/** `--ci` was set: SARIF + non-zero exit when any delta has additions. */
48+
ci: boolean;
4749
summary: boolean;
4850
noIndex: boolean;
4951
} {
@@ -56,6 +58,9 @@ export function parseAuditRest(rest: string[]):
5658
// `--json` so we can reject `--json --format sarif` as a contradiction.
5759
let jsonShortcut = false;
5860
let format: AuditOutputFormat | undefined;
61+
// `--ci` is the CI-aggregate flag: aliases `--format sarif` + non-zero
62+
// exit-on-issue + suppresses chatty stderr. Plan: docs/plans/github-marketplace-action.md (Slice 1b).
63+
let ci = false;
5964
let summary = false;
6065
let noIndex = false;
6166
let baselinePrefix: string | undefined;
@@ -70,6 +75,11 @@ export function parseAuditRest(rest: string[]):
7075
i++;
7176
continue;
7277
}
78+
if (a === "--ci") {
79+
ci = true;
80+
i++;
81+
continue;
82+
}
7383
if (a === "--format" || a.startsWith("--format=")) {
7484
const value = consumeFlagValue(rest, i, "--format");
7585
if (value.kind === "error") return value;
@@ -153,10 +163,26 @@ export function parseAuditRest(rest: string[]):
153163
};
154164
}
155165

156-
// Reconcile --json shortcut with --format. Both → must agree on `json`.
157-
// Neither → default to `text`.
166+
// Reconcile --json / --ci / --format. `--ci` aliases `--format sarif`; mutually
167+
// exclusive with --json (different format aliases) and with --format <other>
168+
// (contradicts the alias). `--ci --format sarif` is redundant but accepted.
158169
let resolvedFormat: AuditOutputFormat;
159-
if (jsonShortcut && format !== undefined) {
170+
if (ci) {
171+
if (jsonShortcut) {
172+
return {
173+
kind: "error",
174+
message:
175+
'codemap audit: "--ci" and "--json" are mutually exclusive (--ci aliases --format sarif; --json aliases --format json).',
176+
};
177+
}
178+
if (format !== undefined && format !== "sarif") {
179+
return {
180+
kind: "error",
181+
message: `codemap audit: "--ci" aliases "--format sarif"; cannot combine with --format ${format}.`,
182+
};
183+
}
184+
resolvedFormat = "sarif";
185+
} else if (jsonShortcut && format !== undefined) {
160186
if (format !== "json") {
161187
return {
162188
kind: "error",
@@ -176,6 +202,7 @@ export function parseAuditRest(rest: string[]):
176202
base,
177203
perDelta,
178204
format: resolvedFormat,
205+
ci,
179206
summary,
180207
noIndex,
181208
};
@@ -262,6 +289,10 @@ Other flags:
262289
one result per added row) for GitHub Code Scanning.
263290
--json Shortcut for --format json. Cannot combine with --format
264291
<other>. Emits {head, deltas} envelope; on error: {"error":"<message>"}.
292+
--ci CI-aggregate flag. Aliases --format sarif + non-zero exit
293+
when any delta has additions. Mutually exclusive with --json
294+
and --format <other>. Recommended in GitHub Actions / GitLab
295+
CI to fail the runner step on structural drift.
265296
--summary Collapse rows to counts. With --format json: deltas.<key>.{added: N, removed: N}.
266297
With --format text: a single line "drift: files +1/-0, dependencies +3/-2, ...".
267298
No-op with --format sarif (results are per-row).
@@ -312,6 +343,8 @@ export async function runAuditCmd(opts: {
312343
base: string | undefined;
313344
perDelta: Record<string, string>;
314345
format: AuditOutputFormat;
346+
/** `--ci`: exit non-zero when any delta has `added.length > 0`. */
347+
ci?: boolean;
315348
summary: boolean;
316349
noIndex: boolean;
317350
}): Promise<void> {
@@ -351,6 +384,16 @@ export async function runAuditCmd(opts: {
351384
return;
352385
}
353386
renderAudit(result, { format: opts.format, summary: opts.summary });
387+
388+
// `--ci`: exit non-zero when any delta has additions. SARIF results
389+
// already surfaced via Code Scanning; non-zero exit fails the runner
390+
// step so the workflow gates the PR.
391+
if (opts.ci === true) {
392+
const hasAdditions = Object.values(result.deltas).some(
393+
(d) => d.added.length > 0,
394+
);
395+
if (hasAdditions) process.exitCode = 1;
396+
}
354397
} finally {
355398
closeDb(db, { readonly: opts.noIndex });
356399
}

src/cli/cmd-query.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe("parseQueryRest", () => {
2626
sql: "SELECT 1",
2727
json: false,
2828
format: "text",
29+
ci: false,
2930
summary: false,
3031
changedSince: undefined,
3132
recipeId: undefined,
@@ -42,6 +43,7 @@ describe("parseQueryRest", () => {
4243
sql: "SELECT 1",
4344
json: true,
4445
format: "json",
46+
ci: false,
4547
summary: false,
4648
changedSince: undefined,
4749
recipeId: undefined,
@@ -58,6 +60,7 @@ describe("parseQueryRest", () => {
5860
sql: "SELECT 1",
5961
json: false,
6062
format: "text",
63+
ci: false,
6164
summary: true,
6265
changedSince: undefined,
6366
recipeId: undefined,
@@ -74,6 +77,7 @@ describe("parseQueryRest", () => {
7477
sql: "SELECT 1",
7578
json: true,
7679
format: "json",
80+
ci: false,
7781
summary: true,
7882
changedSince: undefined,
7983
recipeId: undefined,
@@ -92,6 +96,7 @@ describe("parseQueryRest", () => {
9296
sql: sql!,
9397
json: false,
9498
format: "text",
99+
ci: false,
95100
summary: true,
96101
changedSince: undefined,
97102
recipeId: "fan-out",
@@ -113,6 +118,7 @@ describe("parseQueryRest", () => {
113118
sql: "SELECT 1",
114119
json: false,
115120
format: "text",
121+
ci: false,
116122
summary: false,
117123
changedSince: "origin/main",
118124
recipeId: undefined,
@@ -138,6 +144,7 @@ describe("parseQueryRest", () => {
138144
sql: sql!,
139145
json: true,
140146
format: "json",
147+
ci: false,
141148
summary: false,
142149
changedSince: "HEAD~3",
143150
recipeId: "fan-out",
@@ -160,6 +167,7 @@ describe("parseQueryRest", () => {
160167
sql: "SELECT * FROM symbols",
161168
json: true,
162169
format: "json",
170+
ci: false,
163171
summary: false,
164172
changedSince: undefined,
165173
recipeId: undefined,
@@ -178,6 +186,7 @@ describe("parseQueryRest", () => {
178186
sql: sql!,
179187
json: false,
180188
format: "text",
189+
ci: false,
181190
summary: false,
182191
changedSince: undefined,
183192
recipeId: "fan-in",
@@ -187,6 +196,52 @@ describe("parseQueryRest", () => {
187196
});
188197
});
189198

199+
it("parses --ci as alias for --format sarif + ci flag", () => {
200+
const r = parseQueryRest(["query", "--ci", "-r", "deprecated-symbols"]);
201+
if (r.kind !== "run") throw new Error("expected run");
202+
expect(r.format).toBe("sarif");
203+
expect(r.ci).toBe(true);
204+
});
205+
206+
it("rejects --ci + --json (mutually exclusive aliases)", () => {
207+
const r = parseQueryRest([
208+
"query",
209+
"--ci",
210+
"--json",
211+
"-r",
212+
"deprecated-symbols",
213+
]);
214+
expect(r.kind).toBe("error");
215+
if (r.kind === "error") expect(r.message).toContain("--ci");
216+
});
217+
218+
it("rejects --ci + --format json (contradicting alias)", () => {
219+
const r = parseQueryRest([
220+
"query",
221+
"--ci",
222+
"--format",
223+
"json",
224+
"-r",
225+
"deprecated-symbols",
226+
]);
227+
expect(r.kind).toBe("error");
228+
if (r.kind === "error") expect(r.message).toContain("--ci");
229+
});
230+
231+
it("accepts --ci + --format sarif (redundant but consistent)", () => {
232+
const r = parseQueryRest([
233+
"query",
234+
"--ci",
235+
"--format",
236+
"sarif",
237+
"-r",
238+
"deprecated-symbols",
239+
]);
240+
if (r.kind !== "run") throw new Error("expected run");
241+
expect(r.format).toBe("sarif");
242+
expect(r.ci).toBe(true);
243+
});
244+
190245
it("errors when --group-by has no mode", () => {
191246
const r = parseQueryRest(["query", "--group-by"]);
192247
expect(r.kind).toBe("error");
@@ -382,6 +437,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", (
382437
sql: sql!,
383438
json: false,
384439
format: "text",
440+
ci: false,
385441
summary: false,
386442
changedSince: undefined,
387443
recipeId: "fan-out-sample-json",
@@ -400,6 +456,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", (
400456
sql: sql!,
401457
json: false,
402458
format: "text",
459+
ci: false,
403460
summary: false,
404461
changedSince: undefined,
405462
recipeId: "fan-out",
@@ -465,6 +522,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", (
465522
sql: sql!,
466523
json: true,
467524
format: "json",
525+
ci: false,
468526
summary: false,
469527
changedSince: undefined,
470528
recipeId: "fan-out-sample",
@@ -483,6 +541,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", (
483541
sql: sql!,
484542
json: true,
485543
format: "json",
544+
ci: false,
486545
summary: false,
487546
changedSince: undefined,
488547
recipeId: "fan-out",
@@ -501,6 +560,7 @@ describe("parseQueryRest (continued — these were mis-nested in a prior PR)", (
501560
sql: sql!,
502561
json: true,
503562
format: "json",
563+
ci: false,
504564
summary: false,
505565
changedSince: undefined,
506566
recipeId: "fan-out",

0 commit comments

Comments
 (0)