@cosmicdrift/kumiko-guards 0.355.0 → 0.357.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/_lib/bun-lock-packages.ts +5 -5
  3. package/src/_lib/guard-kit.ts +180 -131
  4. package/src/_lib/qn.ts +9 -9
  5. package/src/_lib/roots.ts +4 -3
  6. package/src/_lib/security-baseline.ts +193 -116
  7. package/src/_lib/sql-inventory.ts +34 -32
  8. package/src/changes.json +6 -0
  9. package/src/check-as-casts.ts +196 -180
  10. package/src/check-complexity.ts +68 -69
  11. package/src/check-predicates.ts +93 -67
  12. package/src/check-secret-literals.ts +49 -38
  13. package/src/check-single-runtime-instance.ts +1 -1
  14. package/src/cli.ts +30 -17
  15. package/src/guard-access-denied-test.ts +49 -23
  16. package/src/guard-app-feature-structure.ts +92 -73
  17. package/src/guard-broker-subscribe.ts +17 -17
  18. package/src/guard-comment-lang.ts +126 -54
  19. package/src/guard-direct-entity-writes.ts +58 -45
  20. package/src/guard-escape-hatch-declared.ts +26 -23
  21. package/src/guard-html-escape.ts +112 -86
  22. package/src/guard-i18n-keys.ts +39 -36
  23. package/src/guard-i18n-locale-mount.ts +47 -40
  24. package/src/guard-i18n-ui-strings.ts +108 -92
  25. package/src/guard-lib-test-coverage.ts +37 -37
  26. package/src/guard-loadall-events.ts +17 -17
  27. package/src/guard-no-framed-extension-sections.ts +4 -4
  28. package/src/guard-no-temporal-polyfill-import.ts +19 -1
  29. package/src/guard-pii-annotations.ts +16 -16
  30. package/src/guard-primitives-discipline.ts +61 -65
  31. package/src/guard-screen-conventions.ts +2 -0
  32. package/src/guard-table-ddl.ts +56 -62
  33. package/src/guard-tenant-escalation.ts +42 -34
  34. package/src/guard-test-stack-drift.ts +6 -6
  35. package/src/guard-test-timeouts.ts +5 -4
  36. package/src/guard-text-field-stance.ts +22 -22
  37. package/src/guard-thin-wrappers.ts +42 -45
  38. package/src/guard-write-handler-qns.ts +29 -29
  39. package/src/runtime-isolation-classify.ts +4 -3
@@ -1,34 +1,34 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Complexity-Check fuer Handler-Hotspots mit Baseline-Regression-Guard.
3
+ * Complexity check for handler hotspots with a baseline regression guard.
4
4
  *
5
- * Berechnet zyklomatische Komplexitaet pro Funktion/Methode (Basis 1, +1 je
6
- * Entscheidungspunkt: if, for, while, case, catch, &&, ||, ??, Ternary).
7
- * Ueber dem Schwellwert -> Hotspot.
5
+ * Computes cyclomatic complexity per function/method (base 1, +1 per decision
6
+ * point: if, for, while, case, catch, &&, ||, ??, ternary). Above the
7
+ * threshold -> hotspot.
8
8
  *
9
- * Baseline-Regression-Guard wie guard-comment-lang.ts: `.kumiko-complexity-
10
- * baseline.json` im Repo-Root pinnt pro File die eingefrorene Hotspot-Anzahl.
11
- * - aktuell <= baseline pro File: PASS
12
- * - aktuell > baseline pro File: FAIL
13
- * Reduktionen updaten die Baseline NICHT automatisch — nach Refactor-Commits
14
- * `--write-baseline` aufrufen. Ohne Baseline-Datei bleibt der Check
15
- * warning-only (Bootstrap: einmalig `--write-baseline`).
16
- * Die Baseline umfasst nur Files des eigenen Repos; Sibling-Hotspots gehoeren
17
- * in deren eigene Baseline (siehe localHotspots).
9
+ * Baseline regression guard like guard-comment-lang.ts: `.kumiko-complexity-
10
+ * baseline.json` in the repo root pins the frozen hotspot count per file.
11
+ * - current <= baseline per file: PASS
12
+ * - current > baseline per file: FAIL
13
+ * Reductions do NOT update the baseline automatically — run `--write-baseline`
14
+ * after refactor commits. Without a baseline file the check stays
15
+ * warning-only (bootstrap: run `--write-baseline` once).
16
+ * The baseline covers only files of the repo itself; sibling hotspots belong
17
+ * in their own baseline (see localHotspots).
18
18
  *
19
- * Bewusste Luecke: gezaehlt werden Hotspots pro File, nicht deren Hoehe — eine
20
- * bereits gelistete Funktion darf komplexer werden, ohne dass der Guard faellt.
19
+ * Deliberate gap: hotspots are counted per file, not their height — an
20
+ * already listed function may get more complex without the guard failing.
21
21
  *
22
- * Opt-out: `// kumiko-lint-ignore complexity-budget <Grund>` auf der
23
- * Funktionszeile, der Zeile darueber, oder (bei JSDoc-dokumentierten
24
- * Funktionen) der Zeile ueber dem JSDoc-Block.
22
+ * Opt-out: `// kumiko-lint-ignore complexity-budget <reason>` on the function
23
+ * line, the line above, or (for JSDoc-documented functions) the line above
24
+ * the JSDoc block.
25
25
  *
26
26
  * Usage:
27
- * bun guards/check-complexity.ts # Vergleich gegen Baseline
28
- * bun guards/check-complexity.ts --write-baseline # Baseline neu schreiben
29
- * bun guards/check-complexity.ts --no-baseline # Vergleich ueberspringen
27
+ * bun guards/check-complexity.ts # compare against baseline
28
+ * bun guards/check-complexity.ts --write-baseline # rewrite baseline
29
+ * bun guards/check-complexity.ts --no-baseline # skip comparison
30
30
  *
31
- * Regel: Issue kumiko-framework#1282
31
+ * Rule: issue kumiko-framework#1282
32
32
  */
33
33
 
34
34
  import * as path from "node:path";
@@ -114,43 +114,42 @@ function hasBudgetTag(fn: Node): boolean {
114
114
  return lineHasIgnoreTag(lines[jsDocStartLine - 2] ?? "", BUDGET_TAG);
115
115
  }
116
116
 
117
+ const BRANCH_KINDS: ReadonlySet<SyntaxKind> = new Set([
118
+ SyntaxKind.IfStatement,
119
+ SyntaxKind.ForStatement,
120
+ SyntaxKind.ForInStatement,
121
+ SyntaxKind.ForOfStatement,
122
+ SyntaxKind.WhileStatement,
123
+ SyntaxKind.DoStatement,
124
+ SyntaxKind.CatchClause,
125
+ SyntaxKind.ConditionalExpression,
126
+ SyntaxKind.CaseClause,
127
+ ]);
128
+
129
+ const SHORT_CIRCUIT_OPERATORS: ReadonlySet<SyntaxKind> = new Set([
130
+ SyntaxKind.AmpersandAmpersandToken,
131
+ SyntaxKind.BarBarToken,
132
+ SyntaxKind.QuestionQuestionToken,
133
+ ]);
134
+
135
+ function isDecisionPoint(node: Node): boolean {
136
+ const kind = node.getKind();
137
+ if (BRANCH_KINDS.has(kind)) return true;
138
+ if (kind !== SyntaxKind.BinaryExpression) return false;
139
+ const operator = node.asKindOrThrow(SyntaxKind.BinaryExpression).getOperatorToken().getKind();
140
+ return SHORT_CIRCUIT_OPERATORS.has(operator);
141
+ }
142
+
117
143
  export function computeComplexity(fn: Node): number {
118
144
  let complexity = 1;
119
145
  fn.forEachDescendant((node, traversal) => {
120
- // Don't count decision points inside a nested function-like node —
121
- // those get their own Hotspot entry.
146
+ // skip: decision points inside a nested function-like node belong to its
147
+ // own Hotspot entry
122
148
  if (node !== fn && (FUNCTION_KINDS as readonly SyntaxKind[]).includes(node.getKind())) {
123
149
  traversal.skip();
124
150
  return;
125
151
  }
126
- switch (node.getKind()) {
127
- case SyntaxKind.IfStatement:
128
- case SyntaxKind.ForStatement:
129
- case SyntaxKind.ForInStatement:
130
- case SyntaxKind.ForOfStatement:
131
- case SyntaxKind.WhileStatement:
132
- case SyntaxKind.DoStatement:
133
- case SyntaxKind.CatchClause:
134
- case SyntaxKind.ConditionalExpression:
135
- complexity++;
136
- break;
137
- case SyntaxKind.CaseClause:
138
- complexity++;
139
- break;
140
- case SyntaxKind.BinaryExpression: {
141
- const op = node.asKindOrThrow(SyntaxKind.BinaryExpression).getOperatorToken().getKind();
142
- if (
143
- op === SyntaxKind.AmpersandAmpersandToken ||
144
- op === SyntaxKind.BarBarToken ||
145
- op === SyntaxKind.QuestionQuestionToken
146
- ) {
147
- complexity++;
148
- }
149
- break;
150
- }
151
- default:
152
- break;
153
- }
152
+ if (isDecisionPoint(node)) complexity++;
154
153
  });
155
154
  return complexity;
156
155
  }
@@ -220,18 +219,18 @@ function report(hotspots: readonly Hotspot[], scanned: number): void {
220
219
  console.log(` Hotspots (complexity >= ${COMPLEXITY_THRESHOLD}): ${hotspots.length}`);
221
220
  if (hotspots.length === 0) {
222
221
  console.log(" Nothing to report.");
223
- return;
224
- }
225
- const shown = hotspots.slice(0, MAX_REPORTED);
226
- for (const h of shown) {
227
- console.log(` ${h.file}:${h.line} ${h.name}() complexity=${h.complexity}`);
228
- }
229
- if (hotspots.length > shown.length) {
230
- console.log(` ... ${hotspots.length - shown.length} more`);
222
+ } else {
223
+ const shown = hotspots.slice(0, MAX_REPORTED);
224
+ for (const h of shown) {
225
+ console.log(` ${h.file}:${h.line} ${h.name}() complexity=${h.complexity}`);
226
+ }
227
+ if (hotspots.length > shown.length) {
228
+ console.log(` ... ${hotspots.length - shown.length} more`);
229
+ }
230
+ console.log(
231
+ `\n Rule: split the function, or deliberately allow it with "// ${BUDGET_TAG} <reason>".`,
232
+ );
231
233
  }
232
- console.log(
233
- `\n Rule: split the function, or deliberately allow it with "// ${BUDGET_TAG} <reason>".`,
234
- );
235
234
  }
236
235
 
237
236
  const complexityBaseline = baselineRatchet({
@@ -276,9 +275,9 @@ function analyse(
276
275
  export const guard: AstGuard = {
277
276
  name: "Complexity Check",
278
277
  scan: SCAN,
279
- // Kein Remediation-Text hier: reportResults haengt hint an JEDEN Fail, auch
280
- // an Format-Drift, wo "Funktion aufteilen" in die Irre fuehrt. Der konkrete
281
- // Rat steht deshalb in der jeweiligen Violation-Message.
278
+ // No remediation text here: reportResults appends the hint to EVERY failure,
279
+ // including format drift, where "split the function" would mislead. The
280
+ // concrete advice lives in the respective violation message.
282
281
  hint: 'after a deliberate change: `kumiko-guards guards --write-baseline --guard="Complexity Check"`',
283
282
  run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
284
283
  writeBaseline: (files) => {
@@ -288,9 +287,9 @@ export const guard: AstGuard = {
288
287
  },
289
288
  };
290
289
 
291
- // Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
292
- // (run-guards.ts) faehrt 18 Guards mit derselben argv, ein --write-baseline
293
- // dort duerfte die Baseline nicht stillschweigend neu schreiben.
290
+ // Flags are read ONLY here, not in run() — the shared runner (run-guards.ts)
291
+ // drives 18 guards with the same argv, and a --write-baseline there must not
292
+ // silently rewrite the baseline.
294
293
  if (import.meta.main) {
295
294
  const args = process.argv.slice(2);
296
295
  if (args.includes("--write-baseline")) {
@@ -1,19 +1,19 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Predicate-Extraction Check (WARNUNG, kein Fail).
3
+ * Predicate-extraction check (WARNING, no fail).
4
4
  *
5
- * Findet zwei Arten von Kandidaten fuer Predicate-Extraction:
6
- * 1. Fat Predicates — `if`/`while`/ternary Conditions mit >=3 logischen
7
- * Operatoren (&&/||) ODER Condition-Text >80 Zeichen.
8
- * 2. Duplikate — dieselbe Condition (normalisiert) >=2x im Scan-Scope,
9
- * mit >=2 Operatoren (um triviale `!x`-Checks auszuschliessen).
5
+ * Finds two kinds of candidates for predicate extraction:
6
+ * 1. Fat predicates — `if`/`while`/ternary conditions with >=3 logical
7
+ * operators (&&/||) OR condition text >80 characters.
8
+ * 2. Duplicates — the same (normalized) condition >=2x in the scan scope,
9
+ * with >=2 operators (to exclude trivial `!x` checks).
10
10
  *
11
- * Output: Warnung mit Datei:Zeile + Hinweis auf Regel. Exit-Code immer 0.
11
+ * Output: warning with file:line + a pointer to the rule. Exit code is always 0.
12
12
  *
13
13
  * Usage:
14
14
  * bun guards/check-predicates.ts
15
15
  *
16
- * Regel: ~/.claude/rules/coding-standards.md → "Predicate Extraction"
16
+ * Rule: ~/.claude/rules/coding-standards.md → "Predicate Extraction"
17
17
  */
18
18
 
19
19
  import * as path from "node:path";
@@ -91,11 +91,13 @@ function collectConditions(sf: SourceFile): Site[] {
91
91
  const file = path.relative(ROOT, sf.getFilePath());
92
92
 
93
93
  const push = (node: Node, conditionNode: Node | undefined): void => {
94
+ // skip: a missing condition (e.g. `for (;;)`) has nothing to extract
94
95
  if (!conditionNode) return;
95
96
  const raw = conditionNode.getText();
96
97
  const text = normalize(raw);
97
98
  const { ands, ors } = countOperators(text);
98
99
  const operators = ands + ors;
100
+ // skip: a short condition without logical operators needs no named predicate
99
101
  if (operators === 0 && text.length < LENGTH_THRESHOLD) return;
100
102
  sites.push({
101
103
  file,
@@ -119,8 +121,83 @@ function collectConditions(sf: SourceFile): Site[] {
119
121
  return sites;
120
122
  }
121
123
 
122
- // Warning-only Check: blockt nie (returnt immer 0 violations). Die Findings
123
- // werden via console ausgegeben; der Runner zeigt `✓` solange nichts wirft.
124
+ type TextDuplicateGroup = [Site, Site, ...Site[]];
125
+ type ShapeDuplicateGroup = { shape: string; sites: Site[] };
126
+
127
+ function bucketDuplicateCandidates(allSites: readonly Site[]): {
128
+ byText: Map<string, Site[]>;
129
+ byShape: Map<string, Site[]>;
130
+ } {
131
+ const byText = new Map<string, Site[]>();
132
+ const byShape = new Map<string, Site[]>();
133
+ for (const s of allSites) {
134
+ // Count as duplicate candidate if either >=2 operators OR text long enough
135
+ // that repetition is still worth extracting (covers 1-operator but lengthy
136
+ // domain checks like kebab-case validation).
137
+ if (s.operators < DUP_MIN_OPERATORS && s.text.length < LENGTH_THRESHOLD) continue;
138
+ const textBucket = byText.get(s.text) ?? [];
139
+ textBucket.push(s);
140
+ byText.set(s.text, textBucket);
141
+ const shape = structuralShape(s.text);
142
+ const shapeBucket = byShape.get(shape) ?? [];
143
+ shapeBucket.push(s);
144
+ byShape.set(shape, shapeBucket);
145
+ }
146
+ return { byText, byShape };
147
+ }
148
+
149
+ function findTextDuplicates(byText: ReadonlyMap<string, Site[]>): TextDuplicateGroup[] {
150
+ const textDups: TextDuplicateGroup[] = [];
151
+ for (const bucket of byText.values()) {
152
+ if (bucket.length < 2) continue;
153
+ const [first, second, ...rest] = bucket;
154
+ if (first !== undefined && second !== undefined) textDups.push([first, second, ...rest]);
155
+ }
156
+ return textDups;
157
+ }
158
+
159
+ function findShapeDuplicates(byShape: ReadonlyMap<string, Site[]>): ShapeDuplicateGroup[] {
160
+ const shapeDups: ShapeDuplicateGroup[] = [];
161
+ for (const [shape, bucket] of byShape.entries()) {
162
+ if (bucket.length < 2) continue;
163
+ // Skip when sites are already covered by exact-text duplicate group.
164
+ const firstText = bucket[0]?.text;
165
+ const allSame = bucket.every((s) => s.text === firstText);
166
+ if (allSame) continue;
167
+ shapeDups.push({ shape, sites: bucket });
168
+ }
169
+ return shapeDups;
170
+ }
171
+
172
+ const snip = (text: string): string => (text.length > 90 ? `${text.slice(0, 87)}...` : text);
173
+
174
+ function printFatPredicates(fatSites: readonly Site[]): void {
175
+ console.log(`\n Fat-predicate candidates:`);
176
+ for (const s of fatSites) {
177
+ const hint = s.ands === 0 && s.ors >= 3 ? " (Array.includes/Set?)" : "";
178
+ console.log(` ${s.file}:${s.line} [${s.ands}&& ${s.ors}||] ${snip(s.text)}${hint}`);
179
+ }
180
+ }
181
+
182
+ function printTextDuplicates(textDups: readonly TextDuplicateGroup[]): void {
183
+ console.log(`\n Exact duplicates:`);
184
+ for (const group of textDups) {
185
+ const [first] = group;
186
+ console.log(` ${group.length}x ${snip(first.text)}`);
187
+ for (const s of group) console.log(` - ${s.file}:${s.line}`);
188
+ }
189
+ }
190
+
191
+ function printShapeDuplicates(shapeDups: readonly ShapeDuplicateGroup[]): void {
192
+ console.log(`\n Structural duplicates:`);
193
+ for (const { shape, sites } of shapeDups) {
194
+ console.log(` ${sites.length}x shape: ${snip(shape)}`);
195
+ for (const s of sites) console.log(` - ${s.file}:${s.line} ${snip(s.text)}`);
196
+ }
197
+ }
198
+
199
+ // Warning-only check: never blocks (always returns 0 violations). The
200
+ // findings are printed via console; the runner shows `✓` as long as nothing throws.
124
201
  export const guard: AstGuard = {
125
202
  name: "Predicate Extraction Check",
126
203
  scan: SCAN,
@@ -136,37 +213,9 @@ export const guard: AstGuard = {
136
213
  const fatSites = allSites.filter(
137
214
  (s) => s.operators >= OPERATOR_THRESHOLD || s.text.length >= LENGTH_THRESHOLD,
138
215
  );
139
-
140
- const byText = new Map<string, Site[]>();
141
- const byShape = new Map<string, Site[]>();
142
- for (const s of allSites) {
143
- // Count as duplicate candidate if either >=2 operators OR text long enough
144
- // that repetition is still worth extracting (covers 1-operator but lengthy
145
- // domain checks like kebab-case validation).
146
- if (s.operators < DUP_MIN_OPERATORS && s.text.length < LENGTH_THRESHOLD) continue;
147
- const textBucket = byText.get(s.text) ?? [];
148
- textBucket.push(s);
149
- byText.set(s.text, textBucket);
150
- const shape = structuralShape(s.text);
151
- const shapeBucket = byShape.get(shape) ?? [];
152
- shapeBucket.push(s);
153
- byShape.set(shape, shapeBucket);
154
- }
155
- const textDups: Array<[Site, Site, ...Site[]]> = [];
156
- for (const bucket of byText.values()) {
157
- if (bucket.length < 2) continue;
158
- const [first, second, ...rest] = bucket;
159
- if (first !== undefined && second !== undefined) textDups.push([first, second, ...rest]);
160
- }
161
- const shapeDups: Array<{ shape: string; sites: Site[] }> = [];
162
- for (const [shape, bucket] of byShape.entries()) {
163
- if (bucket.length < 2) continue;
164
- // Skip when sites are already covered by exact-text duplicate group.
165
- const firstText = bucket[0]?.text;
166
- const allSame = bucket.every((s) => s.text === firstText);
167
- if (allSame) continue;
168
- shapeDups.push({ shape, sites: bucket });
169
- }
216
+ const { byText, byShape } = bucketDuplicateCandidates(allSites);
217
+ const textDups = findTextDuplicates(byText);
218
+ const shapeDups = findShapeDuplicates(byShape);
170
219
 
171
220
  console.log(`Predicate-Extraction Check: ${scanned} files checked.`);
172
221
  console.log(
@@ -180,32 +229,9 @@ export const guard: AstGuard = {
180
229
  return { violations: [] };
181
230
  }
182
231
 
183
- const snip = (text: string): string => (text.length > 90 ? `${text.slice(0, 87)}...` : text);
184
-
185
- if (fatSites.length > 0) {
186
- console.log(`\n Fat-predicate candidates:`);
187
- for (const s of fatSites) {
188
- const hint = s.ands === 0 && s.ors >= 3 ? " (Array.includes/Set?)" : "";
189
- console.log(` ${s.file}:${s.line} [${s.ands}&& ${s.ors}||] ${snip(s.text)}${hint}`);
190
- }
191
- }
192
-
193
- if (textDups.length > 0) {
194
- console.log(`\n Exact duplicates:`);
195
- for (const group of textDups) {
196
- const [first] = group;
197
- console.log(` ${group.length}x ${snip(first.text)}`);
198
- for (const s of group) console.log(` - ${s.file}:${s.line}`);
199
- }
200
- }
201
-
202
- if (shapeDups.length > 0) {
203
- console.log(`\n Structural duplicates:`);
204
- for (const { shape, sites } of shapeDups) {
205
- console.log(` ${sites.length}x shape: ${snip(shape)}`);
206
- for (const s of sites) console.log(` - ${s.file}:${s.line} ${snip(s.text)}`);
207
- }
208
- }
232
+ if (fatSites.length > 0) printFatPredicates(fatSites);
233
+ if (textDups.length > 0) printTextDuplicates(textDups);
234
+ if (shapeDups.length > 0) printShapeDuplicates(shapeDups);
209
235
 
210
236
  console.log(
211
237
  "\n Rule: extract as a named function (isX/hasY/canZ) when the condition has a stable name.",
@@ -1,26 +1,26 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Secret-Literal-Guard (stack-agnostisch).
3
+ * Secret-literal guard (stack-agnostic).
4
4
  *
5
- * Flaggt hartkodierte Secret-Fallbacks in Server-Code:
5
+ * Flags hardcoded secret fallbacks in server code:
6
6
  * const s = env.JWT_SECRET ?? "hardcoded-prod-secret"; // ✗
7
7
  * hmacSecret: config.X ?? "cashcolt-mailer-hmac-secret", // ✗
8
8
  *
9
- * Ein `?? "<literal>"`-Fallback auf etwas Secret-artiges heißt: fehlt die
10
- * Env-Variable, läuft der Server mit einem im Repo sichtbaren Secret weiter —
11
- * genau die Klasse, die bei einem Deploy-Fehler zum geleakten Prod-Secret wird.
12
- * Secure-by-default: fehlendes Secret → hart fehlschlagen, nie auf ein Literal
13
- * zurückfallen.
9
+ * A `?? "<literal>"` fallback on something secret-like means: if the env
10
+ * variable is missing, the server keeps running with a secret visible in the
11
+ * repo — exactly the class that becomes a leaked prod secret after a deploy
12
+ * mistake. Secure by default: a missing secret → fail hard, never fall back to
13
+ * a literal.
14
14
  *
15
- * AKZEPTIERTE AUSNAHME: `bin/server.ts` ist der designierte DEV-Entrypoint
16
- * (runDevApp). Dort sind Dev-Secret-Fallbacks gewollt — der PROD-Entrypoint
17
- * `bin/main.ts` (runProdApp) liest dieselben Secrets aus dem validierten Env
18
- * und failt hart. Der Guard akzeptiert Literale daher nur in `bin/server.ts`
19
- * und flaggt sie überall sonst (Prod-Entrypoint, Config-Module, Handler, Lib).
15
+ * ACCEPTED EXCEPTION: `bin/server.ts` is the designated DEV entrypoint
16
+ * (runDevApp). Dev secret fallbacks are intended there — the PROD entrypoint
17
+ * `bin/main.ts` (runProdApp) reads the same secrets from the validated env
18
+ * and fails hard. The guard therefore accepts literals only in `bin/server.ts`
19
+ * and flags them everywhere else (prod entrypoint, config modules, handlers, lib).
20
20
  *
21
- * Scannt den eigenen Checkout (die `roots`, die der Runner auflöst) —
22
- * jedes Repo läuft diesen Check in seiner eigenen CI gegen sich selbst,
23
- * statt dass ein zentraler Scan in fremde Sibling-Checkouts greift.
21
+ * Scans its own checkout (the `roots` the runner resolves) —
22
+ * every repo runs this check against itself in its own CI, instead of a
23
+ * central scan reaching into foreign sibling checkouts.
24
24
  */
25
25
  import { readFileSync } from "node:fs";
26
26
  import { join } from "node:path";
@@ -81,11 +81,11 @@ function maskComments(source: string): string {
81
81
  const children = node.getChildren(sourceFile).filter((child) => !isJsDocKind(child.kind));
82
82
  if (children.length > 0) {
83
83
  for (const child of children) visit(child);
84
- return;
85
- }
86
- const trivia = source.slice(node.pos, node.getStart(sourceFile));
87
- for (const m of trivia.matchAll(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g)) {
88
- blank(node.pos + m.index, node.pos + m.index + m[0].length);
84
+ } else {
85
+ const trivia = source.slice(node.pos, node.getStart(sourceFile));
86
+ for (const m of trivia.matchAll(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g)) {
87
+ blank(node.pos + m.index, node.pos + m.index + m[0].length);
88
+ }
89
89
  }
90
90
  };
91
91
  visit(sourceFile);
@@ -105,6 +105,33 @@ const SECRET_LITERAL_TOKEN = /hmac|private[_-]?key|signing[_-]?key/i;
105
105
  const LHS_NAME = /([\w$]+)\s*[:=][^:=]*$/;
106
106
  const UNNAMED_PLACEHOLDER = "<unnamed>";
107
107
 
108
+ type SecretFallback = { readonly name: string; readonly literalLength: number };
109
+
110
+ function secretNameFor(left: string, literal: string): string | undefined {
111
+ return (
112
+ SECRET_NAME.exec(left)?.[0] ??
113
+ (SECRET_LITERAL_TOKEN.test(literal)
114
+ ? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
115
+ : undefined)
116
+ );
117
+ }
118
+
119
+ // Every fallback on the line is inspected; the name is read only from the text since the
120
+ // previous fallback, so an earlier assignee cannot lend its name to a later literal.
121
+ function firstSecretFallback(code: string): SecretFallback | undefined {
122
+ let previousEnd = 0;
123
+ for (const match of code.matchAll(STRING_FALLBACK)) {
124
+ const left = code.slice(previousEnd, match.index);
125
+ previousEnd = match.index + match[0].length;
126
+ const literal = match[2];
127
+ if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
128
+ const name = secretNameFor(left, literal);
129
+ if (name === undefined) continue;
130
+ return { name, literalLength: literal.length };
131
+ }
132
+ return undefined;
133
+ }
134
+
108
135
  /**
109
136
  * Per-file scan on comment-masked source (see maskComments), so only comment
110
137
  * text is skipped and a wrapped code line that starts with `*` is still
@@ -116,26 +143,10 @@ export function scanLinesForSecretLiterals(lines: readonly string[]): SecretLite
116
143
  const maskedLines = maskComments(lines.join("\n")).split("\n");
117
144
  for (let i = 0; i < lines.length; i++) {
118
145
  const raw = lines[i] ?? "";
119
- const code = maskedLines[i] ?? "";
120
146
  if (lineHasIgnoreTag(raw, IGNORE_TAG) || lineHasIgnoreTag(lines[i - 1] ?? "", IGNORE_TAG))
121
147
  continue;
122
- // Every fallback on the line is inspected; the name is read only from the text since the
123
- // previous fallback, so an earlier assignee cannot lend its name to a later literal.
124
- let previousEnd = 0;
125
- for (const match of code.matchAll(STRING_FALLBACK)) {
126
- const left = code.slice(previousEnd, match.index);
127
- previousEnd = match.index + match[0].length;
128
- const literal = match[2];
129
- if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
130
- const name =
131
- SECRET_NAME.exec(left)?.[0] ??
132
- (SECRET_LITERAL_TOKEN.test(literal)
133
- ? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
134
- : undefined);
135
- if (name === undefined) continue;
136
- hits.push({ lineNumber: i + 1, name, literalLength: literal.length });
137
- break;
138
- }
148
+ const fallback = firstSecretFallback(maskedLines[i] ?? "");
149
+ if (fallback) hits.push({ lineNumber: i + 1, ...fallback });
139
150
  }
140
151
  return hits;
141
152
  }
@@ -103,7 +103,7 @@ export const check: RepoCheck = {
103
103
  violations.push({
104
104
  file: "bun.lock",
105
105
  line: 1,
106
- message: `bun.lock could not be parsed (${root.name}): ${lock.reason}`,
106
+ message: `bun.lock could not be parsed (${root.name}): ${lock.detail}`,
107
107
  });
108
108
  continue;
109
109
  }
package/src/cli.ts CHANGED
@@ -37,8 +37,11 @@ function printHelp(): void {
37
37
  }
38
38
  }
39
39
 
40
- async function main(): Promise<void> {
41
- const subcommand = process.argv[2];
40
+ function parseInvocation(argv: readonly string[]): {
41
+ subcommand: Subcommand | undefined;
42
+ flags: string[];
43
+ } {
44
+ const subcommand = argv[2];
42
45
  if (subcommand === "--help" || subcommand === "-h") {
43
46
  printHelp();
44
47
  process.exit(0);
@@ -49,27 +52,37 @@ async function main(): Promise<void> {
49
52
  );
50
53
  process.exit(1);
51
54
  }
52
- const flags = process.argv.slice(3);
55
+ const flags = argv.slice(3);
53
56
  if (subcommand !== undefined && (flags.includes("--help") || flags.includes("-h"))) {
54
57
  printHelp();
55
58
  process.exit(0);
56
59
  }
60
+ return { subcommand, flags };
61
+ }
62
+
63
+ function printInventory(flags: readonly string[]): void {
64
+ const flagsError = cliFlagsError("list", flags, SUBCOMMAND_FLAGS.list);
65
+ if (flagsError !== undefined) {
66
+ console.error(flagsError);
67
+ process.exit(1);
68
+ }
69
+ // Registration inventory only — no scan, no project, no guard.run(). What
70
+ // CI checks against instead of running the guards: a guard dropped from a
71
+ // suite's array goes missing here too, not just silently from a run.
72
+ const inventory = buildGuardKitInventory({
73
+ guards: GUARDS,
74
+ uiGuards: UI_GUARDS,
75
+ checks: REPO_CHECKS,
76
+ });
77
+ console.log(JSON.stringify(inventory));
78
+ }
79
+
80
+ async function main(): Promise<void> {
81
+ const { subcommand, flags } = parseInvocation(process.argv);
57
82
 
58
83
  if (subcommand === "list") {
59
- const flagsError = cliFlagsError("list", flags, SUBCOMMAND_FLAGS.list);
60
- if (flagsError !== undefined) {
61
- console.error(flagsError);
62
- process.exit(1);
63
- }
64
- // Registration inventory only — no scan, no project, no guard.run(). What
65
- // CI checks against instead of running the guards: a guard dropped from a
66
- // suite's array goes missing here too, not just silently from a run.
67
- const inventory = buildGuardKitInventory({
68
- guards: GUARDS,
69
- uiGuards: UI_GUARDS,
70
- checks: REPO_CHECKS,
71
- });
72
- console.log(JSON.stringify(inventory));
84
+ printInventory(flags);
85
+ // skip: the inventory subcommand only prints and must not run any guard
73
86
  return;
74
87
  }
75
88