@cosmicdrift/kumiko-guards 0.335.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.335.0",
3
+ "version": "0.336.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,7 +28,7 @@
28
28
  "kumiko-pre-push": "./src/pre-push.sh"
29
29
  },
30
30
  "dependencies": {
31
- "@cosmicdrift/kumiko-repo-manifest": "0.335.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.336.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
@@ -125,6 +125,10 @@ export function relFromRepoRoot(
125
125
  return filePath.slice(filePath.indexOf(marker));
126
126
  }
127
127
 
128
+ export function relativeToCwd(absolutePath: string): string {
129
+ return pathRelative(process.cwd(), absolutePath);
130
+ }
131
+
128
132
  export function isAllowlisted(relPath: string, allowlist: readonly RegExp[]): boolean {
129
133
  return allowlist.some((re) => re.test(relPath));
130
134
  }
@@ -164,7 +164,7 @@ function scanRoot(spec: ScanSpec, root: RepoRoot): RootScan {
164
164
  return { root, files, sourceSurface: sourceSurfaceHits.length };
165
165
  }
166
166
 
167
- function keepsRootKind(spec: ScanSpec, root: RepoRoot): boolean {
167
+ export function keepsRootKind(spec: ScanSpec, root: RepoRoot): boolean {
168
168
  if (spec.kinds) return spec.kinds.includes(root.kind);
169
169
  return root.kind !== "tooling";
170
170
  }
package/src/changes.json CHANGED
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.336.0",
4
+ "type": "improvement",
5
+ "title": "Guards reject --guard without --write-baseline and catch more raw-form-HTML, locale and timeout cases",
6
+ "detail": "Guards are stricter and more consistent: `guards --guard=<name>` without `--write-baseline` is rejected instead of silently running every guard, the raw-form-HTML check also catches tags after `=`, `[` or in spaceless ternaries, the German locale check flags lowercase \"organisation\", `test-timeouts` flags `setDefaultTimeout` and `describe.configure({ timeout })`, the no-framed-extension-sections guard correlates registry keys and usages by resolved value, and the fake-tests helper lookup is scope-aware. UI guards report repo-relative file paths. The pre-push worktree detection compares absolute git dirs."
7
+ },
8
+ {
9
+ "version": "0.336.0",
10
+ "type": "improvement",
11
+ "title": "secret-literal checks all fallbacks on a line; as-casts audit is quiet inside the shared runner",
12
+ "detail": "`secret-literal` now inspects every `?? \"literal\"` fallback on a line instead of only the first, so a hardcoded secret behind a trivial first fallback is flagged. The `as-casts` audit prints its per-site listings only when run standalone; inside the shared runner it prints just the counts and the baseline verdict.",
13
+ "migration": "A repo with a hardcoded secret fallback hidden behind an earlier trivial fallback on the same line can now fail the secret-literal guard. Replace the literal with a hard failure on a missing env value."
14
+ },
2
15
  {
3
16
  "version": "0.335.0",
4
17
  "type": "improvement",
@@ -405,6 +405,11 @@ function groupByCategory(all: readonly Site[]): Map<Category, Site[]> {
405
405
  return byCat;
406
406
  }
407
407
 
408
+ // The per-site listings are advisory and long; inside the shared runner (every consumer's
409
+ // `bun run check`) they would bury the output of guards that can fail, so only a standalone
410
+ // run of this file prints them.
411
+ const PRINT_SITE_LISTINGS = import.meta.main;
412
+
408
413
  function reportCasts(all: readonly Site[], scanned: number): void {
409
414
  const byCat = groupByCategory(all);
410
415
  console.log(`as-Cast Audit: ${scanned} files checked, ${all.length} casts total.\n`);
@@ -412,6 +417,8 @@ function reportCasts(all: readonly Site[], scanned: number): void {
412
417
  const count = byCat.get(c)?.length ?? 0;
413
418
  console.log(` ${c.padEnd(18)} ${count}`);
414
419
  }
420
+ // skip: site listings are printed only on a standalone run of this guard
421
+ if (!PRINT_SITE_LISTINGS) return;
415
422
 
416
423
  const suspects = CATS.filter((c) => c.startsWith("suspect-"));
417
424
 
@@ -64,7 +64,7 @@ import {
64
64
  runRepoChecks,
65
65
  } from "./_lib/guard-kit";
66
66
  import { frameworkTsConfigPath, type RepoRoot, resolveRepoRoots } from "./_lib/roots";
67
- import { type ScanSpec, scanFiles, scanRoots } from "./_lib/scan-scope";
67
+ import { keepsRootKind, type ScanSpec, scanFiles } from "./_lib/scan-scope";
68
68
  import {
69
69
  classify,
70
70
  computeClientReachablePaths,
@@ -177,7 +177,7 @@ export const check: RepoCheck = {
177
177
  // The kind filter (scan-scope.ts keepsRootKind) excludes "tooling" roots by
178
178
  // default — that's a deliberate scope choice, not a vacuous scan, so it must
179
179
  // report notApplicable instead of matchedFiles: 0.
180
- if (scanRoots(SCAN, [root]).length === 0) {
180
+ if (!keepsRootKind(SCAN, root)) {
181
181
  return { violations: [], matchedFiles: 0, notApplicable: true };
182
182
  }
183
183
  const rootAbsPath = root.absPath;
@@ -42,7 +42,7 @@ const IS_TEST = /\.(?:test|integration)\.tsx?$/;
42
42
  const DEV_ENTRYPOINT = /(?:^|\/)bin\/server\.ts$/;
43
43
 
44
44
  // A `?? "literal"` / `|| "literal"` nullish/or fallback to a string literal.
45
- const STRING_FALLBACK = /(?:\?\?|\|\|)\s*(['"`])([^'"`\n]+)\1/;
45
+ const STRING_FALLBACK = /(?:\?\?|\|\|)\s*(['"`])([^'"`\n]+)\1/g;
46
46
  // A secret-like identifier. Matched only against the text LEFT of the fallback
47
47
  // (assignee / property name): `cfg.title ?? "Secret Santa"` is a label, not a secret.
48
48
  const SECRET_NAME =
@@ -116,19 +116,24 @@ export function scanLinesForSecretLiterals(lines: readonly string[]): SecretLite
116
116
  for (let i = 0; i < lines.length; i++) {
117
117
  const raw = lines[i] ?? "";
118
118
  const code = maskedLines[i] ?? "";
119
- const match = STRING_FALLBACK.exec(code);
120
- if (!match) continue;
121
- const literal = match[2];
122
- if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
123
- const left = code.slice(0, match.index);
124
- const name =
125
- SECRET_NAME.exec(left)?.[0] ??
126
- (SECRET_LITERAL_TOKEN.test(literal)
127
- ? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
128
- : undefined);
129
- if (name === undefined) continue;
130
119
  if (raw.includes(IGNORE_TAG) || (lines[i - 1] ?? "").includes(IGNORE_TAG)) continue;
131
- hits.push({ lineNumber: i + 1, name, literalLength: literal.length });
120
+ // Every fallback on the line is inspected; the name is read only from the text since the
121
+ // previous fallback, so an earlier assignee cannot lend its name to a later literal.
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 =
129
+ SECRET_NAME.exec(left)?.[0] ??
130
+ (SECRET_LITERAL_TOKEN.test(literal)
131
+ ? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
132
+ : undefined);
133
+ if (name === undefined) continue;
134
+ hits.push({ lineNumber: i + 1, name, literalLength: literal.length });
135
+ break;
136
+ }
132
137
  }
133
138
  return hits;
134
139
  }
@@ -78,6 +78,7 @@ import {
78
78
  type PropertyAssignment,
79
79
  type SourceFile,
80
80
  SyntaxKind,
81
+ VariableDeclarationKind,
81
82
  } from "ts-morph";
82
83
  import { isFrameworkImportOf } from "./_lib/framework-import";
83
84
  import { isGenericReason, resolveReasonText } from "./_lib/generic-reason";
@@ -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
@@ -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({
@@ -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",
@@ -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,6 +65,34 @@ 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
+
62
96
  function addRegistryMembers(
63
97
  sf: SourceFile,
64
98
  literal: ObjectLiteralExpression,
@@ -75,7 +109,7 @@ function addRegistryMembers(
75
109
  if (valueInit === undefined || valueInit.getKind() !== SyntaxKind.Identifier) continue;
76
110
  const nameNode = member.getNameNode();
77
111
  const key = Node.isComputedPropertyName(nameNode)
78
- ? nameNode.getExpression().getText()
112
+ ? registryKeyFor(nameNode.getExpression())
79
113
  : Node.isStringLiteral(nameNode)
80
114
  ? nameNode.getLiteralValue()
81
115
  : member.getName();
@@ -292,11 +326,8 @@ function findComponentNameUsages(sf: SourceFile): { name: string; kind: MountKin
292
326
  if (prop.getName() !== "__component") continue;
293
327
  const init = prop.getInitializer();
294
328
  if (init === undefined) continue;
295
- const name = Node.isStringLiteral(init)
296
- ? init.getLiteralValue()
297
- : Node.isIdentifier(init)
298
- ? init.getText()
299
- : undefined;
329
+ const name =
330
+ Node.isStringLiteral(init) || Node.isIdentifier(init) ? registryKeyFor(init) : undefined;
300
331
  if (name === undefined) continue;
301
332
  usages.push({ name, kind: mountKindFor(prop) });
302
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
  });
@@ -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
  });
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * Guard: tests wait for a condition, not for time. Flags, in test files and
4
4
  * Playwright specs/helpers:
5
- * (a) `test.setTimeout(...)`, `test.slow(...)` — raising a timeout hides the
5
+ * (a) `test.setTimeout(...)`, `test.slow(...)`, `setDefaultTimeout(...)`,
6
+ * `*.describe.configure({ timeout })` — raising a timeout hides the
6
7
  * cause of a slow or flaky test
7
8
  * (b) `<page>.waitForTimeout(...)` — a fixed sleep instead of a condition
8
9
  * (c) sleep loops — a `while`/`do`/`for`/`for…of`/`for…in` whose body calls
@@ -67,6 +68,8 @@ const LOOP_LABELS: ReadonlyMap<SyntaxKind, string> = new Map([
67
68
  const SLEEP_CALLEES: ReadonlySet<string> = new Set(["sleep", "Bun.sleep"]);
68
69
  const TIMEOUT_RAISING_CALLEES: ReadonlySet<string> = new Set(["test.setTimeout", "test.slow"]);
69
70
  const SET_TIMEOUT_CALLEE = /(^|\.)setTimeout$/;
71
+ const SET_DEFAULT_TIMEOUT_CALLEE = /(^|\.)setDefaultTimeout$/;
72
+ const DESCRIBE_CONFIGURE_CALLEE = /(^|\.)describe\.configure$/;
70
73
 
71
74
  export interface Finding {
72
75
  file: string;
@@ -127,10 +130,26 @@ function exceptionNote(node: Node): "allowed" | "incomplete" | "none" {
127
130
  return nearby.some((text) => text.includes(EXCEPTION_TAG)) ? "incomplete" : "none";
128
131
  }
129
132
 
133
+ function hasTimeoutProperty(node: Node | undefined): boolean {
134
+ return (
135
+ node !== undefined &&
136
+ Node.isObjectLiteralExpression(node) &&
137
+ node.getProperties().some((prop) => {
138
+ if (Node.isShorthandPropertyAssignment(prop) || Node.isPropertyAssignment(prop)) {
139
+ return prop.getName() === "timeout";
140
+ }
141
+ return false;
142
+ })
143
+ );
144
+ }
145
+
130
146
  function raisedTimeoutReason(call: CallExpression): string | undefined {
131
147
  const callee = call.getExpression();
132
148
  const calleeText = callee.getText();
133
- if (TIMEOUT_RAISING_CALLEES.has(calleeText)) {
149
+ if (TIMEOUT_RAISING_CALLEES.has(calleeText) || SET_DEFAULT_TIMEOUT_CALLEE.test(calleeText)) {
150
+ return `${calleeText}(…) raises the timeout instead of fixing the cause`;
151
+ }
152
+ if (DESCRIBE_CONFIGURE_CALLEE.test(calleeText) && call.getArguments().some(hasTimeoutProperty)) {
134
153
  return `${calleeText}(…) raises the timeout instead of fixing the cause`;
135
154
  }
136
155
  if (Node.isPropertyAccessExpression(callee) && callee.getName() === "waitForTimeout") {
@@ -39,7 +39,7 @@ import {
39
39
  } from "ts-morph";
40
40
  import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
41
41
  import { frameworkTsConfigPath } from "./_lib/roots";
42
- import { type ScanSpec, scanFiles, scanRoots } from "./_lib/scan-scope";
42
+ import { keepsRootKind, type ScanSpec, scanFiles } from "./_lib/scan-scope";
43
43
 
44
44
  const ROOT = process.cwd();
45
45
 
@@ -387,7 +387,7 @@ export const check: RepoCheck = {
387
387
  // The kind filter (scan-scope.ts keepsRootKind) excludes "tooling" roots by
388
388
  // default — that's a deliberate scope choice, not a vacuous scan, so it must
389
389
  // report notApplicable instead of matchedFiles: 0.
390
- if (scanRoots(SCAN, roots).length === 0) {
390
+ if (!roots.some((root) => keepsRootKind(SCAN, root))) {
391
391
  return { violations: [], matchedFiles: 0, notApplicable: true };
392
392
  }
393
393
 
package/src/pre-push.sh CHANGED
@@ -123,7 +123,7 @@ fi
123
123
  # canonical sibling path instead of this worktree — checking the wrong
124
124
  # code. Run the worktree-local check only when scripts/check-wt.sh is
125
125
  # tracked and executable (untracked/symlink stubs must not hijack push).
126
- if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ] \
126
+ if [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ] \
127
127
  && git -C "$REPO_ROOT" ls-files --error-unmatch scripts/check-wt.sh >/dev/null 2>&1 \
128
128
  && [ -x "$REPO_ROOT/scripts/check-wt.sh" ]; then
129
129
  echo "[pre-push] worktree detected — worktree check (see scripts/check-wt.sh)…"
@@ -153,7 +153,7 @@ run_guard_bin() {
153
153
  # worktree's own package.json scripts directly instead (only when nested
154
154
  # under the parent workspace; a standalone worktree's own `bun run test`
155
155
  # already checks the right code).
156
- if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ] \
156
+ if [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ] \
157
157
  && [ -n "$PARENT_DIR" ]; then
158
158
  cd "$REPO_ROOT"
159
159
  echo "[pre-push] worktree without scripts/check-wt.sh — running this worktree's package.json scripts (typecheck, lint, test, test:dom) and guards (guards, checks, comment-lang)…"
package/src/run-guards.ts CHANGED
@@ -121,6 +121,10 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
121
121
  console.error(flagsError);
122
122
  return 1;
123
123
  }
124
+ if (guardNameArg !== undefined && !flags.includes("--write-baseline")) {
125
+ console.error("--guard=<name> is only valid with --write-baseline");
126
+ return 1;
127
+ }
124
128
  if (flags.includes("--explain")) {
125
129
  for (const line of explainGuards(GUARDS, buildSharedProject(GUARDS))) {
126
130
  console.log(line);