@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 +2 -2
- package/src/_lib/guard-kit.ts +58 -0
- package/src/changes.json +17 -0
- package/src/check-runtime-isolation.ts +214 -0
- package/src/cli.ts +55 -31
- package/src/guard-upgrade-state.ts +212 -0
- package/src/index.ts +3 -0
- package/src/run-guards.ts +28 -10
- package/src/run-repo-checks.ts +22 -4
- package/src/run-ui-guards.ts +19 -4
- package/src/runtime-isolation-classify.ts +337 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "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.
|
|
30
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.282.0",
|
|
31
31
|
"ts-morph": "^28.0.0"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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")
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
109
|
+
return 0;
|
|
97
110
|
}
|
|
98
|
-
if (
|
|
111
|
+
if (argv.includes("--write-security-baseline")) {
|
|
99
112
|
writeSecurityBaselines(
|
|
100
113
|
GUARDS.filter(isSecurityGuard),
|
|
101
114
|
buildSharedProject(GUARDS.filter(isSecurityGuard)),
|
|
102
115
|
);
|
|
103
|
-
|
|
116
|
+
return 0;
|
|
104
117
|
}
|
|
105
|
-
const strictSecurityBaseline =
|
|
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
|
-
|
|
110
|
-
|
|
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
|
}
|
package/src/run-repo-checks.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
}
|
package/src/run-ui-guards.ts
CHANGED
|
@@ -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
|
-
//
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
+
}
|