@cosmicdrift/kumiko-guards 0.3.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-error-reasons.ts +13 -5
- package/src/guard-escape-hatch-declared.ts +134 -21
- 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
|
@@ -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
|
+
}
|