@cosmicdrift/kumiko-guards 0.334.0 → 0.335.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.
@@ -11,7 +11,7 @@
11
11
  // classification rules down so future edits trip a test, not a
12
12
  // production drift.
13
13
 
14
- import { existsSync, readFileSync } from "node:fs";
14
+ import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
15
15
  import * as path from "node:path";
16
16
  import type { ImportDeclaration, SourceFile } from "ts-morph";
17
17
 
@@ -96,11 +96,54 @@ export function classifyByPath(repoRelativePath: string): Runtime | null {
96
96
  * their sources live under `packages/*\/src/`, not a repo-root `src/`. Kept
97
97
  * independent of the framework's own regex (cross-package import would be a
98
98
  * build-vs-lint layering violation) — if apps stop following this naming
99
- * convention, this drifts and needs a matching update.
99
+ * convention, this drifts and needs a matching update. Entries the app
100
+ * declares itself (`readDeclaredClientEntries`) count in addition, so a
101
+ * `./src/admin/index.tsx` entry is classified as browser code too.
100
102
  */
101
- export function isClientEntryPath(repoRelativePath: string): boolean {
103
+ export function isClientEntryPath(
104
+ repoRelativePath: string,
105
+ declaredEntries: ReadonlySet<string> = new Set(),
106
+ ): boolean {
102
107
  const rel = repoRelativePath.replace(/\\/g, "/");
103
- return /^src\/client(-[a-z][a-z0-9-]*)?\.tsx?$/.test(rel);
108
+ return declaredEntries.has(rel) || /^src\/client(-[a-z][a-z0-9-]*)?\.tsx?$/.test(rel);
109
+ }
110
+
111
+ function isRecord(value: unknown): value is Record<string, unknown> {
112
+ return typeof value === "object" && value !== null && !Array.isArray(value);
113
+ }
114
+
115
+ /**
116
+ * Parses `kumiko.clientEntry` / `kumiko.clientEntries[].sourceFile` out of a
117
+ * package.json value into repo-relative paths. Parsed locally instead of
118
+ * importing the build's resolver (layering: lint must not depend on
119
+ * server-runtime); anything malformed is ignored — `kumiko build` owns
120
+ * rejecting it.
121
+ */
122
+ export function parseDeclaredClientEntries(packageJson: unknown): ReadonlySet<string> {
123
+ const entries = new Set<string>();
124
+ const kumiko = isRecord(packageJson) ? packageJson["kumiko"] : undefined;
125
+ if (!isRecord(kumiko)) return entries;
126
+ const sources: unknown[] = [kumiko["clientEntry"]];
127
+ const multi = kumiko["clientEntries"];
128
+ if (Array.isArray(multi)) {
129
+ for (const entry of multi) sources.push(isRecord(entry) ? entry["sourceFile"] : undefined);
130
+ }
131
+ for (const source of sources) {
132
+ if (typeof source === "string") {
133
+ entries.add(path.posix.normalize(source.replace(/\\/g, "/")));
134
+ }
135
+ }
136
+ return entries;
137
+ }
138
+
139
+ export function readDeclaredClientEntries(repoAbsPath: string): ReadonlySet<string> {
140
+ const file = path.join(repoAbsPath, "package.json");
141
+ if (!existsSync(file)) return new Set();
142
+ try {
143
+ return parseDeclaredClientEntries(JSON.parse(readFileSync(file, "utf8")));
144
+ } catch {
145
+ return new Set();
146
+ }
104
147
  }
105
148
 
106
149
  /**
@@ -122,6 +165,42 @@ export function isValueImport(decl: ImportDeclaration): boolean {
122
165
  return true;
123
166
  }
124
167
 
168
+ export type ValueModuleEdge = {
169
+ readonly spec: string;
170
+ readonly target: SourceFile;
171
+ readonly line: number;
172
+ };
173
+
174
+ /**
175
+ * Every runtime-weighted edge out of a file: value imports plus value
176
+ * re-exports (`export { x } from`, `export * from`) — barrels are how most
177
+ * client entries reach runtime code. Shared by the direct-edge check and the
178
+ * reachability walk so both see the same edge set.
179
+ */
180
+ export function valueModuleEdges(sf: SourceFile): ValueModuleEdge[] {
181
+ const edges: ValueModuleEdge[] = [];
182
+ for (const decl of sf.getImportDeclarations()) {
183
+ if (!isValueImport(decl)) continue;
184
+ const target = decl.getModuleSpecifierSourceFile();
185
+ if (!target) continue;
186
+ edges.push({
187
+ spec: decl.getModuleSpecifierValue(),
188
+ target,
189
+ line: decl.getStartLineNumber(),
190
+ });
191
+ }
192
+ for (const decl of sf.getExportDeclarations()) {
193
+ if (decl.isTypeOnly()) continue;
194
+ const named = decl.getNamedExports();
195
+ if (named.length > 0 && named.every((n) => n.isTypeOnly())) continue;
196
+ const target = decl.getModuleSpecifierSourceFile();
197
+ const spec = decl.getModuleSpecifierValue();
198
+ if (!target || spec === undefined) continue;
199
+ edges.push({ spec, target, line: decl.getStartLineNumber() });
200
+ }
201
+ return edges;
202
+ }
203
+
125
204
  /**
126
205
  * Map dist declaration files back to src. Project References make ts-morph
127
206
  * resolve imports to `.d.ts` under `dist/`; classification and the reachable
@@ -159,10 +238,7 @@ export function computeClientReachablePaths(
159
238
  if (fp.includes("/node_modules/") || fp.includes("/dist/")) continue;
160
239
  if (reached.has(fp)) continue;
161
240
  reached.add(fp);
162
- for (const decl of sf.getImportDeclarations()) {
163
- if (!isValueImport(decl)) continue;
164
- const target = decl.getModuleSpecifierSourceFile();
165
- if (!target) continue;
241
+ for (const { target } of valueModuleEdges(sf)) {
166
242
  const targetPath = toEffectivePath(target.getFilePath());
167
243
  if (targetPath.includes("/node_modules/") || targetPath.includes("/dist/")) continue;
168
244
  if (!reached.has(targetPath)) queue.push(target);
@@ -171,24 +247,69 @@ export function computeClientReachablePaths(
171
247
  return reached;
172
248
  }
173
249
 
174
- /**
175
- * Classify a file by its top-of-file `// @runtime <kind>` directive.
176
- * Reads the first 600 bytes only (cap blast radius on huge files).
177
- */
178
- export function classifyByDirective(filePath: string): Runtime | null {
179
- let head: string;
250
+ const DIRECTIVE_HEAD_BYTES = 600;
251
+ const DIRECTIVE_HEAD_LINES = 8;
252
+
253
+ export type HeadReader = (filePath: string) => string | null;
254
+
255
+ // Reads only the file head, so classifying a large source file does not pull
256
+ // it fully into memory.
257
+ export function readFileHead(filePath: string): string | null {
258
+ let fd: number;
259
+ try {
260
+ fd = openSync(filePath, "r");
261
+ } catch {
262
+ return null;
263
+ }
180
264
  try {
181
- head = readFileSync(filePath, "utf8").slice(0, 600);
265
+ const buffer = Buffer.alloc(DIRECTIVE_HEAD_BYTES);
266
+ const bytesRead = readSync(fd, buffer, 0, DIRECTIVE_HEAD_BYTES, 0);
267
+ return buffer.toString("utf8", 0, bytesRead);
182
268
  } catch {
183
269
  return null;
270
+ } finally {
271
+ closeSync(fd);
184
272
  }
185
- for (const line of head.split("\n").slice(0, 8)) {
273
+ }
274
+
275
+ /**
276
+ * Classify a file by its top-of-file `// @runtime <kind>` directive.
277
+ * Reads the first 600 bytes only (cap blast radius on huge files).
278
+ */
279
+ export function classifyByDirective(
280
+ filePath: string,
281
+ readHead: HeadReader = readFileHead,
282
+ ): Runtime | null {
283
+ const head = readHead(filePath);
284
+ if (head === null) return null;
285
+ for (const line of head.split("\n").slice(0, DIRECTIVE_HEAD_LINES)) {
186
286
  const m = line.match(/\/\/\s*@runtime\s+(\w+)/);
187
287
  if (m && ALL_RUNTIMES.has(m[1] ?? "")) return m[1] as Runtime;
188
288
  }
189
289
  return null;
190
290
  }
191
291
 
292
+ export type DirectiveClassifier = (filePath: string) => Runtime | null;
293
+
294
+ /**
295
+ * Per-run memo of `classifyByDirective`: the guard classifies each file in
296
+ * several passes (client reachability, then violations), and each pass would
297
+ * otherwise re-read the same head. Created per run, not module-global, so
298
+ * tests stay isolated.
299
+ */
300
+ export function createDirectiveClassifier(
301
+ readHead: HeadReader = readFileHead,
302
+ ): DirectiveClassifier {
303
+ const cache = new Map<string, Runtime | null>();
304
+ return (filePath) => {
305
+ const cached = cache.get(filePath);
306
+ if (cached !== undefined) return cached;
307
+ const result = classifyByDirective(filePath, readHead);
308
+ cache.set(filePath, result);
309
+ return result;
310
+ };
311
+ }
312
+
192
313
  /**
193
314
  * Walk upward from `filePath` looking for the nearest package.json that
194
315
  * carries a `kumiko.runtime` marker. Stops at `repoRoot`. Caches per
@@ -201,7 +322,8 @@ export function findWorkspaceRuntime(
201
322
  cache: Map<string, Runtime | null>,
202
323
  ): Runtime | null {
203
324
  let dir = path.dirname(filePath);
204
- while (dir.startsWith(repoRoot) && dir !== repoRoot) {
325
+ // Includes repoRoot itself: a single-package consumer repo keeps its marker there.
326
+ while (dir.startsWith(repoRoot)) {
205
327
  const r = readWorkspaceRuntime(dir, cache);
206
328
  if (r) return r;
207
329
  // Stop at the first package.json — don't fall through to a parent
@@ -212,6 +334,7 @@ export function findWorkspaceRuntime(
212
334
  } catch {
213
335
  // No package.json here — keep climbing.
214
336
  }
337
+ if (dir === repoRoot) break;
215
338
  dir = path.dirname(dir);
216
339
  }
217
340
  return null;
@@ -250,12 +373,13 @@ export function classify(
250
373
  repoRoot: string,
251
374
  workspaceCache: Map<string, Runtime | null>,
252
375
  clientReachable?: ReadonlySet<string>,
376
+ directiveOf: DirectiveClassifier = (file) => classifyByDirective(file),
253
377
  ): Runtime {
254
378
  const effectivePath = toEffectivePath(filePath);
255
379
 
256
380
  const rel = path.relative(repoRoot, effectivePath);
257
381
  return (
258
- classifyByDirective(effectivePath) ??
382
+ directiveOf(effectivePath) ??
259
383
  classifyByPath(rel) ??
260
384
  findWorkspaceRuntime(effectivePath, repoRoot, workspaceCache) ??
261
385
  (clientReachable?.has(effectivePath) ? "client" : undefined) ??
@@ -284,6 +408,7 @@ export function findRuntimeIsolationViolations(
284
408
  repoRoot: string,
285
409
  workspaceCache: Map<string, Runtime | null>,
286
410
  clientReachable: ReadonlySet<string> = new Set(),
411
+ directiveOf: DirectiveClassifier = createDirectiveClassifier(),
287
412
  ): {
288
413
  readonly violations: readonly Violation[];
289
414
  readonly stats: Record<Runtime, number>;
@@ -315,13 +440,10 @@ export function findRuntimeIsolationViolations(
315
440
  noteOutside(fp);
316
441
  continue;
317
442
  }
318
- const fileRt = classify(fp, repoRoot, workspaceCache, clientReachable);
443
+ const fileRt = classify(fp, repoRoot, workspaceCache, clientReachable, directiveOf);
319
444
  stats[fileRt]++;
320
445
 
321
- for (const decl of sf.getImportDeclarations()) {
322
- if (!isValueImport(decl)) continue;
323
- const target = decl.getModuleSpecifierSourceFile();
324
- if (!target) continue;
446
+ for (const { spec, target, line } of valueModuleEdges(sf)) {
325
447
  const targetPath = target.getFilePath();
326
448
  if (targetPath.includes("/node_modules/")) continue;
327
449
  if (!withinRoot(targetPath)) {
@@ -333,9 +455,9 @@ export function findRuntimeIsolationViolations(
333
455
  if (!COMPAT[fileRt].has(targetRt)) {
334
456
  violations.push({
335
457
  file: fp,
336
- line: decl.getStartLineNumber(),
458
+ line,
337
459
  fileRuntime: fileRt,
338
- importedSpec: decl.getModuleSpecifierValue(),
460
+ importedSpec: spec,
339
461
  importedFile: targetPath,
340
462
  importedRuntime: targetRt,
341
463
  });