@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
@@ -11,7 +11,9 @@
11
11
  * generator in #3042). `sectionFieldSpecs` (engine/screen-helpers.ts) is the
12
12
  * one reader that unions both sources.
13
13
  *
14
- * Flagged: for-of, spread and array reads over `<…section…>.fields`.
14
+ * Flagged: for-of, spread, array reads, indexing, call arguments and
15
+ * destructuring over the `fields` of a section (by name or by a `groups`
16
+ * property on its type), also behind `!`, parentheses, `as` and `??`.
15
17
  * Known gap: `section.fields.length` is not flagged — the fields-XOR-groups
16
18
  * validator itself needs it, and an emptiness check is not the silent-
17
19
  * iteration bug.
@@ -35,7 +37,13 @@
35
37
  * bun guards/guard-section-fields-raw.ts --no-baseline # skip comparison
36
38
  */
37
39
  import * as path from "node:path";
38
- import { type Node, type PropertyAccessExpression, type SourceFile, SyntaxKind } from "ts-morph";
40
+ import {
41
+ type Node,
42
+ type PropertyAccessExpression,
43
+ type SourceFile,
44
+ SyntaxKind,
45
+ type VariableDeclaration,
46
+ } from "ts-morph";
39
47
  import {
40
48
  type AstGuard,
41
49
  baselineRatchet,
@@ -68,9 +76,10 @@ const SCAN: ScanSpec = {
68
76
  const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
69
77
 
70
78
  const IGNORE_TAG = "kumiko-lint-ignore section-fields-raw";
71
- // hasIgnoreTag() from _lib accepts a bare tag; the issue's premise 2 wants a
72
- // visible reason, so the trailing `\S` is the whole point of not reusing it.
73
- const REASONED_TAG = new RegExp(`${IGNORE_TAG}\\s+\\S`);
79
+ // hasIgnoreTag() from _lib accepts a bare tag; a visible reason is required, so
80
+ // the reason must start with a real character: the `*` of a closing `*/` (block
81
+ // and JSX comments) must not count as one.
82
+ const REASONED_TAG = new RegExp(`${IGNORE_TAG}\\s+(?!\\*/)[^\\s*]`);
74
83
 
75
84
  const SECTION_RECEIVER = /section/i;
76
85
 
@@ -83,6 +92,7 @@ const ARRAY_READS = new Set([
83
92
  "find",
84
93
  "findIndex",
85
94
  "findLast",
95
+ "findLastIndex",
86
96
  "flat",
87
97
  "flatMap",
88
98
  "forEach",
@@ -90,6 +100,7 @@ const ARRAY_READS = new Set([
90
100
  "indexOf",
91
101
  "join",
92
102
  "keys",
103
+ "lastIndexOf",
93
104
  "map",
94
105
  "reduce",
95
106
  "reduceRight",
@@ -99,7 +110,9 @@ const ARRAY_READS = new Set([
99
110
  "sort",
100
111
  "toReversed",
101
112
  "toSorted",
113
+ "toSpliced",
102
114
  "values",
115
+ "with",
103
116
  ]);
104
117
 
105
118
  function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
@@ -112,19 +125,68 @@ function hasReasonedIgnoreTag(node: Node): boolean {
112
125
  return REASONED_TAG.test(lines[line - 1] ?? "") || REASONED_TAG.test(lines[line - 2] ?? "");
113
126
  }
114
127
 
128
+ // A section is recognised by its name or, when types resolve, by carrying a
129
+ // `groups` property — so `sec.fields` and `layout.sections.map((s) => s.fields)`
130
+ // are caught too. The name regex stays as the fallback for untyped receivers.
131
+ function isSectionReceiver(receiver: Node): boolean {
132
+ if (SECTION_RECEIVER.test(receiver.getText())) return true;
133
+ return receiver.getType().getProperty("groups") !== undefined;
134
+ }
135
+
136
+ // Climbs `x!`, `(x)`, `x as T` and `x ?? y` so a wrapped read is judged by the
137
+ // context it ends up in.
138
+ function unwrapReadContext(access: Node): Node {
139
+ let current = access;
140
+ for (;;) {
141
+ const parent = current.getParent();
142
+ if (
143
+ parent !== undefined &&
144
+ (parent.isKind(SyntaxKind.NonNullExpression) ||
145
+ parent.isKind(SyntaxKind.ParenthesizedExpression) ||
146
+ parent.isKind(SyntaxKind.AsExpression) ||
147
+ (parent.isKind(SyntaxKind.BinaryExpression) &&
148
+ parent.getOperatorToken().getKind() === SyntaxKind.QuestionQuestionToken &&
149
+ parent.getLeft() === current))
150
+ ) {
151
+ current = parent;
152
+ continue;
153
+ }
154
+ return current;
155
+ }
156
+ }
157
+
115
158
  function isSequenceRead(access: PropertyAccessExpression): boolean {
116
- const parent = access.getParent();
159
+ const read = unwrapReadContext(access);
160
+ const parent = read.getParent();
117
161
  if (parent === undefined) return false;
118
- if (parent.isKind(SyntaxKind.ForOfStatement)) return parent.getExpression() === access;
162
+ if (parent.isKind(SyntaxKind.ForOfStatement)) return parent.getExpression() === read;
119
163
  if (parent.isKind(SyntaxKind.SpreadElement) || parent.isKind(SyntaxKind.SpreadAssignment)) {
120
164
  return true;
121
165
  }
122
- if (parent.isKind(SyntaxKind.PropertyAccessExpression) && ARRAY_READS.has(parent.getName())) {
166
+ if (parent.isKind(SyntaxKind.ElementAccessExpression)) return parent.getExpression() === read;
167
+ if (parent.isKind(SyntaxKind.CallExpression)) return parent.getArguments().includes(read);
168
+ if (
169
+ parent.isKind(SyntaxKind.PropertyAccessExpression) &&
170
+ parent.getExpression() === read &&
171
+ ARRAY_READS.has(parent.getName())
172
+ ) {
123
173
  return parent.getParent()?.isKind(SyntaxKind.CallExpression) === true;
124
174
  }
125
175
  return false;
126
176
  }
127
177
 
178
+ function isDestructuredFieldsRead(declaration: VariableDeclaration): boolean {
179
+ const pattern = declaration.getNameNode();
180
+ const initializer = declaration.getInitializer();
181
+ if (!pattern.isKind(SyntaxKind.ObjectBindingPattern) || initializer === undefined) return false;
182
+ if (!isSectionReceiver(initializer)) return false;
183
+ return pattern
184
+ .getElements()
185
+ .some(
186
+ (element) => (element.getPropertyNameNode() ?? element.getNameNode()).getText() === "fields",
187
+ );
188
+ }
189
+
128
190
  export interface Finding {
129
191
  file: string;
130
192
  line: number;
@@ -138,7 +200,7 @@ export function scanRawSectionFieldReads(
138
200
  const findings: Finding[] = [];
139
201
  for (const access of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
140
202
  if (access.getName() !== "fields") continue;
141
- if (!SECTION_RECEIVER.test(access.getExpression().getText())) continue;
203
+ if (!isSectionReceiver(access.getExpression())) continue;
142
204
  if (!isSequenceRead(access)) continue;
143
205
  if (hasReasonedIgnoreTag(access)) continue;
144
206
  const line = access.getStartLineNumber();
@@ -148,6 +210,16 @@ export function scanRawSectionFieldReads(
148
210
  ` [section-fields-raw WARN] ${file}:${line} raw read of ${access.getText().slice(0, 80)} — use sectionFieldSpecs(section)`,
149
211
  );
150
212
  }
213
+ for (const declaration of sf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
214
+ if (!isDestructuredFieldsRead(declaration)) continue;
215
+ if (hasReasonedIgnoreTag(declaration)) continue;
216
+ const line = declaration.getStartLineNumber();
217
+ const file = relFile(sf, roots);
218
+ findings.push({ file, line, snippet: declaration.getText().slice(0, 80) });
219
+ console.warn(
220
+ ` [section-fields-raw WARN] ${file}:${line} destructured fields of ${declaration.getText().slice(0, 80)} — use sectionFieldSpecs(section)`,
221
+ );
222
+ }
151
223
  return findings;
152
224
  }
153
225
 
@@ -94,8 +94,18 @@ function collectCallNames(sf: SourceFile): Map<string, number[]> {
94
94
  return calls;
95
95
  }
96
96
 
97
+ // Only a marker inside a real comment counts: the same text
98
+ // inside a string/template literal is fixture data, not an opt-out.
97
99
  function hasOptOutMarker(sf: SourceFile): boolean {
98
- return OPT_OUT_MARKER.test(sf.getFullText());
100
+ const text = sf.getFullText();
101
+ const marker = new RegExp(OPT_OUT_MARKER.source, "gi");
102
+ for (const match of text.matchAll(marker)) {
103
+ const kind = sf.getDescendantAtPos(match.index)?.getKind();
104
+ if (kind === SyntaxKind.SingleLineCommentTrivia || kind === SyntaxKind.MultiLineCommentTrivia) {
105
+ return true;
106
+ }
107
+ }
108
+ return false;
99
109
  }
100
110
 
101
111
  export function scanFile(sf: SourceFile): Violation | null {
@@ -74,6 +74,16 @@ const RAW_DEFINE_CONFIG_MODULE = "@playwright/test";
74
74
 
75
75
  const TEMPLATE_OWNED_KEYS: ReadonlySet<string> = new Set(["viewport", "deviceScaleFactor"]);
76
76
 
77
+ // Top-level config keys the template owns; spreading defineAppE2eConfig(...)
78
+ // and then setting one of them re-opens the drift the template closes.
79
+ const TEMPLATE_OWNED_CONFIG_KEYS: ReadonlySet<string> = new Set([
80
+ "fullyParallel",
81
+ "retries",
82
+ "timeout",
83
+ "workers",
84
+ "expect",
85
+ ]);
86
+
77
87
  export interface Finding {
78
88
  file: string;
79
89
  line: number;
@@ -148,7 +158,11 @@ function isPlaywrightUseObject(node: Node): boolean {
148
158
  function templateOwnedKeyFindings(sf: SourceFile): Finding[] {
149
159
  const findings: Finding[] = [];
150
160
  const isConfigFile = CONFIG_FILENAME.test(sf.getFilePath());
151
- for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
161
+ const props = [
162
+ ...sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment),
163
+ ...sf.getDescendantsOfKind(SyntaxKind.ShorthandPropertyAssignment),
164
+ ];
165
+ for (const prop of props) {
152
166
  const name = prop.getName();
153
167
  if (!TEMPLATE_OWNED_KEYS.has(name)) continue;
154
168
  // viewport is only drift in the config's own use/project use — a spec's
@@ -170,6 +184,34 @@ function templateOwnedKeyFindings(sf: SourceFile): Finding[] {
170
184
  return findings;
171
185
  }
172
186
 
187
+ function isDefineAppE2eConfigSpread(node: Node): boolean {
188
+ if (!Node.isSpreadAssignment(node)) return false;
189
+ const spread = node.getExpression();
190
+ return (
191
+ Node.isCallExpression(spread) && spread.getExpression().getText() === DEFINE_APP_E2E_CONFIG
192
+ );
193
+ }
194
+
195
+ function configOverrideFindings(sf: SourceFile): Finding[] {
196
+ const findings: Finding[] = [];
197
+ for (const literal of sf.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)) {
198
+ const properties = literal.getProperties();
199
+ if (!properties.some(isDefineAppE2eConfigSpread)) continue;
200
+ for (const prop of properties) {
201
+ if (!Node.isPropertyAssignment(prop) && !Node.isShorthandPropertyAssignment(prop)) continue;
202
+ const name = prop.getName();
203
+ if (!TEMPLATE_OWNED_CONFIG_KEYS.has(name)) continue;
204
+ const message = withExceptionNote(
205
+ prop,
206
+ `Config key "${name}" next to a ...defineAppE2eConfig(...) spread — timeouts, retries, workers and expect belong to the template; pass the supported options to defineAppE2eConfig instead.`,
207
+ );
208
+ if (message !== undefined)
209
+ findings.push({ file: "", line: prop.getStartLineNumber(), message });
210
+ }
211
+ }
212
+ return findings;
213
+ }
214
+
173
215
  function isPageScreenshotCall(call: Node): boolean {
174
216
  if (!Node.isCallExpression(call)) return false;
175
217
  const callee = call.getExpression();
@@ -202,7 +244,11 @@ export function scanTemplateDrift(
202
244
  const findings: Finding[] = [];
203
245
  const own = ownConfigFinding(sf);
204
246
  if (own !== undefined) findings.push({ ...own, file });
205
- for (const f of [...templateOwnedKeyFindings(sf), ...pageScreenshotFindings(sf)]) {
247
+ for (const f of [
248
+ ...templateOwnedKeyFindings(sf),
249
+ ...configOverrideFindings(sf),
250
+ ...pageScreenshotFindings(sf),
251
+ ]) {
206
252
  findings.push({ ...f, file });
207
253
  }
208
254
  return findings.sort((a, b) => a.line - b.line);
@@ -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
 
@@ -109,9 +109,12 @@ export function pendingViolations(json: UpgradeJson, markerVersion: string): Gua
109
109
  }));
110
110
  }
111
111
 
112
- function resolveKumikoUpgradeBin(cwd: string): string | undefined {
113
- const which = Bun.which("kumiko-upgrade");
114
- if (which) return which;
112
+ export function resolveKumikoUpgradeBin(
113
+ cwd: string,
114
+ pathEnv: string | undefined = process.env["PATH"],
115
+ ): string | undefined {
116
+ // Repo-pinned binary wins over PATH so the marker is measured against the
117
+ // version this checkout actually installs.
115
118
  let dir = cwd;
116
119
  for (;;) {
117
120
  const candidate = join(dir, "node_modules", ".bin", "kumiko-upgrade");
@@ -120,7 +123,7 @@ function resolveKumikoUpgradeBin(cwd: string): string | undefined {
120
123
  if (parent === dir) break;
121
124
  dir = parent;
122
125
  }
123
- return undefined;
126
+ return Bun.which("kumiko-upgrade", { PATH: pathEnv }) ?? undefined;
124
127
  }
125
128
 
126
129
  async function runKumikoUpgrade(
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);
@@ -169,5 +173,6 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
169
173
  // Only run on direct invocation — otherwise `import { GUARDS }` would kick
170
174
  // off the whole suite and the list wouldn't be testable.
171
175
  if (import.meta.main) {
172
- process.exit(runGuardsCli(process.argv.slice(2)));
176
+ // Exit codes wrap at 256, so a raw failure count could read as success.
177
+ process.exit(runGuardsCli(process.argv.slice(2)) > 0 ? 1 : 0);
173
178
  }
@@ -49,5 +49,5 @@ export async function runRepoChecksCli(argv: readonly string[]): Promise<number>
49
49
  }
50
50
 
51
51
  if (import.meta.main) {
52
- process.exit(await runRepoChecksCli(process.argv.slice(2)));
52
+ process.exit((await runRepoChecksCli(process.argv.slice(2))) > 0 ? 1 : 0);
53
53
  }
@@ -47,5 +47,5 @@ export function runUiGuardsCli(argv: readonly string[]): number {
47
47
 
48
48
  // Same as run-guards.ts: only run on direct invocation.
49
49
  if (import.meta.main) {
50
- process.exit(runUiGuardsCli(process.argv.slice(2)));
50
+ process.exit(runUiGuardsCli(process.argv.slice(2)) > 0 ? 1 : 0);
51
51
  }
@@ -11,7 +11,7 @@
11
11
  // classification rules down so future edits trip a test, not a
12
12
  // production drift.
13
13
 
14
- import { existsSync, readFileSync } from "node:fs";
14
+ import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
15
15
  import * as path from "node:path";
16
16
  import type { ImportDeclaration, SourceFile } from "ts-morph";
17
17
 
@@ -96,11 +96,54 @@ export function classifyByPath(repoRelativePath: string): Runtime | null {
96
96
  * their sources live under `packages/*\/src/`, not a repo-root `src/`. Kept
97
97
  * independent of the framework's own regex (cross-package import would be a
98
98
  * build-vs-lint layering violation) — if apps stop following this naming
99
- * convention, this drifts and needs a matching update.
99
+ * convention, this drifts and needs a matching update. Entries the app
100
+ * declares itself (`readDeclaredClientEntries`) count in addition, so a
101
+ * `./src/admin/index.tsx` entry is classified as browser code too.
100
102
  */
101
- export function isClientEntryPath(repoRelativePath: string): boolean {
103
+ export function isClientEntryPath(
104
+ repoRelativePath: string,
105
+ declaredEntries: ReadonlySet<string> = new Set(),
106
+ ): boolean {
102
107
  const rel = repoRelativePath.replace(/\\/g, "/");
103
- return /^src\/client(-[a-z][a-z0-9-]*)?\.tsx?$/.test(rel);
108
+ return declaredEntries.has(rel) || /^src\/client(-[a-z][a-z0-9-]*)?\.tsx?$/.test(rel);
109
+ }
110
+
111
+ function isRecord(value: unknown): value is Record<string, unknown> {
112
+ return typeof value === "object" && value !== null && !Array.isArray(value);
113
+ }
114
+
115
+ /**
116
+ * Parses `kumiko.clientEntry` / `kumiko.clientEntries[].sourceFile` out of a
117
+ * package.json value into repo-relative paths. Parsed locally instead of
118
+ * importing the build's resolver (layering: lint must not depend on
119
+ * server-runtime); anything malformed is ignored — `kumiko build` owns
120
+ * rejecting it.
121
+ */
122
+ export function parseDeclaredClientEntries(packageJson: unknown): ReadonlySet<string> {
123
+ const entries = new Set<string>();
124
+ const kumiko = isRecord(packageJson) ? packageJson["kumiko"] : undefined;
125
+ if (!isRecord(kumiko)) return entries;
126
+ const sources: unknown[] = [kumiko["clientEntry"]];
127
+ const multi = kumiko["clientEntries"];
128
+ if (Array.isArray(multi)) {
129
+ for (const entry of multi) sources.push(isRecord(entry) ? entry["sourceFile"] : undefined);
130
+ }
131
+ for (const source of sources) {
132
+ if (typeof source === "string") {
133
+ entries.add(path.posix.normalize(source.replace(/\\/g, "/")));
134
+ }
135
+ }
136
+ return entries;
137
+ }
138
+
139
+ export function readDeclaredClientEntries(repoAbsPath: string): ReadonlySet<string> {
140
+ const file = path.join(repoAbsPath, "package.json");
141
+ if (!existsSync(file)) return new Set();
142
+ try {
143
+ return parseDeclaredClientEntries(JSON.parse(readFileSync(file, "utf8")));
144
+ } catch {
145
+ return new Set();
146
+ }
104
147
  }
105
148
 
106
149
  /**
@@ -122,6 +165,42 @@ export function isValueImport(decl: ImportDeclaration): boolean {
122
165
  return true;
123
166
  }
124
167
 
168
+ export type ValueModuleEdge = {
169
+ readonly spec: string;
170
+ readonly target: SourceFile;
171
+ readonly line: number;
172
+ };
173
+
174
+ /**
175
+ * Every runtime-weighted edge out of a file: value imports plus value
176
+ * re-exports (`export { x } from`, `export * from`) — barrels are how most
177
+ * client entries reach runtime code. Shared by the direct-edge check and the
178
+ * reachability walk so both see the same edge set.
179
+ */
180
+ export function valueModuleEdges(sf: SourceFile): ValueModuleEdge[] {
181
+ const edges: ValueModuleEdge[] = [];
182
+ for (const decl of sf.getImportDeclarations()) {
183
+ if (!isValueImport(decl)) continue;
184
+ const target = decl.getModuleSpecifierSourceFile();
185
+ if (!target) continue;
186
+ edges.push({
187
+ spec: decl.getModuleSpecifierValue(),
188
+ target,
189
+ line: decl.getStartLineNumber(),
190
+ });
191
+ }
192
+ for (const decl of sf.getExportDeclarations()) {
193
+ if (decl.isTypeOnly()) continue;
194
+ const named = decl.getNamedExports();
195
+ if (named.length > 0 && named.every((n) => n.isTypeOnly())) continue;
196
+ const target = decl.getModuleSpecifierSourceFile();
197
+ const spec = decl.getModuleSpecifierValue();
198
+ if (!target || spec === undefined) continue;
199
+ edges.push({ spec, target, line: decl.getStartLineNumber() });
200
+ }
201
+ return edges;
202
+ }
203
+
125
204
  /**
126
205
  * Map dist declaration files back to src. Project References make ts-morph
127
206
  * resolve imports to `.d.ts` under `dist/`; classification and the reachable
@@ -159,10 +238,7 @@ export function computeClientReachablePaths(
159
238
  if (fp.includes("/node_modules/") || fp.includes("/dist/")) continue;
160
239
  if (reached.has(fp)) continue;
161
240
  reached.add(fp);
162
- for (const decl of sf.getImportDeclarations()) {
163
- if (!isValueImport(decl)) continue;
164
- const target = decl.getModuleSpecifierSourceFile();
165
- if (!target) continue;
241
+ for (const { target } of valueModuleEdges(sf)) {
166
242
  const targetPath = toEffectivePath(target.getFilePath());
167
243
  if (targetPath.includes("/node_modules/") || targetPath.includes("/dist/")) continue;
168
244
  if (!reached.has(targetPath)) queue.push(target);
@@ -171,24 +247,69 @@ export function computeClientReachablePaths(
171
247
  return reached;
172
248
  }
173
249
 
174
- /**
175
- * Classify a file by its top-of-file `// @runtime <kind>` directive.
176
- * Reads the first 600 bytes only (cap blast radius on huge files).
177
- */
178
- export function classifyByDirective(filePath: string): Runtime | null {
179
- let head: string;
250
+ const DIRECTIVE_HEAD_BYTES = 600;
251
+ const DIRECTIVE_HEAD_LINES = 8;
252
+
253
+ export type HeadReader = (filePath: string) => string | null;
254
+
255
+ // Reads only the file head, so classifying a large source file does not pull
256
+ // it fully into memory.
257
+ export function readFileHead(filePath: string): string | null {
258
+ let fd: number;
259
+ try {
260
+ fd = openSync(filePath, "r");
261
+ } catch {
262
+ return null;
263
+ }
180
264
  try {
181
- head = readFileSync(filePath, "utf8").slice(0, 600);
265
+ const buffer = Buffer.alloc(DIRECTIVE_HEAD_BYTES);
266
+ const bytesRead = readSync(fd, buffer, 0, DIRECTIVE_HEAD_BYTES, 0);
267
+ return buffer.toString("utf8", 0, bytesRead);
182
268
  } catch {
183
269
  return null;
270
+ } finally {
271
+ closeSync(fd);
184
272
  }
185
- for (const line of head.split("\n").slice(0, 8)) {
273
+ }
274
+
275
+ /**
276
+ * Classify a file by its top-of-file `// @runtime <kind>` directive.
277
+ * Reads the first 600 bytes only (cap blast radius on huge files).
278
+ */
279
+ export function classifyByDirective(
280
+ filePath: string,
281
+ readHead: HeadReader = readFileHead,
282
+ ): Runtime | null {
283
+ const head = readHead(filePath);
284
+ if (head === null) return null;
285
+ for (const line of head.split("\n").slice(0, DIRECTIVE_HEAD_LINES)) {
186
286
  const m = line.match(/\/\/\s*@runtime\s+(\w+)/);
187
287
  if (m && ALL_RUNTIMES.has(m[1] ?? "")) return m[1] as Runtime;
188
288
  }
189
289
  return null;
190
290
  }
191
291
 
292
+ export type DirectiveClassifier = (filePath: string) => Runtime | null;
293
+
294
+ /**
295
+ * Per-run memo of `classifyByDirective`: the guard classifies each file in
296
+ * several passes (client reachability, then violations), and each pass would
297
+ * otherwise re-read the same head. Created per run, not module-global, so
298
+ * tests stay isolated.
299
+ */
300
+ export function createDirectiveClassifier(
301
+ readHead: HeadReader = readFileHead,
302
+ ): DirectiveClassifier {
303
+ const cache = new Map<string, Runtime | null>();
304
+ return (filePath) => {
305
+ const cached = cache.get(filePath);
306
+ if (cached !== undefined) return cached;
307
+ const result = classifyByDirective(filePath, readHead);
308
+ cache.set(filePath, result);
309
+ return result;
310
+ };
311
+ }
312
+
192
313
  /**
193
314
  * Walk upward from `filePath` looking for the nearest package.json that
194
315
  * carries a `kumiko.runtime` marker. Stops at `repoRoot`. Caches per
@@ -201,7 +322,8 @@ export function findWorkspaceRuntime(
201
322
  cache: Map<string, Runtime | null>,
202
323
  ): Runtime | null {
203
324
  let dir = path.dirname(filePath);
204
- while (dir.startsWith(repoRoot) && dir !== repoRoot) {
325
+ // Includes repoRoot itself: a single-package consumer repo keeps its marker there.
326
+ while (dir.startsWith(repoRoot)) {
205
327
  const r = readWorkspaceRuntime(dir, cache);
206
328
  if (r) return r;
207
329
  // Stop at the first package.json — don't fall through to a parent
@@ -212,6 +334,7 @@ export function findWorkspaceRuntime(
212
334
  } catch {
213
335
  // No package.json here — keep climbing.
214
336
  }
337
+ if (dir === repoRoot) break;
215
338
  dir = path.dirname(dir);
216
339
  }
217
340
  return null;
@@ -250,12 +373,13 @@ export function classify(
250
373
  repoRoot: string,
251
374
  workspaceCache: Map<string, Runtime | null>,
252
375
  clientReachable?: ReadonlySet<string>,
376
+ directiveOf: DirectiveClassifier = (file) => classifyByDirective(file),
253
377
  ): Runtime {
254
378
  const effectivePath = toEffectivePath(filePath);
255
379
 
256
380
  const rel = path.relative(repoRoot, effectivePath);
257
381
  return (
258
- classifyByDirective(effectivePath) ??
382
+ directiveOf(effectivePath) ??
259
383
  classifyByPath(rel) ??
260
384
  findWorkspaceRuntime(effectivePath, repoRoot, workspaceCache) ??
261
385
  (clientReachable?.has(effectivePath) ? "client" : undefined) ??
@@ -284,6 +408,7 @@ export function findRuntimeIsolationViolations(
284
408
  repoRoot: string,
285
409
  workspaceCache: Map<string, Runtime | null>,
286
410
  clientReachable: ReadonlySet<string> = new Set(),
411
+ directiveOf: DirectiveClassifier = createDirectiveClassifier(),
287
412
  ): {
288
413
  readonly violations: readonly Violation[];
289
414
  readonly stats: Record<Runtime, number>;
@@ -315,13 +440,10 @@ export function findRuntimeIsolationViolations(
315
440
  noteOutside(fp);
316
441
  continue;
317
442
  }
318
- const fileRt = classify(fp, repoRoot, workspaceCache, clientReachable);
443
+ const fileRt = classify(fp, repoRoot, workspaceCache, clientReachable, directiveOf);
319
444
  stats[fileRt]++;
320
445
 
321
- for (const decl of sf.getImportDeclarations()) {
322
- if (!isValueImport(decl)) continue;
323
- const target = decl.getModuleSpecifierSourceFile();
324
- if (!target) continue;
446
+ for (const { spec, target, line } of valueModuleEdges(sf)) {
325
447
  const targetPath = target.getFilePath();
326
448
  if (targetPath.includes("/node_modules/")) continue;
327
449
  if (!withinRoot(targetPath)) {
@@ -333,9 +455,9 @@ export function findRuntimeIsolationViolations(
333
455
  if (!COMPAT[fileRt].has(targetRt)) {
334
456
  violations.push({
335
457
  file: fp,
336
- line: decl.getStartLineNumber(),
458
+ line,
337
459
  fileRuntime: fileRt,
338
- importedSpec: decl.getModuleSpecifierValue(),
460
+ importedSpec: spec,
339
461
  importedFile: targetPath,
340
462
  importedRuntime: targetRt,
341
463
  });