@cosmicdrift/kumiko-guards 0.334.0 → 0.336.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 (42) hide show
  1. package/package.json +2 -2
  2. package/src/_lib/framework-import.ts +29 -0
  3. package/src/_lib/git-env.ts +2 -10
  4. package/src/_lib/guard-kit.ts +12 -8
  5. package/src/_lib/scan-scope.ts +1 -1
  6. package/src/_lib/sql-inventory.ts +5 -1
  7. package/src/changes.json +26 -0
  8. package/src/check-as-casts.ts +89 -14
  9. package/src/check-real-provider-isolation.ts +9 -1
  10. package/src/check-runtime-isolation.ts +11 -4
  11. package/src/check-secret-literals.ts +18 -13
  12. package/src/cli.ts +4 -0
  13. package/src/guard-access-denied-test.ts +2 -2
  14. package/src/guard-admin-api.ts +1 -2
  15. package/src/guard-app-feature-structure.ts +8 -6
  16. package/src/guard-direct-fetch.ts +1 -1
  17. package/src/guard-error-reasons.ts +7 -3
  18. package/src/guard-escape-hatch-declared.ts +25 -13
  19. package/src/guard-fake-tests.ts +32 -25
  20. package/src/guard-i18n-keys.ts +0 -1
  21. package/src/guard-i18n-locale-terminology.ts +3 -3
  22. package/src/guard-lib-test-coverage.ts +11 -6
  23. package/src/guard-no-custom-primitives.ts +3 -2
  24. package/src/guard-no-framed-extension-sections.ts +90 -26
  25. package/src/guard-no-inline-styles.ts +9 -3
  26. package/src/guard-no-logic-in-views.ts +8 -2
  27. package/src/guard-no-raw-hooks.ts +11 -4
  28. package/src/guard-open-to-all-reason.ts +2 -2
  29. package/src/guard-primitives-discipline.ts +3 -2
  30. package/src/guard-raw-classname.ts +8 -2
  31. package/src/guard-raw-sql.ts +9 -3
  32. package/src/guard-section-fields-raw.ts +81 -9
  33. package/src/guard-test-stack-drift.ts +11 -1
  34. package/src/guard-test-template-drift.ts +48 -2
  35. package/src/guard-test-timeouts.ts +21 -2
  36. package/src/guard-thin-wrappers.ts +2 -2
  37. package/src/guard-upgrade-state.ts +7 -4
  38. package/src/pre-push.sh +2 -2
  39. package/src/run-guards.ts +6 -1
  40. package/src/run-repo-checks.ts +1 -1
  41. package/src/run-ui-guards.ts +1 -1
  42. package/src/runtime-isolation-classify.ts +147 -25
@@ -46,10 +46,11 @@
46
46
  * unresolved and the R2/R3 finding names that explicitly. The declaration
47
47
  * does not propagate upward: it covers escalations inside that function's
48
48
  * own body, not the function it is nested inside. This detection is purely
49
- * lexical — the guard matches on the name `declareEscapeHatch`, not on where
50
- * it was imported from, so a same-named local function clears just as well;
51
- * consistent with `escapeHatch:` itself, which is likewise never checked for
52
- * origin. Referenced-by-variable functions, spread options, computed/string
49
+ * lexical except for origin: `declareEscapeHatch` (and `withUnsafeRawGrant`
50
+ * for R2) must resolve to an import from `@cosmicdrift/kumiko-framework`
51
+ * (or, inside the framework package, a relative import into its `src`), so a
52
+ * same-named local function does not clear. `escapeHatch:` itself is a plain
53
+ * property and is never checked for origin. Referenced-by-variable functions, spread options, computed/string
53
54
  * keys, and a non-literal, non-module-local-const `escapeHatch`/`reason`
54
55
  * value are conservatively not recognized (miss, don't falsely clear).
55
56
  *
@@ -68,7 +69,7 @@
68
69
  *
69
70
  * Usage:
70
71
  * bun guards/guard-escape-hatch-declared.ts
71
- * Baseline: bun guards/run-guards.ts --write-security-baseline
72
+ * Baseline: kumiko-guards guards --write-security-baseline
72
73
  */
73
74
  import * as path from "node:path";
74
75
  import {
@@ -77,7 +78,9 @@ import {
77
78
  type PropertyAssignment,
78
79
  type SourceFile,
79
80
  SyntaxKind,
81
+ VariableDeclarationKind,
80
82
  } from "ts-morph";
83
+ import { isFrameworkImportOf } from "./_lib/framework-import";
81
84
  import { isGenericReason, resolveReasonText } from "./_lib/generic-reason";
82
85
  import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
83
86
 
@@ -230,7 +233,7 @@ function isExplicitUnsafeRawGrant(call: Node): boolean {
230
233
  const receiver = expr.getExpression();
231
234
  return (
232
235
  receiver.isKind(SyntaxKind.CallExpression) &&
233
- receiver.getExpression().getText() === "withUnsafeRawGrant"
236
+ isFrameworkImportOf(receiver.getExpression(), "withUnsafeRawGrant")
234
237
  );
235
238
  }
236
239
 
@@ -387,9 +390,7 @@ function declareEscapeHatchReasonNode(stmt: Node): Node | undefined {
387
390
  const expr = stmt.getExpression();
388
391
  if (!expr.isKind(SyntaxKind.CallExpression)) return undefined;
389
392
  const callee = expr.getExpression();
390
- if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
391
- return undefined;
392
- }
393
+ if (!isFrameworkImportOf(callee, "declareEscapeHatch")) return undefined;
393
394
  const args = expr.getArguments();
394
395
  const arg = args[0];
395
396
  if (args.length !== 1 || !arg?.isKind(SyntaxKind.ObjectLiteralExpression)) return undefined;
@@ -421,12 +422,22 @@ function isExplicitlyUnresolvableReason(node: Node): boolean {
421
422
  (decl) =>
422
423
  decl.isKind(SyntaxKind.ImportSpecifier) ||
423
424
  decl.isKind(SyntaxKind.ImportClause) ||
424
- decl.isKind(SyntaxKind.NamespaceImport),
425
+ decl.isKind(SyntaxKind.NamespaceImport) ||
426
+ isNonConstVariableDeclaration(decl),
427
+ );
428
+ }
429
+
430
+ function isNonConstVariableDeclaration(decl: Node): boolean {
431
+ if (!decl.isKind(SyntaxKind.VariableDeclaration)) return false;
432
+ const list = decl.getParent();
433
+ return (
434
+ list.isKind(SyntaxKind.VariableDeclarationList) &&
435
+ list.getDeclarationKind() !== VariableDeclarationKind.Const
425
436
  );
426
437
  }
427
438
 
428
439
  const UNRESOLVABLE_REASON_HINT =
429
- " A declareEscapeHatch({ reason }) call was found here, but its reason is an import, a function call, or a template with substitutions — none of those can be statically judged, so declareEscapeHatch needs a string literal or a module-local const instead.";
440
+ " A declareEscapeHatch({ reason }) call was found here, but its reason is an import, a function call, or a template with substitutions (or a `let`/non-`const` binding) — none of those can be statically judged, so declareEscapeHatch needs a string literal or a module-local const instead.";
430
441
 
431
442
  // Walks the same ancestor chain as isInsideEscapeHatchDeclaredFunction, but
432
443
  // looks for a declareEscapeHatch statement whose reason is one of the three
@@ -471,7 +482,8 @@ function hasDeclaredEscapeHatchStatement(fn: Node): boolean {
471
482
  body = fn.getBody();
472
483
  }
473
484
  if (!body?.isKind(SyntaxKind.Block)) return false;
474
- return body.getStatements().some((stmt) => isValidDeclareEscapeHatchCall(stmt));
485
+ const firstStatement = body.getStatements()[0];
486
+ return firstStatement !== undefined && isValidDeclareEscapeHatchCall(firstStatement);
475
487
  }
476
488
 
477
489
  function isInsideEscapeHatchDeclaredFunction(node: Node): boolean {
@@ -680,7 +692,7 @@ export function createEscapeHatchGuard(opts: { root: string }): AstGuard {
680
692
  name: "Escape-Hatch-Declared Guard",
681
693
  scan: SCAN,
682
694
  security: true,
683
- hint: 'Declare an escape hatch (r.systemScope() on the feature definition, .job.ts/r.job(...) for jobs, or { escapeHatch: { reason: "..." } } on the handler or hook), or remove the ctx.db.raw/unsafeRaw/queryAs|writeAs(system)/unsafeAllTenants access. Baseline after a deliberate reduction: `bun guards/run-guards.ts --write-security-baseline`',
695
+ hint: 'Declare an escape hatch (r.systemScope() on the feature definition, .job.ts/r.job(...) for jobs, or { escapeHatch: { reason: "..." } } on the handler or hook), or remove the ctx.db.raw/unsafeRaw/queryAs|writeAs(system)/unsafeAllTenants access. Baseline after a deliberate reduction: `kumiko-guards guards --write-security-baseline`',
684
696
  run(files) {
685
697
  const violations: GuardViolation[] = [
686
698
  ...findGenericReasonCalls(files, opts.root).map((f) => ({
@@ -77,40 +77,47 @@ function isDirectAssertionCall(call: CallExpression): boolean {
77
77
  return ASSERTION_API_METHODS.has(name);
78
78
  }
79
79
 
80
- // A bare identifier `foo(...)` counts as an assertion when `foo` is declared
81
- // in the SAME file (function declaration, or a variable initialized with an
82
- // arrow/function expression, at any nesting level) and its own body asserts —
80
+ // A bare identifier `foo(...)` counts as an assertion when `foo` resolves
81
+ // (by symbol, so a same-named helper in another scope is not confused with it)
82
+ // to a declaration in the SAME file (function declaration, or a variable
83
+ // initialized with an arrow/function expression) whose own body asserts —
83
84
  // recursively, so a chain of thin wrappers around expect() still counts.
84
- function resolveSameFileHelper(sf: SourceFile, name: string): Node | undefined {
85
- const fn = sf
86
- .getDescendantsOfKind(SyntaxKind.FunctionDeclaration)
87
- .find((f) => f.getName() === name);
88
- if (fn) return fn;
89
- const varDecl = sf
90
- .getDescendantsOfKind(SyntaxKind.VariableDeclaration)
91
- .find((v) => v.getName() === name);
92
- const init = varDecl?.getInitializer();
93
- if (
94
- init &&
95
- (init.isKind(SyntaxKind.ArrowFunction) || init.isKind(SyntaxKind.FunctionExpression))
96
- ) {
97
- return init;
85
+ function resolveSameFileHelper(sf: SourceFile, identifier: Node): Node | undefined {
86
+ for (const decl of identifier.getSymbol()?.getDeclarations() ?? []) {
87
+ if (decl.getSourceFile() !== sf) continue;
88
+ if (decl.isKind(SyntaxKind.FunctionDeclaration)) return decl;
89
+ if (!decl.isKind(SyntaxKind.VariableDeclaration)) continue;
90
+ const init = decl.getInitializer();
91
+ if (
92
+ init &&
93
+ (init.isKind(SyntaxKind.ArrowFunction) || init.isKind(SyntaxKind.FunctionExpression))
94
+ ) {
95
+ return init;
96
+ }
98
97
  }
99
98
  return undefined;
100
99
  }
101
100
 
102
- // depth limit + visited set: a helper chain deeper than the limit is treated
103
- // as unproven (violation), and a recursive helper never infinite-loops.
104
- function bodyHasAssertion(node: Node, sf: SourceFile, depth: number, visited: Set<Node>): boolean {
105
- if (visited.has(node)) return false;
106
- visited.add(node);
101
+ // depth limit + shallowest-visit memo: a helper chain deeper than the limit is
102
+ // treated as unproven (violation), a recursive helper never infinite-loops, and
103
+ // a helper first reached at the depth limit is still explored when reached
104
+ // again via a shorter path.
105
+ function bodyHasAssertion(
106
+ node: Node,
107
+ sf: SourceFile,
108
+ depth: number,
109
+ shallowestVisit: Map<Node, number>,
110
+ ): boolean {
111
+ const seenAt = shallowestVisit.get(node);
112
+ if (seenAt !== undefined && seenAt <= depth) return false;
113
+ shallowestVisit.set(node, depth);
107
114
  for (const call of node.getDescendantsOfKind(SyntaxKind.CallExpression)) {
108
115
  if (isDirectAssertionCall(call)) return true;
109
116
  if (depth >= MAX_HELPER_DEPTH) continue;
110
117
  const expr = call.getExpression();
111
118
  if (!expr.isKind(SyntaxKind.Identifier)) continue;
112
- const helper = resolveSameFileHelper(sf, expr.getText());
113
- if (helper && bodyHasAssertion(helper, sf, depth + 1, visited)) return true;
119
+ const helper = resolveSameFileHelper(sf, expr);
120
+ if (helper && bodyHasAssertion(helper, sf, depth + 1, shallowestVisit)) return true;
114
121
  }
115
122
  return false;
116
123
  }
@@ -141,7 +148,7 @@ function scanFile(sf: SourceFile): Violation[] {
141
148
  const isArrowOrFn =
142
149
  body.isKind(SyntaxKind.ArrowFunction) || body.isKind(SyntaxKind.FunctionExpression);
143
150
  if (!isArrowOrFn) continue;
144
- const hasAssertion = bodyHasAssertion(body, sf, 0, new Set());
151
+ const hasAssertion = bodyHasAssertion(body, sf, 0, new Map());
145
152
  if (!hasAssertion) {
146
153
  const nameArg = args[0]?.getText().slice(0, 50) ?? "<anonymous>";
147
154
  violations.push({
@@ -63,7 +63,6 @@ function collectUsedKeys(sf: SourceFile): UsedKey[] {
63
63
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
64
64
  const exprText = call.getExpression().getText();
65
65
  if (exprText !== "t" && !/(^|\.)t$/.test(exprText)) continue;
66
- if (exprText === "test" || exprText === "expect") continue;
67
66
  const args = call.getArguments();
68
67
  if (args.length === 0) continue;
69
68
  const first = args[0];
@@ -44,7 +44,7 @@ const RULES: Record<"de" | "es", LocaleRule> = {
44
44
  // Tenants? covers plural; Organisation(en|s)? without trailing \b so
45
45
  // compounds like Organisations-ID / Organisationsstruktur still match
46
46
  // (infra#603). TenantAdmin stays safe: \b after Tenant fails on 'A'.
47
- forbidden: [/\bTenants?\b/i, /\bOrganisation(en|s)?/],
47
+ forbidden: [/\bTenants?\b/i, /\bOrganisation(en|s)?/i],
48
48
  },
49
49
  es: {
50
50
  preferred: "Organización",
@@ -92,7 +92,7 @@ export function findViolations(files: readonly SourceFile[]): GuardViolation[] {
92
92
  violations.push({
93
93
  file: sf.getFilePath(),
94
94
  line: prop.getStartLineNumber(),
95
- message: `[${locale}] verbotener Begriff "${hit}" in Übersetzungswert — nutze "${rule.preferred}" (fw#2200).`,
95
+ message: `[${locale}] forbidden term "${hit}" in translation value — use "${rule.preferred}" (fw#2200).`,
96
96
  });
97
97
  }
98
98
  }
@@ -104,7 +104,7 @@ export function findViolations(files: readonly SourceFile[]): GuardViolation[] {
104
104
  export const guard: AstGuard = {
105
105
  name: "i18n-Locale-Terminology Guard",
106
106
  scan: SCAN,
107
- hint: "DE: Mandant statt Tenant/Organisation; ES: Organización statt tenant-Loanword. Rollen wie TenantAdmin sind OK.",
107
+ hint: "DE: Mandant instead of Tenant/Organisation; ES: Organización instead of the tenant loanword. Roles like TenantAdmin are fine.",
108
108
  run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
109
109
  const violations = findViolations(files)
110
110
  .map((v) => ({ ...v, file: relFromRepoRoot(v.file, roots) }))
@@ -29,6 +29,7 @@
29
29
  import { dirname, resolve } from "node:path";
30
30
  import { Node, type SourceFile, SyntaxKind } from "ts-morph";
31
31
  import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
32
+ import { escapeRegExp } from "./_lib/handler-name-forms";
32
33
  import { hasIgnoreTag } from "./_lib/ignore-tag";
33
34
 
34
35
  // isTestFile() classifies by regex on the file's own path, not by which within-glob matched it, so lib/ vs. test classification stays correct regardless of scan-scope resolution.
@@ -116,16 +117,20 @@ function bodyTextWithoutImports(testSf: SourceFile): string {
116
117
  }
117
118
 
118
119
  function referencesName(testSf: SourceFile, name: string): boolean {
119
- return new RegExp(`\\b${name}\\b`).test(bodyTextWithoutImports(testSf));
120
+ // `\b` does not treat `$` as a word character, so identifiers like `format$`
121
+ // need explicit identifier-char lookarounds.
122
+ return new RegExp(`(?<![\\w$])${escapeRegExp(name)}(?![\\w$])`).test(
123
+ bodyTextWithoutImports(testSf),
124
+ );
120
125
  }
121
126
 
122
127
  export const guard: AstGuard = {
123
128
  name: "Lib-Test-Coverage Guard (App-Repos)",
124
129
  scan: SCAN,
125
130
  hint:
126
- "Jede lib-Datei mit exportierter Funktion braucht einen Test, der aus dem " +
127
- "Modul importiert und jede Funktion namentlich referenziert. IO-Loader, die " +
128
- `ein Integrationstest deckt: // ${IGNORE_TAG} <Grund, z.B. integration-covered via …>`,
131
+ "Every lib file with an exported function needs a test that imports from the " +
132
+ "module and references each function by name. IO loaders that an " +
133
+ `integration test covers: // ${IGNORE_TAG} <reason, e.g. integration-covered via …>`,
129
134
  run(files: readonly SourceFile[]) {
130
135
  const violations: GuardViolation[] = [];
131
136
  const testFiles: SourceFile[] = [];
@@ -145,7 +150,7 @@ export const guard: AstGuard = {
145
150
  violations.push({
146
151
  file: sf.getFilePath(),
147
152
  line: 1,
148
- message: `Kein Test importiert dieses lib-Modul (${relevant.length} exportierte Funktion(en) ungetestet)`,
153
+ message: `No test imports this lib module (${relevant.length} exported function(s) untested)`,
149
154
  });
150
155
  continue;
151
156
  }
@@ -155,7 +160,7 @@ export const guard: AstGuard = {
155
160
  violations.push({
156
161
  file: sf.getFilePath(),
157
162
  line: e.node.getStartLineNumber(),
158
- message: `Exportierte Funktion "${e.name}" wird in keinem verknüpften Test namentlich referenziert`,
163
+ message: `Exported function "${e.name}" is not referenced by name in any linked test`,
159
164
  });
160
165
  }
161
166
  }
@@ -28,6 +28,7 @@ import {
28
28
  compareToBaseline,
29
29
  filesForGuard,
30
30
  type GuardViolation,
31
+ relativeToCwd,
31
32
  runStandalone,
32
33
  type ScanSpec,
33
34
  } from "./_lib/guard-kit";
@@ -66,7 +67,7 @@ function collectRawFormHtml(sf: SourceFile): RawFormFinding[] {
66
67
  if (!RAW_FORM_TAGS.has(tag)) continue;
67
68
  if (hasIgnoreTag(el, IGNORE_TAG)) continue;
68
69
  findings.push({
69
- file: path.relative(ROOT, sf.getFilePath()),
70
+ file: relativeToCwd(sf.getFilePath()),
70
71
  line: el.getStartLineNumber(),
71
72
  tag,
72
73
  });
@@ -146,7 +147,7 @@ function analyse(
146
147
  if (!PRIMITIVE_NAME.test(c.name)) continue;
147
148
  if (hasIgnoreTag(c.node, IGNORE_TAG)) continue;
148
149
  violations.push({
149
- file: sf.getFilePath(),
150
+ file: relativeToCwd(sf.getFilePath()),
150
151
  line: c.line,
151
152
  message: `App-local UI primitive "${c.name}" — use a framework widget/primitive`,
152
153
  });
@@ -34,7 +34,13 @@
34
34
  // two; no usage site found at all, or any other mount kind, stays flagged.
35
35
 
36
36
  import * as path from "node:path";
37
- import { Node, type ObjectLiteralExpression, type SourceFile, SyntaxKind } from "ts-morph";
37
+ import {
38
+ Node,
39
+ type ObjectLiteralExpression,
40
+ type SourceFile,
41
+ SyntaxKind,
42
+ VariableDeclarationKind,
43
+ } from "ts-morph";
38
44
  import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
39
45
  import { hasIgnoreTag } from "./_lib/ignore-tag";
40
46
 
@@ -59,32 +65,93 @@ function isExternalSourceFile(sf: SourceFile): boolean {
59
65
  return sf.getFilePath().includes("/node_modules/");
60
66
  }
61
67
 
68
+ function stringConstantValue(node: Node): string | undefined {
69
+ if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
70
+ return node.getLiteralText();
71
+ }
72
+ if (Node.isAsExpression(node)) return stringConstantValue(node.getExpression());
73
+ if (!Node.isIdentifier(node)) return undefined;
74
+ const symbol = node.getSymbol();
75
+ const resolved = symbol?.isAlias() ? symbol.getAliasedSymbol() : symbol;
76
+ for (const decl of resolved?.getDeclarations() ?? []) {
77
+ if (!Node.isVariableDeclaration(decl)) continue;
78
+ if (decl.getVariableStatement()?.getDeclarationKind() !== VariableDeclarationKind.Const) {
79
+ continue;
80
+ }
81
+ const init = decl.getInitializer();
82
+ const value = init === undefined ? undefined : stringConstantValue(init);
83
+ if (value !== undefined) return value;
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ // Registry keys and `__component` usages meet on the resolved string value, so
89
+ // two features' same-named local consts don't collide and a literal never
90
+ // matches a computed key. An unresolvable identifier keeps a name-based key
91
+ // that no literal can equal, which leaves the component conservatively flagged.
92
+ function registryKeyFor(node: Node): string {
93
+ return stringConstantValue(node) ?? `unresolved:${node.getText()}`;
94
+ }
95
+
96
+ function addRegistryMembers(
97
+ sf: SourceFile,
98
+ literal: ObjectLiteralExpression,
99
+ entries: Map<string, string>,
100
+ visited: Set<Node>,
101
+ ): void {
102
+ if (visited.has(literal)) return;
103
+ visited.add(literal);
104
+ for (const member of literal.getProperties()) {
105
+ // { NOTES_SECTION_EXTENSION_NAME: NotesSection } (PropertyAssignment,
106
+ // possibly computed-key) or { NotesSection } (ShorthandPropertyAssignment).
107
+ if (Node.isPropertyAssignment(member)) {
108
+ const valueInit = member.getInitializer();
109
+ if (valueInit === undefined || valueInit.getKind() !== SyntaxKind.Identifier) continue;
110
+ const nameNode = member.getNameNode();
111
+ const key = Node.isComputedPropertyName(nameNode)
112
+ ? registryKeyFor(nameNode.getExpression())
113
+ : Node.isStringLiteral(nameNode)
114
+ ? nameNode.getLiteralValue()
115
+ : member.getName();
116
+ entries.set(valueInit.getText(), key);
117
+ } else if (Node.isShorthandPropertyAssignment(member)) {
118
+ const name = member.getName();
119
+ entries.set(name, name);
120
+ } else if (Node.isSpreadAssignment(member)) {
121
+ addRegistryFromExpression(sf, member.getExpression(), entries, visited);
122
+ }
123
+ }
124
+ }
125
+
126
+ // The registry may be inline, a same-file variable (`extensionSectionComponents:
127
+ // shared`, `{ extensionSectionComponents }`) or spread in (`{ ...shared }`).
128
+ function addRegistryFromExpression(
129
+ sf: SourceFile,
130
+ expression: Node,
131
+ entries: Map<string, string>,
132
+ visited: Set<Node>,
133
+ ): void {
134
+ if (Node.isObjectLiteralExpression(expression)) {
135
+ addRegistryMembers(sf, expression, entries, visited);
136
+ return;
137
+ }
138
+ if (!Node.isIdentifier(expression)) return;
139
+ const init = sf.getVariableDeclaration(expression.getText())?.getInitializer();
140
+ if (init !== undefined) addRegistryFromExpression(sf, init, entries, visited);
141
+ }
142
+
62
143
  /** Maps each registered component's identifier to the registry key usage sites reference via `__component: SOME_CONST`. */
63
144
  function findExtensionComponentEntries(sf: SourceFile): Map<string, string> {
64
145
  const entries = new Map<string, string>();
146
+ const visited = new Set<Node>();
65
147
  for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
66
148
  if (prop.getName() !== "extensionSectionComponents") continue;
67
149
  const init = prop.getInitializer();
68
- if (init === undefined || init.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
69
- for (const member of init.asKindOrThrow(SyntaxKind.ObjectLiteralExpression).getProperties()) {
70
- // { NOTES_SECTION_EXTENSION_NAME: NotesSection } (PropertyAssignment,
71
- // possibly computed-key) or { NotesSection } (ShorthandPropertyAssignment).
72
- if (member.getKind() === SyntaxKind.PropertyAssignment) {
73
- const pa = member.asKindOrThrow(SyntaxKind.PropertyAssignment);
74
- const valueInit = pa.getInitializer();
75
- if (valueInit === undefined || valueInit.getKind() !== SyntaxKind.Identifier) continue;
76
- const nameNode = pa.getNameNode();
77
- const key = Node.isComputedPropertyName(nameNode)
78
- ? nameNode.getExpression().getText()
79
- : Node.isStringLiteral(nameNode)
80
- ? nameNode.getLiteralValue()
81
- : pa.getName();
82
- entries.set(valueInit.getText(), key);
83
- } else if (member.getKind() === SyntaxKind.ShorthandPropertyAssignment) {
84
- const name = member.asKindOrThrow(SyntaxKind.ShorthandPropertyAssignment).getName();
85
- entries.set(name, name);
86
- }
87
- }
150
+ if (init !== undefined) addRegistryFromExpression(sf, init, entries, visited);
151
+ }
152
+ for (const prop of sf.getDescendantsOfKind(SyntaxKind.ShorthandPropertyAssignment)) {
153
+ if (prop.getName() !== "extensionSectionComponents") continue;
154
+ addRegistryFromExpression(sf, prop.getNameNode(), entries, visited);
88
155
  }
89
156
  return entries;
90
157
  }
@@ -259,11 +326,8 @@ function findComponentNameUsages(sf: SourceFile): { name: string; kind: MountKin
259
326
  if (prop.getName() !== "__component") continue;
260
327
  const init = prop.getInitializer();
261
328
  if (init === undefined) continue;
262
- const name = Node.isStringLiteral(init)
263
- ? init.getLiteralValue()
264
- : Node.isIdentifier(init)
265
- ? init.getText()
266
- : undefined;
329
+ const name =
330
+ Node.isStringLiteral(init) || Node.isIdentifier(init) ? registryKeyFor(init) : undefined;
267
331
  if (name === undefined) continue;
268
332
  usages.push({ name, kind: mountKindFor(prop) });
269
333
  }
@@ -5,7 +5,13 @@
5
5
  // Part of App-Mounting 2.0 (infra#208).
6
6
 
7
7
  import { type SourceFile, SyntaxKind } from "ts-morph";
8
- import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
8
+ import {
9
+ type AstGuard,
10
+ type GuardViolation,
11
+ relativeToCwd,
12
+ runStandalone,
13
+ type ScanSpec,
14
+ } from "./_lib/guard-kit";
9
15
  import { hasIgnoreTag } from "./_lib/ignore-tag";
10
16
 
11
17
  const SCAN: ScanSpec = {
@@ -30,7 +36,7 @@ export const guard: AstGuard = {
30
36
  if (attr.getNameNode().getText() !== "style") continue;
31
37
  if (hasIgnoreTag(attr, IGNORE_TAG)) continue;
32
38
  violations.push({
33
- file: sf.getFilePath(),
39
+ file: relativeToCwd(sf.getFilePath()),
34
40
  line: attr.getStartLineNumber(),
35
41
  message: "style= prop in app code (use theme tokens/widgets)",
36
42
  });
@@ -45,7 +51,7 @@ export const guard: AstGuard = {
45
51
  if (name !== "CSSProperties" && name !== "React.CSSProperties") continue;
46
52
  if (hasIgnoreTag(ref, IGNORE_TAG)) continue;
47
53
  violations.push({
48
- file: sf.getFilePath(),
54
+ file: relativeToCwd(sf.getFilePath()),
49
55
  line: ref.getStartLineNumber(),
50
56
  message: "CSSProperties style object in app code (use theme tokens/widgets)",
51
57
  });
@@ -19,7 +19,13 @@ import {
19
19
  type SourceFile,
20
20
  SyntaxKind,
21
21
  } from "ts-morph";
22
- import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
22
+ import {
23
+ type AstGuard,
24
+ type GuardViolation,
25
+ relativeToCwd,
26
+ runStandalone,
27
+ type ScanSpec,
28
+ } from "./_lib/guard-kit";
23
29
  import { hasIgnoreTag } from "./_lib/ignore-tag";
24
30
 
25
31
  const SCAN: ScanSpec = {
@@ -134,7 +140,7 @@ export const guard: AstGuard = {
134
140
  if (name === "" || !isViewLogic(name, fn)) continue;
135
141
  if (hasIgnoreTag(fn, IGNORE_TAG)) continue;
136
142
  violations.push({
137
- file: sf.getFilePath(),
143
+ file: relativeToCwd(sf.getFilePath()),
138
144
  line: fn.getStartLineNumber(),
139
145
  message: `View logic "${name}" belongs in lib/ (with a test) — web/ only components/hooks`,
140
146
  });
@@ -11,7 +11,13 @@
11
11
  // Part of App-Mounting 2.0 (infra#208).
12
12
 
13
13
  import { Node, type SourceFile, SyntaxKind } from "ts-morph";
14
- import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
14
+ import {
15
+ type AstGuard,
16
+ type GuardViolation,
17
+ relativeToCwd,
18
+ runStandalone,
19
+ type ScanSpec,
20
+ } from "./_lib/guard-kit";
15
21
  import { hasIgnoreTag } from "./_lib/ignore-tag";
16
22
 
17
23
  // Match property/identifier names instead of call text — otherwise
@@ -40,7 +46,8 @@ export const guard: AstGuard = {
40
46
  name: "No-Raw-Hooks Guard (App-Repos)",
41
47
  scan: SCAN,
42
48
  hint:
43
- "Use the framework hook set: useQuery (live: true for SSE), useMutation, useDisclosure. " +
49
+ "Use the framework hook set: useQuery (live: true for SSE), useMutation, useDisclosure, " +
50
+ "useReportStepComplete (wizard extension steps). " +
44
51
  `Real special case (DOM integration or similar): // ${IGNORE_TAG} <reason>`,
45
52
  run(files: readonly SourceFile[]) {
46
53
  const violations: GuardViolation[] = [];
@@ -52,7 +59,7 @@ export const guard: AstGuard = {
52
59
  if (BANNED_HOOKS.has(name)) {
53
60
  if (hasIgnoreTag(call, IGNORE_TAG)) continue;
54
61
  violations.push({
55
- file: sf.getFilePath(),
62
+ file: relativeToCwd(sf.getFilePath()),
56
63
  line: call.getStartLineNumber(),
57
64
  message: `${name} in App-Screen — use framework hooks (useQuery/useMutation/useDisclosure)`,
58
65
  });
@@ -61,7 +68,7 @@ export const guard: AstGuard = {
61
68
  if (name === "fetch") {
62
69
  if (hasIgnoreTag(call, IGNORE_TAG)) continue;
63
70
  violations.push({
64
- file: sf.getFilePath(),
71
+ file: relativeToCwd(sf.getFilePath()),
65
72
  line: call.getStartLineNumber(),
66
73
  message: "fetch() in App-Screen — use useQuery/useMutation or an API client (*.ts)",
67
74
  });
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * Usage:
14
14
  * bun guards/guard-open-to-all-reason.ts
15
- * Baseline: bun guards/run-guards.ts --write-security-baseline
15
+ * Baseline: kumiko-guards guards --write-security-baseline
16
16
  */
17
17
  import * as path from "node:path";
18
18
  import { type SourceFile, SyntaxKind } from "ts-morph";
@@ -85,7 +85,7 @@ export function createOpenToAllReasonGuard(opts: { root: string }): AstGuard {
85
85
  name: "Open-To-All-Reason Guard",
86
86
  scan: SCAN,
87
87
  security: true,
88
- hint: 'Provide openToAll: { reason: "<why any authenticated user may call this>" } (+ personalData: "tenant-members" for write handlers with unbound personal data). Baseline after a deliberate reduction: `bun guards/run-guards.ts --write-security-baseline`',
88
+ hint: 'Provide openToAll: { reason: "<why any authenticated user may call this>" } (+ personalData: "tenant-members" for write handlers with unbound personal data). Baseline after a deliberate reduction: `kumiko-guards guards --write-security-baseline`',
89
89
  run(files) {
90
90
  const violations: GuardViolation[] = [
91
91
  ...findGenericOpenToAllReasons(files, opts.root).map((f) => ({
@@ -167,10 +167,11 @@ function walk(dir: string, out: string[]): void {
167
167
  }
168
168
 
169
169
  function buildTagPattern(tag: string): RegExp {
170
- // Match `<tag` at the JSX opening: after whitespace, a paren, or >.
170
+ // Match `<tag` at the JSX opening: at line start or after a char that can't
171
+ // end an identifier/member access (`cond?<input/>:<div/>` hits, `Array<input` doesn't).
171
172
  // Negative lookahead `[a-zA-Z0-9-]` so `<input` doesn't match `<inputfoo`.
172
173
  // Doesn't match the closing tag (`</tag>`) — that would be redundant.
173
- return new RegExp(`(^|[\\s(>{,;])<${tag}(?![a-zA-Z0-9-])`, "u");
174
+ return new RegExp(`(^|[^\\w$.])<${tag}(?![a-zA-Z0-9-])`, "u");
174
175
  }
175
176
 
176
177
  const TAG_PATTERNS = FORBIDDEN_TAGS.map((t) => ({ ...t, pattern: buildTagPattern(t.tag) }));
@@ -9,7 +9,13 @@
9
9
  // respective migration PR (ui-guards input in _app-test.yml).
10
10
 
11
11
  import { type JsxAttribute, type SourceFile, SyntaxKind } from "ts-morph";
12
- import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
12
+ import {
13
+ type AstGuard,
14
+ type GuardViolation,
15
+ relativeToCwd,
16
+ runStandalone,
17
+ type ScanSpec,
18
+ } from "./_lib/guard-kit";
13
19
  import { hasIgnoreTag } from "./_lib/ignore-tag";
14
20
 
15
21
  const SCAN: ScanSpec = {
@@ -97,7 +103,7 @@ export const guard: AstGuard = {
97
103
  const bad = offendingTokens(part.text);
98
104
  if (bad.length === 0) continue;
99
105
  violations.push({
100
- file: sf.getFilePath(),
106
+ file: relativeToCwd(sf.getFilePath()),
101
107
  line: part.line,
102
108
  message: `design-bearing Tailwind classes in app code: ${bad.join(", ")}`,
103
109
  });
@@ -14,7 +14,12 @@
14
14
 
15
15
  import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
16
16
  import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
17
- import { BLOCKING_SQL_KINDS, scanRepo, sqlScanDirsFor } from "./_lib/sql-inventory";
17
+ import {
18
+ BLOCKING_SQL_KINDS,
19
+ isSqlScanExcluded,
20
+ scanRepo,
21
+ sqlScanDirsFor,
22
+ } from "./_lib/sql-inventory";
18
23
 
19
24
  export type RawSqlFinding = {
20
25
  readonly repo: string;
@@ -65,8 +70,9 @@ export const check: RepoCheck = {
65
70
  "Rule: runtime SQL only in db/queries/*, bun-db/query.ts, testing/*, or with " +
66
71
  "// kumiko-lint-ignore raw-sql <reason> on the line or the line above.",
67
72
  async run(roots) {
68
- // kumiko-platform's deliberate empty scan-dir list must not read as vacuous (infra#610).
69
- const applicableRoots = roots.filter((r) => sqlScanDirsFor(r).length > 0);
73
+ // Only the deliberate exclusion is skipped; a repo whose declared source roots
74
+ // are all missing stays in the scan so 0 files surfaces as vacuous.
75
+ const applicableRoots = roots.filter((r) => !isSqlScanExcluded(r));
70
76
  if (applicableRoots.length === 0) {
71
77
  return { violations: [], matchedFiles: 0, notApplicable: true };
72
78
  }