@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.
@@ -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
+ }