@cosmicdrift/kumiko-guards 0.281.0 → 0.282.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.281.0",
3
+ "version": "0.282.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>",
@@ -27,7 +27,7 @@
27
27
  "kumiko-guards": "./src/cli.ts"
28
28
  },
29
29
  "dependencies": {
30
- "@cosmicdrift/kumiko-repo-manifest": "0.281.0",
30
+ "@cosmicdrift/kumiko-repo-manifest": "0.282.0",
31
31
  "ts-morph": "^28.0.0"
32
32
  },
33
33
  "publishConfig": {
@@ -479,6 +479,23 @@ export function guardKitPreflightError(guardCount: number, rootCount: number): s
479
479
  return undefined;
480
480
  }
481
481
 
482
+ /**
483
+ * Validates CLI args for one subcommand against the flags it actually
484
+ * understands — every arg must match `known` exactly (not just a `--`-prefix
485
+ * check, which let a single-dash typo or a stray positional through
486
+ * unnoticed). An unknown arg must fail loud, never pass through silently.
487
+ */
488
+ export function cliFlagsError(
489
+ subcommand: string,
490
+ argv: readonly string[],
491
+ known: readonly string[],
492
+ ): string | undefined {
493
+ const unknown = argv.filter((arg) => !known.includes(arg));
494
+ if (unknown.length === 0) return undefined;
495
+ const knownList = known.length > 0 ? known.join(", ") : "(none)";
496
+ return `Unknown argument${unknown.length > 1 ? "s" : ""} for "${subcommand}": ${unknown.join(", ")}. Known flags: ${knownList}`;
497
+ }
498
+
482
499
  export type GuardKitBannerDeps = {
483
500
  readonly resolution?: RootResolution;
484
501
  };
@@ -505,6 +522,47 @@ export function printGuardKitBanner(
505
522
  if (project) console.log(`Project: ${project.getSourceFiles().length} files`);
506
523
  }
507
524
 
525
+ export type SuiteInventory = {
526
+ readonly count: number;
527
+ readonly names: readonly string[];
528
+ };
529
+
530
+ export type GuardKitInventory = {
531
+ readonly version: string;
532
+ readonly total: number;
533
+ readonly suites: {
534
+ readonly guards: SuiteInventory;
535
+ readonly ui: SuiteInventory;
536
+ readonly checks: SuiteInventory;
537
+ };
538
+ };
539
+
540
+ function suiteInventory(items: readonly { readonly name: string }[]): SuiteInventory {
541
+ const names = items.map((item) => item.name);
542
+ return { count: names.length, names };
543
+ }
544
+
545
+ /**
546
+ * Static registration inventory for the three suites — reads each array's
547
+ * `.name` only, no scan/project/guard.run(). What a consumer's CI checks
548
+ * against instead of running the guards, e.g. so a guard dropped from a
549
+ * suite's array is missing here too, not just silently absent from a run.
550
+ */
551
+ export function buildGuardKitInventory(args: {
552
+ readonly guards: readonly { readonly name: string }[];
553
+ readonly uiGuards: readonly { readonly name: string }[];
554
+ readonly checks: readonly { readonly name: string }[];
555
+ }): GuardKitInventory {
556
+ const guards = suiteInventory(args.guards);
557
+ const ui = suiteInventory(args.uiGuards);
558
+ const checks = suiteInventory(args.checks);
559
+ return {
560
+ version: guardKitVersion(),
561
+ total: guards.count + ui.count + checks.count,
562
+ suites: { guards, ui, checks },
563
+ };
564
+ }
565
+
508
566
  /**
509
567
  * Vacuity floor for guards with their own `main()`.
510
568
  *
package/src/changes.json CHANGED
@@ -1,4 +1,21 @@
1
1
  [
2
+ {
3
+ "version": "0.282.0",
4
+ "type": "fix",
5
+ "title": "kumiko-guards bin now passes flags through to the guards, ui, and checks subcommands",
6
+ "detail": "The bin's `guards` subcommand called run-guards.ts's suite runner directly, skipping the `--explain`/`--write-security-baseline`/`--strict-security-baseline` handling that only existed in run-guards.ts's own `if (import.meta.main)` block — a consumer running `bunx @cosmicdrift/kumiko-guards guards --write-security-baseline` got a normal guard run with the flag silently dropped. Each suite (guards, ui, checks) now exports a `run*Cli(argv)` function that validates argv against that suite's known flags and applies them; both the bin and the suite's own direct-invocation entry point call the same function, so they cannot drift apart again. Validation matches each arg exactly against the known list (not just a `--`-prefix check), so a single-dash typo or a stray positional also exits 1 with the flags that subcommand accepts, rather than passing through unnoticed."
7
+ },
8
+ {
9
+ "version": "0.282.0",
10
+ "type": "improvement",
11
+ "title": "kumiko-guards list prints the registration inventory as JSON"
12
+ },
13
+ {
14
+ "version": "0.282.0",
15
+ "type": "improvement",
16
+ "title": "Port the upgrade-state and runtime-isolation guards",
17
+ "detail": "guard-upgrade-state and check-runtime-isolation ported from the private infra/guards package into the public @cosmicdrift/kumiko-guards package, rebuilt onto the public RepoCheck pattern with single-repo root resolution. guard-app-dockerfile, guard-doc-status, check-licenses, and check-security stay in infra/guards as CDGS-specific house rules (private registry scope, CDGS doc taxonomy, and CDGS license/security exception files with no equivalent consumer-facing mechanism in the public package) — they were deliberately not ported, not forgotten."
18
+ },
2
19
  {
3
20
  "version": "0.3.0",
4
21
  "type": "improvement",
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env bun
2
+ // Runtime-Isolation Guard.
3
+ //
4
+ // Prevents production code (runtime) from transitively loading test or
5
+ // tooling modules — the original trigger was a top-level Vitest import in a
6
+ // testing helper that crashed drizzle-kit under Node. Every file gets a
7
+ // runtime context assigned; every import edge is checked against a compat
8
+ // matrix.
9
+ //
10
+ // Per-file classification (highest priority first):
11
+ // 1. File directive → // @runtime <kind> in the first 5 lines
12
+ // 2. Path pattern → *.test.ts, *.integration.ts, *.e2e.ts,
13
+ // **/__tests__/**, **/testing/** → test;
14
+ // plus a handful of verified-isomorphic
15
+ // single-file/directory carve-outs (see
16
+ // runtime-isolation-classify.ts)
17
+ // 3. Workspace → package.json `"kumiko": { "runtime": "..." }`
18
+ // 4. Client reachability → transitively reachable, via value imports,
19
+ // from a browser-bundle entry
20
+ // (`src/client-*.tsx`) or from any file that
21
+ // already classifies as "client" on its own
22
+ // (directive/path/workspace)
23
+ // 5. Default → runtime
24
+ //
25
+ // Compat matrix: which runtime context may import which.
26
+ // runtime → runtime, client
27
+ // client → client
28
+ // dev → runtime, client, dev, tooling
29
+ // tooling → runtime, client, dev, tooling, test
30
+ // test → everything
31
+ //
32
+ // Single-repo only (unlike infra/guards' check-runtime-isolation.ts, which
33
+ // scans every sibling checkout in one shared ts-morph project): this public
34
+ // package only ever resolves the repo `cwd` sits in, so there is exactly one
35
+ // repo root to classify files against.
36
+ //
37
+ // Pure classification + violation detection live in
38
+ // `runtime-isolation-classify.ts` (unit-testable with an in-memory ts-morph
39
+ // project) — this file only does the repo/glob resolution, ts-morph project
40
+ // setup, and RepoCheck wiring.
41
+ //
42
+ // A file/import edge classified "client" importing "runtime" can be a real
43
+ // finding this guard needs to see, but can also be a known, reviewed
44
+ // exception (e.g. samples/recipes are worked examples, not the framework's
45
+ // own production surface — see kumiko-framework#2337 for the one currently
46
+ // frozen here). Exceptions are tracked via a per-repo baseline file
47
+ // (`.kumiko-runtime-isolation-baseline.json`, same `baselineRatchet`
48
+ // mechanism as check-complexity/guard-pii-annotations/etc.), not a
49
+ // hardcoded list in this source file: a consumer repo can freeze or clear
50
+ // its own findings the same way, with `--write-baseline`.
51
+ //
52
+ // Usage:
53
+ // bun packages/guards/src/check-runtime-isolation.ts
54
+ // bun packages/guards/src/check-runtime-isolation.ts --write-baseline
55
+
56
+ import * as path from "node:path";
57
+ import { Project } from "ts-morph";
58
+ import {
59
+ baselineRatchet,
60
+ type GuardViolation,
61
+ type RepoCheck,
62
+ reportResults,
63
+ runRepoChecks,
64
+ } from "./_lib/guard-kit";
65
+ import { frameworkTsConfigPath, type RepoRoot, resolveRepoRoots } from "./_lib/roots";
66
+ import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
67
+ import {
68
+ classify,
69
+ computeClientReachablePaths,
70
+ findRuntimeIsolationViolations,
71
+ isClientEntryPath,
72
+ type Runtime,
73
+ } from "./runtime-isolation-classify";
74
+
75
+ const SCAN: ScanSpec = { scope: "source", extensions: ["ts", "tsx"] };
76
+ const BASELINE_FILE = ".kumiko-runtime-isolation-baseline.json";
77
+
78
+ type RawViolation = {
79
+ readonly file: string;
80
+ readonly line: number;
81
+ readonly fileRuntime: Runtime;
82
+ readonly importedSpec: string;
83
+ readonly importedRuntime: Runtime;
84
+ };
85
+
86
+ function printViolation(v: RawViolation, root: string): void {
87
+ const fileRel = path.relative(root, v.file);
88
+ console.log(` ${fileRel}:${v.line}`);
89
+ console.log(` [${v.fileRuntime}] imports [${v.importedRuntime}] "${v.importedSpec}"`);
90
+ }
91
+
92
+ function violationKey(rootAbsPath: string, v: RawViolation): string {
93
+ return `${path.relative(rootAbsPath, v.file)}::${v.importedSpec}`;
94
+ }
95
+
96
+ function countByKey(
97
+ rootAbsPath: string,
98
+ violations: readonly RawViolation[],
99
+ ): Record<string, number> {
100
+ const counts: Record<string, number> = {};
101
+ for (const v of violations) {
102
+ const key = violationKey(rootAbsPath, v);
103
+ counts[key] = (counts[key] ?? 0) + 1;
104
+ }
105
+ return counts;
106
+ }
107
+
108
+ function ratchetFor(rootAbsPath: string) {
109
+ return baselineRatchet({
110
+ file: path.join(rootAbsPath, BASELINE_FILE),
111
+ formatVersion: 1,
112
+ unit: "runtime-isolation violation(s)",
113
+ });
114
+ }
115
+
116
+ function scanRoot(root: RepoRoot): {
117
+ allViolations: readonly RawViolation[];
118
+ outsideRoot: readonly string[];
119
+ scannedFiles: number;
120
+ } {
121
+ const tsConfigFilePath = frameworkTsConfigPath();
122
+ const project =
123
+ tsConfigFilePath !== undefined
124
+ ? new Project({
125
+ tsConfigFilePath,
126
+ skipAddingFilesFromTsConfig: true,
127
+ skipFileDependencyResolution: true,
128
+ })
129
+ : new Project({ skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: true });
130
+
131
+ const paths = scanFiles(SCAN, [root]);
132
+ // Exact-path lookup, never a re-glob: `project.getSourceFiles(paths)`
133
+ // treats each array entry as a glob pattern — pathologically slow (and,
134
+ // for a bracketed filename like `[id].tsx`, a broken character class)
135
+ // over thousands of paths. `getSourceFile(path)` is an exact lookup.
136
+ const scannedFiles = paths
137
+ .map((p) => project.getSourceFile(p) ?? project.addSourceFileAtPath(p))
138
+ .filter((f) => {
139
+ const fp = f.getFilePath();
140
+ return !fp.includes("/node_modules/") && !fp.includes("/dist/");
141
+ });
142
+
143
+ const workspaceCache = new Map<string, Runtime | null>();
144
+ const clientReachable = computeClientReachablePaths(scannedFiles, (sf) => {
145
+ const rel = path.relative(root.absPath, sf.getFilePath());
146
+ if (isClientEntryPath(rel)) return true;
147
+ return classify(sf.getFilePath(), root.absPath, workspaceCache) === "client";
148
+ });
149
+
150
+ const { violations: allViolations, outsideRoot } = findRuntimeIsolationViolations(
151
+ scannedFiles,
152
+ root.absPath,
153
+ workspaceCache,
154
+ clientReachable,
155
+ );
156
+
157
+ return { allViolations, outsideRoot, scannedFiles: scannedFiles.length };
158
+ }
159
+
160
+ export const check: RepoCheck = {
161
+ name: "Runtime-Isolation Check",
162
+ hint:
163
+ "runtime may import runtime/client only; client may import client only; " +
164
+ "dev/tooling/test are more permissive. See runtime-isolation-classify.ts for the compat matrix. " +
165
+ "New, deliberate exception? `bun packages/guards/src/check-runtime-isolation.ts --write-baseline`",
166
+ run(roots) {
167
+ const root = roots[0];
168
+ if (!root) return { violations: [], matchedFiles: 0, notApplicable: true };
169
+ const rootAbsPath = root.absPath;
170
+
171
+ const { allViolations, outsideRoot, scannedFiles } = scanRoot(root);
172
+ for (const v of allViolations) printViolation(v, rootAbsPath);
173
+
174
+ const counts = countByKey(rootAbsPath, allViolations);
175
+ const resolveLine = (key: string): number => {
176
+ const relFile = key.split("::")[0];
177
+ return allViolations.find((v) => path.relative(rootAbsPath, v.file) === relFile)?.line ?? 1;
178
+ };
179
+ const violations: GuardViolation[] = ratchetFor(rootAbsPath).check(
180
+ counts,
181
+ "runtime may import runtime/client only; client may import client only — see runtime-isolation-classify.ts for the compat matrix.",
182
+ {
183
+ formatDriftRemediation:
184
+ "Run `bun packages/guards/src/check-runtime-isolation.ts --write-baseline` once.",
185
+ resolveLine,
186
+ },
187
+ );
188
+
189
+ if (outsideRoot.length > 0) {
190
+ console.log(
191
+ ` Runtime-Isolation Check: ${outsideRoot.length} import target(s) outside the repo root (skipped).`,
192
+ );
193
+ }
194
+
195
+ return { violations, matchedFiles: scannedFiles, notApplicable: false };
196
+ },
197
+ };
198
+
199
+ if (import.meta.main) {
200
+ const args = process.argv.slice(2);
201
+ if (args.includes("--write-baseline")) {
202
+ const root = resolveRepoRoots()[0];
203
+ if (!root) {
204
+ console.error("No repo root resolved — nothing to baseline.");
205
+ process.exit(1);
206
+ }
207
+ const { allViolations } = scanRoot(root);
208
+ for (const v of allViolations) printViolation(v, root.absPath);
209
+ ratchetFor(root.absPath).write(countByKey(root.absPath, allViolations));
210
+ process.exit(0);
211
+ }
212
+ const failed = reportResults(await runRepoChecks([check]));
213
+ process.exit(failed > 0 ? 1 : 0);
214
+ }
package/src/cli.ts CHANGED
@@ -1,55 +1,79 @@
1
1
  #!/usr/bin/env bun
2
2
  // Consumer entry point for `bunx @cosmicdrift/kumiko-guards`. Runs all three
3
3
  // suites (or one, via subcommand) using the exact call forms the three
4
- // runners already use in their own `if (import.meta.main)` blocks.
5
- import {
6
- buildSharedProject,
7
- printGuardKitBanner,
8
- reportResults,
9
- runGuards,
10
- runRepoChecks,
11
- } from "./_lib/guard-kit";
12
- import { GUARDS } from "./run-guards";
13
- import { REPO_CHECKS } from "./run-repo-checks";
14
- import { UI_GUARDS } from "./run-ui-guards";
15
-
16
- const SUBCOMMANDS = ["guards", "ui", "checks"] as const;
4
+ // runners already use in their own `if (import.meta.main)` blocks — including
5
+ // flags, which each runner's own *Cli function validates and applies so this
6
+ // bin and the direct `bun run-*.ts` invocation can never drift apart.
7
+ import { buildGuardKitInventory, cliFlagsError } from "./_lib/guard-kit";
8
+ import { GUARD_FLAGS, GUARDS, runGuardsCli } from "./run-guards";
9
+ import { REPO_CHECK_FLAGS, REPO_CHECKS, runRepoChecksCli } from "./run-repo-checks";
10
+ import { runUiGuardsCli, UI_GUARD_FLAGS, UI_GUARDS } from "./run-ui-guards";
11
+
12
+ const SUBCOMMANDS = ["guards", "ui", "checks", "list"] as const;
17
13
  type Subcommand = (typeof SUBCOMMANDS)[number];
18
14
 
15
+ const SUBCOMMAND_FLAGS: Record<Subcommand, readonly string[]> = {
16
+ guards: GUARD_FLAGS,
17
+ ui: UI_GUARD_FLAGS,
18
+ checks: REPO_CHECK_FLAGS,
19
+ list: [],
20
+ };
21
+
19
22
  function isSubcommand(value: string): value is Subcommand {
20
23
  return (SUBCOMMANDS as readonly string[]).includes(value);
21
24
  }
22
25
 
23
- function runGuardsSuite(): number {
24
- const project = buildSharedProject(GUARDS);
25
- printGuardKitBanner(GUARDS.length, project);
26
- return reportResults(runGuards(GUARDS, project));
27
- }
28
-
29
- function runUiGuardsSuite(): number {
30
- const project = buildSharedProject(UI_GUARDS);
31
- printGuardKitBanner(UI_GUARDS.length, project);
32
- return reportResults(runGuards(UI_GUARDS, project));
33
- }
34
-
35
- async function runRepoChecksSuite(): Promise<number> {
36
- printGuardKitBanner(REPO_CHECKS.length);
37
- return reportResults(await runRepoChecks(REPO_CHECKS));
26
+ function printHelp(): void {
27
+ console.log("Usage: kumiko-guards [guards|ui|checks|list] [flags]");
28
+ console.log();
29
+ for (const sub of SUBCOMMANDS) {
30
+ const flags = SUBCOMMAND_FLAGS[sub];
31
+ console.log(` ${sub}${flags.length > 0 ? ` [${flags.join("|")}]` : ""}`);
32
+ }
38
33
  }
39
34
 
40
35
  async function main(): Promise<void> {
41
36
  const subcommand = process.argv[2];
37
+ if (subcommand === "--help" || subcommand === "-h") {
38
+ printHelp();
39
+ process.exit(0);
40
+ }
42
41
  if (subcommand !== undefined && !isSubcommand(subcommand)) {
43
42
  console.error(
44
43
  `Unknown subcommand "${subcommand}". Valid subcommands: ${SUBCOMMANDS.join(", ")}`,
45
44
  );
46
45
  process.exit(1);
47
46
  }
47
+ const flags = process.argv.slice(3);
48
+
49
+ if (subcommand === "list") {
50
+ const flagsError = cliFlagsError("list", flags, SUBCOMMAND_FLAGS.list);
51
+ if (flagsError !== undefined) {
52
+ console.error(flagsError);
53
+ process.exit(1);
54
+ }
55
+ // Registration inventory only — no scan, no project, no guard.run(). What
56
+ // CI checks against instead of running the guards: a guard dropped from a
57
+ // suite's array goes missing here too, not just silently from a run.
58
+ const inventory = buildGuardKitInventory({
59
+ guards: GUARDS,
60
+ uiGuards: UI_GUARDS,
61
+ checks: REPO_CHECKS,
62
+ });
63
+ console.log(JSON.stringify(inventory));
64
+ return;
65
+ }
48
66
 
49
67
  let failed = 0;
50
- if (subcommand === undefined || subcommand === "guards") failed += runGuardsSuite();
51
- if (subcommand === undefined || subcommand === "ui") failed += runUiGuardsSuite();
52
- if (subcommand === undefined || subcommand === "checks") failed += await runRepoChecksSuite();
68
+ if (subcommand === undefined || subcommand === "guards") {
69
+ failed += runGuardsCli(subcommand === "guards" ? flags : []);
70
+ }
71
+ if (subcommand === undefined || subcommand === "ui") {
72
+ failed += runUiGuardsCli(subcommand === "ui" ? flags : []);
73
+ }
74
+ if (subcommand === undefined || subcommand === "checks") {
75
+ failed += await runRepoChecksCli(subcommand === "checks" ? flags : []);
76
+ }
53
77
 
54
78
  process.exit(failed > 0 ? 1 : 0);
55
79
  }
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: a consumer repo's `.kumiko/upgrade-state.json` marker must be
4
+ * caught up with the installed Kumiko framework version.
5
+ *
6
+ * The marker is written by `kumiko-upgrade --apply` (local bin from
7
+ * `@cosmicdrift/kumiko-dev-server`) and records the version it was applied
8
+ * at. This guard re-runs `kumiko-upgrade --from <marker version> --json` and
9
+ * fails if any changelog entries are still pending — meaning the marker is
10
+ * stale and the repo hasn't run the upgrade since.
11
+ *
12
+ * Single-repo only, like `guard-upgrade-state.ts` in infra/guards but
13
+ * without that package's multi-repo `resolveRepoRoots()` scan loop — this
14
+ * package's `resolveRepoRoots()` only ever resolves the one repo `roots[0]`
15
+ * sits in. Repos without the marker file are `notApplicable` — this guard
16
+ * only fires once a repo has adopted the upgrade-state workflow at all.
17
+ *
18
+ * Usage:
19
+ * bun guard-upgrade-state.ts
20
+ */
21
+
22
+ import { existsSync, readFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import {
25
+ type GuardViolation,
26
+ type RepoCheck,
27
+ reportResults,
28
+ runRepoChecks,
29
+ } from "./_lib/guard-kit";
30
+
31
+ const MARKER_REL = ".kumiko/upgrade-state.json";
32
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
33
+
34
+ type UpgradeMarker = { readonly version: string };
35
+
36
+ type PendingEntry = {
37
+ readonly version: string;
38
+ readonly type: string;
39
+ readonly title: string;
40
+ };
41
+
42
+ type UpgradeJson = {
43
+ readonly currentVersion: string;
44
+ readonly installedVersion?: string | null;
45
+ readonly pending: readonly PendingEntry[];
46
+ };
47
+
48
+ export function resolveInstalledVersion(json: UpgradeJson): string {
49
+ return json.installedVersion ?? json.currentVersion;
50
+ }
51
+
52
+ export function isPendingEntry(value: unknown): value is PendingEntry {
53
+ if (!value || typeof value !== "object") return false;
54
+ const v = value as Record<string, unknown>;
55
+ return (
56
+ typeof v["version"] === "string" &&
57
+ typeof v["type"] === "string" &&
58
+ typeof v["title"] === "string"
59
+ );
60
+ }
61
+
62
+ export function isUpgradeJson(value: unknown): value is UpgradeJson {
63
+ if (!value || typeof value !== "object") return false;
64
+ const v = value as Record<string, unknown>;
65
+ const installed = v["installedVersion"];
66
+ return (
67
+ typeof v["currentVersion"] === "string" &&
68
+ (installed === undefined || installed === null || typeof installed === "string") &&
69
+ Array.isArray(v["pending"]) &&
70
+ v["pending"].every(isPendingEntry)
71
+ );
72
+ }
73
+
74
+ export function readMarker(root: string): UpgradeMarker | { error: string } {
75
+ const markerPath = join(root, MARKER_REL);
76
+ if (!existsSync(markerPath)) {
77
+ return {
78
+ error: `missing ${MARKER_REL} — run \`bun run kumiko-upgrade --apply\` once and commit the marker file`,
79
+ };
80
+ }
81
+ let parsed: unknown;
82
+ try {
83
+ parsed = JSON.parse(readFileSync(markerPath, "utf-8"));
84
+ } catch {
85
+ return { error: `${MARKER_REL} is not valid JSON — broken marker file` };
86
+ }
87
+ const version =
88
+ parsed && typeof parsed === "object"
89
+ ? (parsed as Record<string, unknown>)["version"]
90
+ : undefined;
91
+ if (typeof version !== "string" || !SEMVER_RE.test(version)) {
92
+ return {
93
+ error: `${MARKER_REL} is missing a valid "version" field (expected semver x.y.z[-pre][+build]) — broken marker file`,
94
+ };
95
+ }
96
+ return { version };
97
+ }
98
+
99
+ /** Pending changelog entries turned into guard violations — pure, testable without a subprocess. */
100
+ export function pendingViolations(json: UpgradeJson, markerVersion: string): GuardViolation[] {
101
+ if (json.pending.length === 0) return [];
102
+ const installedVersion = resolveInstalledVersion(json);
103
+ return json.pending.map((entry) => ({
104
+ file: MARKER_REL,
105
+ line: 1,
106
+ message:
107
+ `${MARKER_REL} is at ${markerVersion}, installed is ${installedVersion} — pending: ` +
108
+ `${entry.version} · ${entry.type} · ${entry.title}. Run \`bun run kumiko-upgrade --apply\`.`,
109
+ }));
110
+ }
111
+
112
+ function resolveKumikoUpgradeBin(cwd: string): string | undefined {
113
+ const which = Bun.which("kumiko-upgrade");
114
+ if (which) return which;
115
+ let dir = cwd;
116
+ for (;;) {
117
+ const candidate = join(dir, "node_modules", ".bin", "kumiko-upgrade");
118
+ if (existsSync(candidate)) return candidate;
119
+ const parent = join(dir, "..");
120
+ if (parent === dir) break;
121
+ dir = parent;
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ async function runKumikoUpgrade(
127
+ version: string,
128
+ cwd: string,
129
+ ): Promise<{ ok: true; json: UpgradeJson } | { ok: false; error: string }> {
130
+ const binPath = resolveKumikoUpgradeBin(cwd);
131
+ if (!binPath) {
132
+ return {
133
+ ok: false,
134
+ error:
135
+ "`kumiko-upgrade` not resolvable — add `@cosmicdrift/kumiko-dev-server` as a devDependency or repair the install",
136
+ };
137
+ }
138
+ const proc = Bun.spawn({
139
+ cmd: [binPath, "--from", version, "--json"],
140
+ cwd,
141
+ env: { ...process.env, INIT_CWD: cwd },
142
+ stdout: "pipe",
143
+ stderr: "pipe",
144
+ });
145
+ const [stdout, stderr, exitCode] = await Promise.all([
146
+ new Response(proc.stdout).text(),
147
+ new Response(proc.stderr).text(),
148
+ proc.exited,
149
+ ]);
150
+ if (exitCode !== 0) {
151
+ return {
152
+ ok: false,
153
+ error: `\`kumiko-upgrade --from ${version} --json\` exited ${exitCode}:\n${stderr || stdout}`,
154
+ };
155
+ }
156
+ let parsed: unknown;
157
+ try {
158
+ const jsonStart = stdout.indexOf("{");
159
+ parsed = JSON.parse(jsonStart >= 0 ? stdout.slice(jsonStart) : stdout);
160
+ } catch {
161
+ return {
162
+ ok: false,
163
+ error: `\`kumiko-upgrade --from ${version} --json\` did not print valid JSON:\n${stdout}${stderr}`,
164
+ };
165
+ }
166
+ if (!isUpgradeJson(parsed)) {
167
+ return {
168
+ ok: false,
169
+ error: `\`kumiko-upgrade --from ${version} --json\` printed JSON without a valid "pending" array:\n${stdout}`,
170
+ };
171
+ }
172
+ return { ok: true, json: parsed };
173
+ }
174
+
175
+ export const check: RepoCheck = {
176
+ name: "Upgrade-State Guard",
177
+ hint: "Marker is written by `kumiko-upgrade --apply` — run it once the pending changelog entries are handled.",
178
+ async run(roots) {
179
+ const root = roots[0];
180
+ if (!root) return { violations: [], matchedFiles: 0, notApplicable: true };
181
+ const rootAbsPath = root.absPath;
182
+ if (!existsSync(join(rootAbsPath, MARKER_REL))) {
183
+ return { violations: [], matchedFiles: 0, notApplicable: true };
184
+ }
185
+ const marker = readMarker(rootAbsPath);
186
+ if ("error" in marker) {
187
+ return {
188
+ violations: [{ file: MARKER_REL, line: 1, message: marker.error }],
189
+ matchedFiles: 1,
190
+ notApplicable: false,
191
+ };
192
+ }
193
+ const result = await runKumikoUpgrade(marker.version, rootAbsPath);
194
+ if (!result.ok) {
195
+ return {
196
+ violations: [{ file: MARKER_REL, line: 1, message: result.error }],
197
+ matchedFiles: 1,
198
+ notApplicable: false,
199
+ };
200
+ }
201
+ return {
202
+ violations: pendingViolations(result.json, marker.version),
203
+ matchedFiles: 1,
204
+ notApplicable: false,
205
+ };
206
+ },
207
+ };
208
+
209
+ if (import.meta.main) {
210
+ const failed = reportResults(await runRepoChecks([check]));
211
+ process.exit(failed > 0 ? 1 : 0);
212
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export {
2
2
  type AstGuard,
3
+ buildGuardKitInventory,
3
4
  buildSharedProject,
4
5
  explainGuards,
5
6
  filesForGuard,
7
+ type GuardKitInventory,
6
8
  type GuardOutcome,
7
9
  type GuardViolation,
8
10
  isSecurityGuard,
@@ -14,6 +16,7 @@ export {
14
16
  runGuards,
15
17
  runRepoChecks,
16
18
  type ScanSpec,
19
+ type SuiteInventory,
17
20
  } from "./_lib/guard-kit";
18
21
  export { findLocalRepo, type RepoRoot, resolveRepoRoots } from "./_lib/roots";
19
22
  export {
package/src/run-guards.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // security guards.
10
10
  import {
11
11
  buildSharedProject,
12
+ cliFlagsError,
12
13
  explainGuards,
13
14
  isSecurityGuard,
14
15
  printGuardKitBanner,
@@ -86,26 +87,43 @@ export const GUARDS = [
86
87
  libTestCoverage,
87
88
  ];
88
89
 
89
- // Only run on direct invocation — otherwise `import { GUARDS }` would kick
90
- // off the whole suite and the list wouldn't be testable.
91
- if (import.meta.main) {
92
- if (process.argv.includes("--explain")) {
90
+ export const GUARD_FLAGS = [
91
+ "--explain",
92
+ "--write-security-baseline",
93
+ "--strict-security-baseline",
94
+ ] as const;
95
+
96
+ // Shared by the direct `bun run-guards.ts` invocation below and by the
97
+ // `guards` subcommand in cli.ts — one place for the flag behavior so the
98
+ // two entry points can never drift.
99
+ export function runGuardsCli(argv: readonly string[]): number {
100
+ const flagsError = cliFlagsError("guards", argv, GUARD_FLAGS);
101
+ if (flagsError !== undefined) {
102
+ console.error(flagsError);
103
+ return 1;
104
+ }
105
+ if (argv.includes("--explain")) {
93
106
  for (const line of explainGuards(GUARDS, buildSharedProject(GUARDS))) {
94
107
  console.log(line);
95
108
  }
96
- process.exit(0);
109
+ return 0;
97
110
  }
98
- if (process.argv.includes("--write-security-baseline")) {
111
+ if (argv.includes("--write-security-baseline")) {
99
112
  writeSecurityBaselines(
100
113
  GUARDS.filter(isSecurityGuard),
101
114
  buildSharedProject(GUARDS.filter(isSecurityGuard)),
102
115
  );
103
- process.exit(0);
116
+ return 0;
104
117
  }
105
- const strictSecurityBaseline = process.argv.includes("--strict-security-baseline");
118
+ const strictSecurityBaseline = argv.includes("--strict-security-baseline");
106
119
  const guards = strictSecurityBaseline ? GUARDS.filter(isSecurityGuard) : GUARDS;
107
120
  const project = buildSharedProject(guards);
108
121
  printGuardKitBanner(guards.length, project);
109
- const failed = reportResults(runGuards(guards, project, { strictSecurityBaseline }));
110
- process.exit(failed > 0 ? 1 : 0);
122
+ return reportResults(runGuards(guards, project, { strictSecurityBaseline }));
123
+ }
124
+
125
+ // Only run on direct invocation — otherwise `import { GUARDS }` would kick
126
+ // off the whole suite and the list wouldn't be testable.
127
+ if (import.meta.main) {
128
+ process.exit(runGuardsCli(process.argv.slice(2)));
111
129
  }
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bun
2
- import { printGuardKitBanner, reportResults, runRepoChecks } from "./_lib/guard-kit";
2
+ import { cliFlagsError, printGuardKitBanner, reportResults, runRepoChecks } from "./_lib/guard-kit";
3
3
  // Standalone-`main()` guards ported as RepoCheck — run in-process, no
4
4
  // per-guard subprocess/project.
5
+ import { check as runtimeIsolation } from "./check-runtime-isolation";
5
6
  import { check as secretLiterals } from "./check-secret-literals";
6
7
  import { check as featureIntegrationTests } from "./guard-feature-integration-tests";
7
8
  import { check as noDirectProcessEnv } from "./guard-no-direct-process-env";
@@ -10,6 +11,7 @@ import { check as rawSql } from "./guard-raw-sql";
10
11
  import { check as rendererBoundaries } from "./guard-renderer-boundaries";
11
12
  import { check as testStackDrift } from "./guard-test-stack-drift";
12
13
  import { check as thinWrappers } from "./guard-thin-wrappers";
14
+ import { check as upgradeState } from "./guard-upgrade-state";
13
15
 
14
16
  export const REPO_CHECKS = [
15
17
  rawSql,
@@ -20,12 +22,28 @@ export const REPO_CHECKS = [
20
22
  secretLiterals,
21
23
  featureIntegrationTests,
22
24
  testStackDrift,
25
+ runtimeIsolation,
26
+ upgradeState,
23
27
  ];
24
28
 
25
- if (import.meta.main) {
29
+ // No flags today — the array stays so an unknown flag still fails loud
30
+ // instead of silently doing nothing, and so a future flag has one place to land.
31
+ export const REPO_CHECK_FLAGS: readonly string[] = [];
32
+
33
+ // Shared by the direct `bun run-repo-checks.ts` invocation below and by the
34
+ // `checks` subcommand in cli.ts.
35
+ export async function runRepoChecksCli(argv: readonly string[]): Promise<number> {
36
+ const flagsError = cliFlagsError("checks", argv, REPO_CHECK_FLAGS);
37
+ if (flagsError !== undefined) {
38
+ console.error(flagsError);
39
+ return 1;
40
+ }
26
41
  // No shared ts-morph Project here — RepoCheck.run() does its own file
27
42
  // walk per check, so the banner omits the "Project: N files" line.
28
43
  printGuardKitBanner(REPO_CHECKS.length);
29
- const failed = reportResults(await runRepoChecks(REPO_CHECKS));
30
- process.exit(failed > 0 ? 1 : 0);
44
+ return reportResults(await runRepoChecks(REPO_CHECKS));
45
+ }
46
+
47
+ if (import.meta.main) {
48
+ process.exit(await runRepoChecksCli(process.argv.slice(2)));
31
49
  }
@@ -3,6 +3,7 @@
3
3
  // Project over the UI enforcement guards.
4
4
  import {
5
5
  buildSharedProject,
6
+ cliFlagsError,
6
7
  printGuardKitBanner,
7
8
  reportResults,
8
9
  runGuards,
@@ -25,10 +26,24 @@ export const UI_GUARDS = [
25
26
  i18nUiStrings,
26
27
  ];
27
28
 
28
- // Same as run-guards.ts: only run on direct invocation.
29
- if (import.meta.main) {
29
+ // No flags today the array stays so an unknown flag still fails loud
30
+ // instead of silently doing nothing, and so a future flag has one place to land.
31
+ export const UI_GUARD_FLAGS: readonly string[] = [];
32
+
33
+ // Shared by the direct `bun run-ui-guards.ts` invocation below and by the
34
+ // `ui` subcommand in cli.ts.
35
+ export function runUiGuardsCli(argv: readonly string[]): number {
36
+ const flagsError = cliFlagsError("ui", argv, UI_GUARD_FLAGS);
37
+ if (flagsError !== undefined) {
38
+ console.error(flagsError);
39
+ return 1;
40
+ }
30
41
  const project = buildSharedProject(UI_GUARDS);
31
42
  printGuardKitBanner(UI_GUARDS.length, project);
32
- const failed = reportResults(runGuards(UI_GUARDS, project));
33
- process.exit(failed > 0 ? 1 : 0);
43
+ return reportResults(runGuards(UI_GUARDS, project));
44
+ }
45
+
46
+ // Same as run-guards.ts: only run on direct invocation.
47
+ if (import.meta.main) {
48
+ process.exit(runUiGuardsCli(process.argv.slice(2)));
34
49
  }
@@ -0,0 +1,337 @@
1
+ // Pure classification helpers for the runtime-isolation guard.
2
+ //
3
+ // Extracted from `check-runtime-isolation.ts` so the regex/path logic
4
+ // can be unit-tested in isolation. The orchestration (ts-morph,
5
+ // process.exit, file walks) stays in the script.
6
+ //
7
+ // Why: the path-pattern table has historically had quiet bugs
8
+ // (e.g. `\/scripts\/` did not match `scripts/foo.ts` at repo-root,
9
+ // silently misclassifying tooling files as `runtime`). The unit-tests
10
+ // in `__tests__/runtime-isolation-classify.test.ts` lock the
11
+ // classification rules down so future edits trip a test, not a
12
+ // production drift.
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import * as path from "node:path";
16
+ import type { ImportDeclaration, SourceFile } from "ts-morph";
17
+
18
+ export type Runtime = "runtime" | "client" | "dev" | "tooling" | "test";
19
+
20
+ export const ALL_RUNTIMES: ReadonlySet<string> = new Set([
21
+ "runtime",
22
+ "client",
23
+ "dev",
24
+ "tooling",
25
+ "test",
26
+ ]);
27
+
28
+ export const COMPAT: Record<Runtime, ReadonlySet<Runtime>> = {
29
+ runtime: new Set(["runtime", "client"]),
30
+ client: new Set(["client"]),
31
+ dev: new Set(["runtime", "client", "dev", "tooling"]),
32
+ tooling: new Set(["runtime", "client", "dev", "tooling", "test"]),
33
+ test: new Set(["runtime", "client", "dev", "tooling", "test"]),
34
+ };
35
+
36
+ /**
37
+ * Classify a file by its path relative to the repo root. Returns null
38
+ * if no path-pattern matched (caller falls back to workspace / default).
39
+ *
40
+ * Pure function — accepts a repo-relative path string, no I/O.
41
+ */
42
+ export function classifyByPath(repoRelativePath: string): Runtime | null {
43
+ const rel = repoRelativePath.replace(/\\/g, "/");
44
+ if (/\/(__tests__|testing)\//.test(rel)) return "test";
45
+ if (/\/testing\.tsx?$/.test(rel)) return "test";
46
+ if (/\.(test|integration|e2e)\.[tj]sx?$/.test(rel)) return "test";
47
+ if (/(?:^|\/)scripts\//.test(rel)) return "tooling";
48
+ if (/(?:^|\/)bin\//.test(rel)) return "tooling";
49
+ if (/\/drizzle\/[^/]+\.ts$/.test(rel)) return "tooling";
50
+ if (/\/drizzle\.config\.[tj]s$/.test(rel)) return "tooling";
51
+
52
+ // Shared UI types are used by both client and runtime. In the kumiko isolation
53
+ // model, "client" is the most permissive production category that "runtime"
54
+ // can also import.
55
+ if (/(?:^|\/)ui-types\//.test(rel)) return "client";
56
+
57
+ // Same reasoning, two more shapes: a `web.ts`/`web/` subpath is the
58
+ // established convention (locale-de, locale-es, bundled-features) for the
59
+ // client-safe slice of an otherwise `"runtime"`-marked package — the
60
+ // package.json marker classifies the whole package, this carves the
61
+ // deliberately-named exception back out. Deliberately workspace-wide (any
62
+ // repo, any depth), not framework-only: app repos' own `src/features/*/web/`
63
+ // dirs follow the identical convention. `time`/`utils`/`engine/types`/`errors`
64
+ // are framework's other isomorphic exports (published as their own subpath
65
+ // exports, empirically zero Node-only or cross-module value imports, same
66
+ // shape as `ui-types`). Scoped to `packages/framework/src/` specifically —
67
+ // `utils`/`errors` are common enough directory names elsewhere that a
68
+ // repo-wide match risks misclassifying an unrelated server-only folder in
69
+ // some other package.
70
+ if (/(?:^|\/)web\//.test(rel) || /\/web\.tsx?$/.test(rel)) return "client";
71
+ if (/^packages\/framework\/src\/(?:time|utils|engine\/types|errors)\//.test(rel)) return "client";
72
+
73
+ // More single-file carve-outs, same "package marker is coarser than the
74
+ // file" shape, verified case by case rather than by a directory
75
+ // convention:
76
+ // - locale-{de,es}/src/strings.ts: pure string-constant data (zero
77
+ // imports), re-exported by the already-client `web.ts` sibling in the
78
+ // same package but shadowed by the package's own `"runtime"` marker.
79
+ if (/^packages\/locale-(?:de|es)\/src\/strings\.ts$/.test(rel)) return "client";
80
+ // - dev-server/src/env-schema.ts: a zod-only schema, no dev-server-
81
+ // internal imports — safe to carve out on its own.
82
+ if (/^packages\/dev-server\/src\/env-schema\.ts$/.test(rel)) return "client";
83
+
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * App-repo browser bundle entry, mirroring kumiko-build's own discovery
89
+ * (`discoverClientEntries` in kumiko-framework's
90
+ * `packages/server-runtime/src/build-prod-bundle.ts`): `src/client.tsx`/
91
+ * `src/client.ts` (single-entry) or `src/client-<suffix>.tsx?` (multi-entry).
92
+ * Framework/enterprise packages never match — their sources live under
93
+ * `packages/*\/src/`, not a repo-root `src/`. Kept independent of the
94
+ * framework's own regex (cross-package import would be a build-vs-lint
95
+ * layering violation) — if kumiko-build's discovery pattern changes, this
96
+ * drifts and needs a matching update.
97
+ */
98
+ export function isClientEntryPath(repoRelativePath: string): boolean {
99
+ const rel = repoRelativePath.replace(/\\/g, "/");
100
+ return /^src\/client(-[a-z][a-z0-9-]*)?\.tsx?$/.test(rel);
101
+ }
102
+
103
+ /**
104
+ * An import declaration that survives `verbatimModuleSyntax` stripping and
105
+ * therefore carries real runtime weight. Shared by the direct-edge check and
106
+ * the client-reachability walk so both agree on what counts as an edge.
107
+ */
108
+ export function isValueImport(decl: ImportDeclaration): boolean {
109
+ if (decl.isTypeOnly()) return false;
110
+ const named = decl.getNamedImports();
111
+ if (
112
+ named.length > 0 &&
113
+ named.every((n) => n.isTypeOnly()) &&
114
+ !decl.getDefaultImport() &&
115
+ !decl.getNamespaceImport()
116
+ ) {
117
+ return false;
118
+ }
119
+ return true;
120
+ }
121
+
122
+ /**
123
+ * Map dist declaration files back to src. Project References make ts-morph
124
+ * resolve imports to `.d.ts` under `dist/`; classification and the reachable
125
+ * set must use the same path.
126
+ */
127
+ export function toEffectivePath(filePath: string): string {
128
+ if (filePath.endsWith(".d.ts") && filePath.includes("/dist/")) {
129
+ const base = filePath.replace("/dist/", "/src/").replace(/\.d\.ts$/, "");
130
+ if (existsSync(`${base}.ts`)) return `${base}.ts`;
131
+ if (existsSync(`${base}.tsx`)) return `${base}.tsx`;
132
+ }
133
+ return filePath;
134
+ }
135
+
136
+ /**
137
+ * BFS over value-import edges starting at every file `isEntry` accepts.
138
+ * Files reached this way are part of a browser bundle even when nothing in
139
+ * their own path/directive/workspace marks them "client" — a plain helper
140
+ * file, several hops from `src/client-*.tsx`, value-importing a server
141
+ * subpath is exactly the drift this catches. Crossing into node_modules is
142
+ * fine (ts-morph resolves workspace symlinks to the real package file);
143
+ * only literal `node_modules/`/`dist/` targets are excluded, matching the
144
+ * direct-edge check.
145
+ */
146
+ export function computeClientReachablePaths(
147
+ sourceFiles: readonly SourceFile[],
148
+ isEntry: (sourceFile: SourceFile) => boolean,
149
+ ): ReadonlySet<string> {
150
+ const reached = new Set<string>();
151
+ const queue: SourceFile[] = sourceFiles.filter(isEntry);
152
+ while (queue.length > 0) {
153
+ const sf = queue.shift();
154
+ if (!sf) break;
155
+ const fp = toEffectivePath(sf.getFilePath());
156
+ if (fp.includes("/node_modules/") || fp.includes("/dist/")) continue;
157
+ if (reached.has(fp)) continue;
158
+ reached.add(fp);
159
+ for (const decl of sf.getImportDeclarations()) {
160
+ if (!isValueImport(decl)) continue;
161
+ const target = decl.getModuleSpecifierSourceFile();
162
+ if (!target) continue;
163
+ const targetPath = toEffectivePath(target.getFilePath());
164
+ if (targetPath.includes("/node_modules/") || targetPath.includes("/dist/")) continue;
165
+ if (!reached.has(targetPath)) queue.push(target);
166
+ }
167
+ }
168
+ return reached;
169
+ }
170
+
171
+ /**
172
+ * Classify a file by its top-of-file `// @runtime <kind>` directive.
173
+ * Reads the first 600 bytes only (cap blast radius on huge files).
174
+ */
175
+ export function classifyByDirective(filePath: string): Runtime | null {
176
+ let head: string;
177
+ try {
178
+ head = readFileSync(filePath, "utf8").slice(0, 600);
179
+ } catch {
180
+ return null;
181
+ }
182
+ for (const line of head.split("\n").slice(0, 8)) {
183
+ const m = line.match(/\/\/\s*@runtime\s+(\w+)/);
184
+ if (m && ALL_RUNTIMES.has(m[1] ?? "")) return m[1] as Runtime;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ /**
190
+ * Walk upward from `filePath` looking for the nearest package.json that
191
+ * carries a `kumiko.runtime` marker. Stops at `repoRoot`. Caches per
192
+ * directory in the supplied map so a long scan only reads each
193
+ * package.json once.
194
+ */
195
+ export function findWorkspaceRuntime(
196
+ filePath: string,
197
+ repoRoot: string,
198
+ cache: Map<string, Runtime | null>,
199
+ ): Runtime | null {
200
+ let dir = path.dirname(filePath);
201
+ while (dir.startsWith(repoRoot) && dir !== repoRoot) {
202
+ const r = readWorkspaceRuntime(dir, cache);
203
+ if (r) return r;
204
+ // Stop at the first package.json — don't fall through to a parent
205
+ // workspace that happens to have a marker.
206
+ try {
207
+ readFileSync(path.join(dir, "package.json"), "utf8");
208
+ return null;
209
+ } catch {
210
+ // No package.json here — keep climbing.
211
+ }
212
+ dir = path.dirname(dir);
213
+ }
214
+ return null;
215
+ }
216
+
217
+ function readWorkspaceRuntime(dir: string, cache: Map<string, Runtime | null>): Runtime | null {
218
+ const cached = cache.get(dir);
219
+ if (cached !== undefined) return cached;
220
+ const pkgPath = path.join(dir, "package.json");
221
+ let result: Runtime | null = null;
222
+ try {
223
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
224
+ const r = pkg.kumiko?.runtime;
225
+ if (typeof r === "string" && ALL_RUNTIMES.has(r)) result = r as Runtime;
226
+ } catch {
227
+ // package.json missing or unreadable — unmarked
228
+ }
229
+ cache.set(dir, result);
230
+ return result;
231
+ }
232
+
233
+ /**
234
+ * Compose the classification layers (directive > path > workspace >
235
+ * client-reachability > default) into one call. The cache is owned by the
236
+ * caller so a single scan amortizes the workspace-lookup across files.
237
+ *
238
+ * `clientReachable` only ever promotes the *default* — a file with an
239
+ * explicit directive, a matched path pattern, or a workspace marker keeps
240
+ * that classification regardless of reachability. This is what lets a
241
+ * framework file explicitly marked `"runtime"` (e.g. `engine/index.ts`) stay
242
+ * "runtime" even when a client bundle reaches it — which is exactly the
243
+ * violation this guard needs to see, not paper over.
244
+ */
245
+ export function classify(
246
+ filePath: string,
247
+ repoRoot: string,
248
+ workspaceCache: Map<string, Runtime | null>,
249
+ clientReachable?: ReadonlySet<string>,
250
+ ): Runtime {
251
+ const effectivePath = toEffectivePath(filePath);
252
+
253
+ const rel = path.relative(repoRoot, effectivePath);
254
+ return (
255
+ classifyByDirective(effectivePath) ??
256
+ classifyByPath(rel) ??
257
+ findWorkspaceRuntime(effectivePath, repoRoot, workspaceCache) ??
258
+ (clientReachable?.has(effectivePath) ? "client" : undefined) ??
259
+ "runtime"
260
+ );
261
+ }
262
+
263
+ export type Violation = {
264
+ readonly file: string;
265
+ readonly line: number;
266
+ readonly fileRuntime: Runtime;
267
+ readonly importedSpec: string;
268
+ readonly importedFile: string;
269
+ readonly importedRuntime: Runtime;
270
+ };
271
+
272
+ /**
273
+ * The direct-edge half of the guard: classify every scanned file, then flag
274
+ * every value-import whose target runtime the source runtime isn't allowed
275
+ * to depend on (`COMPAT`). `clientReachable` (from `computeClientReachablePaths`)
276
+ * is threaded through so a file pulled into a browser bundle gets judged as
277
+ * "client" even without its own directive/path/workspace marker.
278
+ */
279
+ export function findRuntimeIsolationViolations(
280
+ sourceFiles: readonly SourceFile[],
281
+ repoRoot: string,
282
+ workspaceCache: Map<string, Runtime | null>,
283
+ clientReachable: ReadonlySet<string> = new Set(),
284
+ ): {
285
+ readonly violations: readonly Violation[];
286
+ readonly stats: Record<Runtime, number>;
287
+ /** Import targets (or scanned files) that resolved outside the repo root. */
288
+ readonly outsideRoot: readonly string[];
289
+ } {
290
+ const violations: Violation[] = [];
291
+ const outsideRoot: string[] = [];
292
+ const seenOutside = new Set<string>();
293
+ const noteOutside = (fp: string) => {
294
+ if (seenOutside.has(fp)) return;
295
+ seenOutside.add(fp);
296
+ outsideRoot.push(fp);
297
+ };
298
+ const stats: Record<Runtime, number> = { runtime: 0, client: 0, dev: 0, tooling: 0, test: 0 };
299
+ const withinRoot = (fp: string) => fp === repoRoot || fp.startsWith(`${repoRoot}/`);
300
+
301
+ for (const sf of sourceFiles) {
302
+ const fp = sf.getFilePath();
303
+ if (fp.includes("/node_modules/") || fp.includes("/dist/")) continue;
304
+ if (!withinRoot(fp)) {
305
+ noteOutside(fp);
306
+ continue;
307
+ }
308
+ const fileRt = classify(fp, repoRoot, workspaceCache, clientReachable);
309
+ stats[fileRt]++;
310
+
311
+ for (const decl of sf.getImportDeclarations()) {
312
+ if (!isValueImport(decl)) continue;
313
+ const target = decl.getModuleSpecifierSourceFile();
314
+ if (!target) continue;
315
+ const targetPath = target.getFilePath();
316
+ if (targetPath.includes("/node_modules/")) continue;
317
+ if (!withinRoot(targetPath)) {
318
+ noteOutside(targetPath);
319
+ continue;
320
+ }
321
+ const targetRt = classify(targetPath, repoRoot, workspaceCache, clientReachable);
322
+
323
+ if (!COMPAT[fileRt].has(targetRt)) {
324
+ violations.push({
325
+ file: fp,
326
+ line: decl.getStartLineNumber(),
327
+ fileRuntime: fileRt,
328
+ importedSpec: decl.getModuleSpecifierValue(),
329
+ importedFile: targetPath,
330
+ importedRuntime: targetRt,
331
+ });
332
+ }
333
+ }
334
+ }
335
+
336
+ return { violations, stats, outsideRoot };
337
+ }