@cosmicdrift/kumiko-guards 0.377.0 → 0.379.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.
@@ -18,6 +18,11 @@
18
18
  * (dash vs. underscore) but share the `ent` symbol. Plus an explicit
19
19
  * `backingTable` override. Tables that are NOT statically resolvable
20
20
  * are skipped (a miss is preferable to a false block).
21
+ * c) Tables of external packages (node_modules, workspace links), read from the
22
+ * `kumiko-es-tables.json` catalog each published package ships; a package whose
23
+ * sources live in the checked repo must keep that catalog current (checked here,
24
+ * rewritten with `--write-baseline --guard=<name>`).
25
+ * d) `r.projection({ table })` tables: event-fed read models, only the apply may write.
21
26
  *
22
27
  * 2. **Find direct writes.** Two forms:
23
28
  * - Method form `<receiver>.insert(<tableIdent>)` / `.update` / `.delete`.
@@ -46,13 +51,21 @@
46
51
  import * as path from "node:path";
47
52
  import {
48
53
  type CallExpression,
54
+ type FileSystemHost,
49
55
  type Identifier,
50
56
  Node,
51
57
  type ObjectLiteralExpression,
58
+ type Project,
52
59
  type SourceFile,
53
60
  SyntaxKind,
54
61
  } from "ts-morph";
55
- import { ALL_REPO_KINDS, type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
62
+ import {
63
+ ALL_REPO_KINDS,
64
+ type AstGuard,
65
+ isExternalSourcePath,
66
+ runStandalone,
67
+ type ScanSpec,
68
+ } from "./_lib/guard-kit";
56
69
 
57
70
  const ROOT = process.cwd();
58
71
 
@@ -95,46 +108,6 @@ const FN_WRITE_HELPERS = new Map<string, "insert" | "update" | "delete">([
95
108
  ["deleteManyBatched", "delete"],
96
109
  ]);
97
110
 
98
- // Writes that are deliberately outside the event-store executor. Match on file + enclosing function + table so the exception cannot widen silently.
99
- type DirectWriteException = {
100
- readonly file: RegExp;
101
- readonly functionName: string;
102
- readonly table: string;
103
- readonly justification: string;
104
- };
105
-
106
- const DIRECT_WRITE_EXCEPTIONS: readonly DirectWriteException[] = [
107
- {
108
- file: /(^|\/)packages\/bundled-features\/src\/template-resolver\/seeding\.ts$/,
109
- functionName: "resolveExistingForEventStoreSeed",
110
- table: "templateResourcesTable",
111
- justification: "Orphan projection row without events, no event-store verb possible",
112
- },
113
- ];
114
-
115
- // First named container: `function f`, `const f = () =>`, or `run: () =>` in an object literal.
116
- function enclosingFunctionName(node: Node): string | undefined {
117
- for (const ancestor of node.getAncestors()) {
118
- if (Node.isFunctionDeclaration(ancestor)) return ancestor.getName();
119
- if (Node.isVariableDeclaration(ancestor) || Node.isPropertyAssignment(ancestor)) {
120
- return ancestor.getName();
121
- }
122
- }
123
- return undefined;
124
- }
125
-
126
- function isDirectWriteException(call: CallExpression, table: string): boolean {
127
- const functionName = enclosingFunctionName(call);
128
- if (functionName === undefined) return false;
129
- const file = call.getSourceFile().getFilePath();
130
- return DIRECT_WRITE_EXCEPTIONS.some(
131
- (exception) =>
132
- exception.file.test(file) &&
133
- exception.functionName === functionName &&
134
- exception.table === table,
135
- );
136
- }
137
-
138
111
  // `table as EntityTableMeta`, `(table)`, `table!`, `<T>table`, `table satisfies T` all name the same table.
139
112
  export function unwrapTableExpression(node: Node): Node {
140
113
  let current = node;
@@ -156,21 +129,26 @@ export function unwrapTableExpression(node: Node): Node {
156
129
  // declaration disambiguates them.
157
130
  type TableId = string; // "${filePath}::${name}"
158
131
 
159
- function declIdOf(id: import("ts-morph").Identifier): TableId | undefined {
132
+ function declIdOf(id: Identifier): TableId | undefined {
160
133
  const symbol = id.getSymbol();
161
134
  if (!symbol) return undefined;
162
- const decls = symbol.getDeclarations();
135
+ // An imported identifier's own declaration is the ImportSpecifier; follow the alias
136
+ // chain (barrels, renames) so the ID matches the one built at the definition.
137
+ const resolved = symbol.isAlias() ? (symbol.getAliasedSymbol() ?? symbol) : symbol;
138
+ const decls = resolved.getDeclarations();
163
139
  if (decls.length === 0) return undefined;
164
140
  // Param-only symbols are factory pass-throughs — skip.
165
141
  if (decls.every((d) => d.getKind() === SyntaxKind.Parameter)) return undefined;
166
142
  const first = decls[0];
167
143
  if (!first) return undefined;
168
- return `${first.getSourceFile().getFilePath()}::${id.getText()}`;
144
+ const name = Node.hasName(first) ? first.getName() : id.getText();
145
+ return `${first.getSourceFile().getFilePath()}::${name}`;
169
146
  }
170
147
 
171
148
  export function collectEsTables(files: readonly SourceFile[]): Set<TableId> {
172
149
  const tables = new Set<TableId>();
173
150
  for (const sf of files) {
151
+ if (!sf.getFullText().includes("createEventStoreExecutor")) continue;
174
152
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
175
153
  const expr = call.getExpression();
176
154
  if (expr.getText() !== "createEventStoreExecutor") continue;
@@ -251,6 +229,8 @@ function rebuildableEntityTableDeclId(
251
229
  return declIdOf(nameNode.asKindOrThrow(SyntaxKind.Identifier));
252
230
  }
253
231
 
232
+ const ENTITY_METHOD_ACCESS = /\.\s*entity\b/;
233
+
254
234
  // Tables registered via `r.entity({ name, ...entityDef })` as a rebuildable implicit
255
235
  // projection, linked entity→table via the shared entity-def symbol (see header), not by name string.
256
236
  export function collectEntityProjectionTables(files: readonly SourceFile[]): Set<TableId> {
@@ -259,6 +239,7 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
259
239
 
260
240
  // Pass 1: entity-def decls that r.entity makes rebuildable (+ {table} override).
261
241
  for (const sf of files) {
242
+ if (!ENTITY_METHOD_ACCESS.test(sf.getFullText())) continue;
262
243
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
263
244
  const registration = entityRegistrationOf(call);
264
245
  if (!registration) continue;
@@ -270,6 +251,7 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
270
251
  // Pass 2: `const xTable = buildEntityTable(<name>, <entityDef>)` where entityDef
271
252
  // is in the rebuildable set → xTable is a rebuildable entity table.
272
253
  for (const sf of files) {
254
+ if (!sf.getFullText().includes("buildEntityTable")) continue;
273
255
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
274
256
  const did = rebuildableEntityTableDeclId(call, rebuildableEntities);
275
257
  if (did) tables.add(did);
@@ -279,6 +261,39 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
279
261
  return tables;
280
262
  }
281
263
 
264
+ const PROJECTION_METHOD_ACCESS = /\.\s*projection\b/;
265
+
266
+ // The `table` of `r.projection({ table, apply })`: an event-fed read model, so only its apply
267
+ // (tx receiver) may write it. Shorthand `{ table }` is a name-only reference to the same symbol.
268
+ export function collectProjectionTables(files: readonly SourceFile[]): Set<TableId> {
269
+ const tables = new Set<TableId>();
270
+ for (const sf of files) {
271
+ if (!PROJECTION_METHOD_ACCESS.test(sf.getFullText())) continue;
272
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
273
+ const callee = call.getExpression().asKind(SyntaxKind.PropertyAccessExpression);
274
+ if (callee?.getName() !== "projection") continue;
275
+ const definition = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
276
+ const property = definition?.getProperty("table");
277
+ const identifier =
278
+ property?.asKind(SyntaxKind.ShorthandPropertyAssignment)?.getNameNode() ??
279
+ unwrapTableExpression(
280
+ property?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer() ?? call,
281
+ ).asKind(SyntaxKind.Identifier);
282
+ const did = identifier && declIdOf(identifier);
283
+ if (did) tables.add(did);
284
+ }
285
+ }
286
+ return tables;
287
+ }
288
+
289
+ function collectScopeEsTables(files: readonly SourceFile[]): Set<TableId> {
290
+ return new Set<TableId>([
291
+ ...collectEsTables(files),
292
+ ...collectEntityProjectionTables(files),
293
+ ...collectProjectionTables(files),
294
+ ]);
295
+ }
296
+
282
297
  type Violation = {
283
298
  file: string;
284
299
  line: number;
@@ -427,14 +442,246 @@ function hasAnyTableWriteOrDeclaration(files: readonly SourceFile[]): boolean {
427
442
  return false;
428
443
  }
429
444
 
445
+ // A TableId ends in `::<identifier text>`, so a table argument whose text is no ES table name
446
+ // can never match; filtering on the name avoids a symbol lookup (TS program build) per call.
447
+ function tableNamesOf(tableIds: ReadonlySet<TableId>): Set<string> {
448
+ return new Set([...tableIds].map((tableId) => tableId.slice(tableId.lastIndexOf("::") + 2)));
449
+ }
450
+
451
+ // Local names an import can give a table in this file (`import { a as b }` -> b). Syntactic,
452
+ // so a rename or barrel re-export never hides an ES table from the name prefilter.
453
+ function importedBindingNames(sf: SourceFile): Set<string> {
454
+ const names = new Set<string>();
455
+ for (const declaration of sf.getImportDeclarations()) {
456
+ const defaultName = declaration.getDefaultImport()?.getText();
457
+ if (defaultName) names.add(defaultName);
458
+ for (const specifier of declaration.getNamedImports()) {
459
+ names.add(specifier.getAliasNode()?.getText() ?? specifier.getName());
460
+ }
461
+ }
462
+ return names;
463
+ }
464
+
465
+ const ES_TABLE_CATALOG_FILE = "kumiko-es-tables.json";
466
+
467
+ type CatalogEntry = { readonly module: string; readonly export: string };
468
+
469
+ function tableIdFilePath(tableId: TableId): string {
470
+ return tableId.slice(0, tableId.lastIndexOf("::"));
471
+ }
472
+
473
+ function tableIdName(tableId: TableId): string {
474
+ return tableId.slice(tableId.lastIndexOf("::") + 2);
475
+ }
476
+
477
+ function packageRootOf(host: FileSystemHost, filePath: string): string | undefined {
478
+ let dir = path.dirname(filePath);
479
+ while (dir !== path.dirname(dir)) {
480
+ if (host.fileExistsSync(path.join(dir, "package.json"))) return dir;
481
+ dir = path.dirname(dir);
482
+ }
483
+ return undefined;
484
+ }
485
+
486
+ // Source (`src/x.ts`, workspace link) and published (`dist/x.d.ts`) layouts must yield one key.
487
+ function moduleOf(packageRoot: string, filePath: string): string {
488
+ return path
489
+ .relative(packageRoot, filePath)
490
+ .split(path.sep)
491
+ .join("/")
492
+ .replace(/^(src|dist)\//, "")
493
+ .replace(/\.d\.ts$|\.(ts|tsx|js)$/, "");
494
+ }
495
+
496
+ function catalogKey(module: string, exportName: string): string {
497
+ return `${module}::${exportName}`;
498
+ }
499
+
500
+ function isCatalogEntry(value: unknown): value is CatalogEntry {
501
+ return (
502
+ typeof value === "object" &&
503
+ value !== null &&
504
+ "module" in value &&
505
+ "export" in value &&
506
+ typeof value.module === "string" &&
507
+ typeof value.export === "string"
508
+ );
509
+ }
510
+
511
+ function parseCatalog(text: string): CatalogEntry[] | undefined {
512
+ try {
513
+ const parsed: unknown = JSON.parse(text);
514
+ return Array.isArray(parsed) && parsed.every(isCatalogEntry) ? parsed : undefined;
515
+ } catch {
516
+ return undefined;
517
+ }
518
+ }
519
+
520
+ function readCatalogKeys(host: FileSystemHost, packageRoot: string): Set<string> {
521
+ const catalogPath = path.join(packageRoot, ES_TABLE_CATALOG_FILE);
522
+ if (!host.fileExistsSync(catalogPath)) return new Set();
523
+ const entries = parseCatalog(host.readFileSync(catalogPath)) ?? [];
524
+ return new Set(entries.map((entry) => catalogKey(entry.module, entry.export)));
525
+ }
526
+
527
+ export type ExternalEsTables = { has(tableId: TableId): boolean };
528
+
529
+ // A published package ships `kumiko-es-tables.json` (module + export of its ES tables), because
530
+ // its sources are not on disk in a consumer (only dist/*.d.ts). Missing catalog: no ES tables.
531
+ export function createExternalEsTables(project: Project): ExternalEsTables {
532
+ const host = project.getFileSystem();
533
+ const keysByPackageRoot = new Map<string, ReadonlySet<string>>();
534
+ return {
535
+ has(tableId) {
536
+ const filePath = tableIdFilePath(tableId);
537
+ const root = packageRootOf(host, filePath);
538
+ if (root === undefined) return false;
539
+ let keys = keysByPackageRoot.get(root);
540
+ if (!keys) {
541
+ keys = readCatalogKeys(host, root);
542
+ keysByPackageRoot.set(root, keys);
543
+ }
544
+ return keys.has(catalogKey(moduleOf(root, filePath), tableIdName(tableId)));
545
+ },
546
+ };
547
+ }
548
+
549
+ function isPublishedPackage(host: FileSystemHost, packageRoot: string): boolean {
550
+ try {
551
+ const manifest: unknown = JSON.parse(host.readFileSync(path.join(packageRoot, "package.json")));
552
+ return !(
553
+ typeof manifest === "object" &&
554
+ manifest !== null &&
555
+ "private" in manifest &&
556
+ manifest.private === true
557
+ );
558
+ } catch {
559
+ return false;
560
+ }
561
+ }
562
+
563
+ // Owner side: the exported scope ES tables of every published package, grouped by package root.
564
+ function deriveCatalogs(
565
+ files: readonly SourceFile[],
566
+ esTables: ReadonlySet<TableId>,
567
+ ): Map<string, CatalogEntry[]> {
568
+ const catalogs = new Map<string, CatalogEntry[]>();
569
+ const firstFile = files[0];
570
+ if (!firstFile) return catalogs;
571
+ const project = firstFile.getProject();
572
+ const host = project.getFileSystem();
573
+ const scopePaths = new Set<string>(files.map((file) => file.getFilePath()));
574
+ const publishedByRoot = new Map<string, boolean>();
575
+ for (const tableId of esTables) {
576
+ const filePath = tableIdFilePath(tableId);
577
+ if (!scopePaths.has(filePath) || EXCLUDE.test(filePath)) continue;
578
+ const name = tableIdName(tableId);
579
+ if (!project.getSourceFile(filePath)?.getVariableDeclaration(name)?.isExported()) continue;
580
+ const root = packageRootOf(host, filePath);
581
+ if (root === undefined) continue;
582
+ let published = publishedByRoot.get(root);
583
+ if (published === undefined) {
584
+ published = isPublishedPackage(host, root);
585
+ publishedByRoot.set(root, published);
586
+ }
587
+ if (!published) continue;
588
+ const entries = catalogs.get(root) ?? [];
589
+ entries.push({ module: moduleOf(root, filePath), export: name });
590
+ catalogs.set(root, entries);
591
+ }
592
+ for (const [root, entries] of catalogs) {
593
+ entries.sort(
594
+ (a, b) => a.module.localeCompare(b.module, "en") || a.export.localeCompare(b.export, "en"),
595
+ );
596
+ catalogs.set(root, [
597
+ ...new Map(entries.map((e) => [catalogKey(e.module, e.export), e])).values(),
598
+ ]);
599
+ }
600
+ return catalogs;
601
+ }
602
+
603
+ const REWRITE_CATALOGS_COMMAND =
604
+ 'bun packages/guards/src/run-guards.ts --write-baseline --guard="Direct-Entity-Writes Guard"';
605
+
606
+ // Published packages whose catalog exists on disk although they define no ES table any more.
607
+ function publishedRootsWithCatalog(files: readonly SourceFile[]): Set<string> {
608
+ const roots = new Set<string>();
609
+ const firstFile = files[0];
610
+ if (!firstFile) return roots;
611
+ const host = firstFile.getProject().getFileSystem();
612
+ const rootByDir = new Map<string, string | undefined>();
613
+ for (const sf of files) {
614
+ const dir = path.dirname(sf.getFilePath());
615
+ if (!rootByDir.has(dir)) rootByDir.set(dir, packageRootOf(host, dir));
616
+ const root = rootByDir.get(dir);
617
+ if (root !== undefined && host.fileExistsSync(path.join(root, ES_TABLE_CATALOG_FILE))) {
618
+ roots.add(root);
619
+ }
620
+ }
621
+ return roots;
622
+ }
623
+
624
+ function catalogViolations(
625
+ files: readonly SourceFile[],
626
+ esTables: ReadonlySet<TableId>,
627
+ ): { file: string; line: number; message: string; neverFrozen: boolean }[] {
628
+ const firstFile = files[0];
629
+ if (!firstFile) return [];
630
+ const host = firstFile.getProject().getFileSystem();
631
+ const expected = deriveCatalogs(files, esTables);
632
+ const roots = new Set([...expected.keys(), ...publishedRootsWithCatalog(files)]);
633
+ const violations: { file: string; line: number; message: string; neverFrozen: boolean }[] = [];
634
+ for (const root of roots) {
635
+ const wanted = new Set((expected.get(root) ?? []).map((e) => catalogKey(e.module, e.export)));
636
+ const actual = readCatalogKeys(host, root);
637
+ const missing = [...wanted].filter((key) => !actual.has(key));
638
+ const surplus = [...actual].filter((key) => !wanted.has(key));
639
+ if (missing.length === 0 && surplus.length === 0) continue;
640
+ const catalogPath = path.join(root, ES_TABLE_CATALOG_FILE);
641
+ const detail = [
642
+ missing.length > 0 ? `missing: ${missing.join(", ")}` : "",
643
+ surplus.length > 0 ? `surplus: ${surplus.join(", ")}` : "",
644
+ ]
645
+ .filter(Boolean)
646
+ .join("; ");
647
+ violations.push({
648
+ file: path.relative(ROOT, catalogPath),
649
+ line: 0,
650
+ message: `ES table catalog out of date (${detail}). Rewrite it: ${REWRITE_CATALOGS_COMMAND}`,
651
+ neverFrozen: true,
652
+ });
653
+ }
654
+ return violations;
655
+ }
656
+
657
+ function writeCatalogs(files: readonly SourceFile[], esTables: ReadonlySet<TableId>): void {
658
+ const firstFile = files[0];
659
+ if (!firstFile) throw new Error("No scanned files: refusing to rewrite ES table catalogs");
660
+ const host = firstFile.getProject().getFileSystem();
661
+ const expected = deriveCatalogs(files, esTables);
662
+ for (const root of publishedRootsWithCatalog(files)) {
663
+ if (!expected.has(root)) host.deleteSync(path.join(root, ES_TABLE_CATALOG_FILE));
664
+ }
665
+ for (const [root, entries] of expected) {
666
+ host.writeFileSync(
667
+ path.join(root, ES_TABLE_CATALOG_FILE),
668
+ `${JSON.stringify(entries, null, 2)}\n`,
669
+ );
670
+ }
671
+ }
672
+
430
673
  export function scanDirectWrites(
431
674
  sf: SourceFile,
432
675
  esTables: ReadonlySet<TableId>,
676
+ externalEsTables: ExternalEsTables = createExternalEsTables(sf.getProject()),
433
677
  ): Omit<Violation, "file">[] {
434
678
  const out: Omit<Violation, "file">[] = [];
679
+ // Superset of every name a write may use for an ES table: definition names plus import bindings.
680
+ const candidateNames = new Set([...tableNamesOf(esTables), ...importedBindingNames(sf)]);
435
681
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
436
682
  const w = resolveWrite(call);
437
683
  if (!w) continue;
684
+ if (!candidateNames.has(w.tableArg.getText())) continue;
438
685
 
439
686
  // Walk the receiver chain down to its leftmost identifier. For `db.insert`
440
687
  // that's `db`; for `ctx.db.raw` (function-form arg0) that's `ctx`; for
@@ -446,8 +693,10 @@ export function scanDirectWrites(
446
693
  const receiverName = receiver.getText();
447
694
 
448
695
  const did = declIdOf(w.tableArg);
449
- if (!did || !esTables.has(did)) continue;
450
- if (isDirectWriteException(call, w.tableArg.getText())) continue;
696
+ if (!did) continue;
697
+ if (!esTables.has(did)) {
698
+ if (!isExternalSourcePath(tableIdFilePath(did)) || !externalEsTables.has(did)) continue;
699
+ }
451
700
 
452
701
  if (TX_RECEIVER_NAMES.has(receiverName)) {
453
702
  // A tx receiver is only legitimate when the write actually sits
@@ -457,7 +706,7 @@ export function scanDirectWrites(
457
706
  if (isInsideProjectionApply(call)) continue;
458
707
  out.push({
459
708
  line: call.getStartLineNumber(),
460
- receiver: receiverName,
709
+ receiver: w.receiver.getText(),
461
710
  op: w.op,
462
711
  table: w.tableArg.getText(),
463
712
  snippet: call.getText().slice(0, 120),
@@ -468,7 +717,7 @@ export function scanDirectWrites(
468
717
 
469
718
  out.push({
470
719
  line: call.getStartLineNumber(),
471
- receiver: receiverName,
720
+ receiver: w.receiver.getText(),
472
721
  op: w.op,
473
722
  table: w.tableArg.getText(),
474
723
  snippet: call.getText().slice(0, 120),
@@ -486,14 +735,9 @@ export const guard: AstGuard = {
486
735
  "Write ES-entity tables through the entity write handlers / event-store executor or inside an r.projection apply (tx receiver); a direct insert/update/delete lets the projection row drift from the event stream. " +
487
736
  "For a BLOCKED scan, check that the scan still covers the files declaring r.entity / createEventStoreExecutor.",
488
737
  run(files) {
489
- // Merge both collections BEFORE the empty-set check — otherwise a repo
490
- // that only uses r.entity (no createEventStoreExecutor anymore) would
491
- // skip every scan: the canary violation fires falsely, and writes on
492
- // entity tables go undetected.
493
- const esTables = new Set<TableId>([
494
- ...collectEsTables(files),
495
- ...collectEntityProjectionTables(files),
496
- ]);
738
+ // Merge all collections BEFORE the empty-set check — otherwise a repo that only uses r.entity
739
+ // would skip every scan: the canary fires falsely and writes on entity tables go undetected.
740
+ const esTables = collectScopeEsTables(files);
497
741
  if (esTables.size === 0) {
498
742
  // An empty set means either the repo has no event store at all, or the
499
743
  // scan lost the ES definitions while table writes are still present.
@@ -510,13 +754,23 @@ export const guard: AstGuard = {
510
754
  };
511
755
  }
512
756
 
513
- const violations: Array<{ file: string; line: number; message: string }> = [];
757
+ const violations: Array<{
758
+ file: string;
759
+ line: number;
760
+ message: string;
761
+ neverFrozen?: boolean;
762
+ }> = catalogViolations(files, esTables);
763
+
764
+ const firstFile = files[0];
765
+ const externalEsTables = firstFile
766
+ ? createExternalEsTables(firstFile.getProject())
767
+ : { has: () => false };
514
768
 
515
769
  for (const sf of files) {
516
770
  const file = sf.getFilePath();
517
771
  if (EXCLUDE.test(file)) continue;
518
772
 
519
- for (const hit of scanDirectWrites(sf, esTables)) {
773
+ for (const hit of scanDirectWrites(sf, esTables, externalEsTables)) {
520
774
  violations.push({
521
775
  file: path.relative(ROOT, file),
522
776
  line: hit.line,
@@ -527,6 +781,9 @@ export const guard: AstGuard = {
527
781
 
528
782
  return { violations };
529
783
  },
784
+ writeBaseline(files) {
785
+ writeCatalogs(files, collectScopeEsTables(files));
786
+ },
530
787
  };
531
788
 
532
789
  // Run only when invoked as a script — tests import the helpers without
@@ -31,6 +31,7 @@ import {
31
31
  runStandalone,
32
32
  type ScanSpec,
33
33
  } from "./_lib/guard-kit";
34
+ import { lineHasIgnoreTag } from "./_lib/ignore-tag";
34
35
  import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
35
36
 
36
37
  const SCAN: ScanSpec = {
@@ -48,7 +49,9 @@ const EXCLUDE =
48
49
 
49
50
  // packages/framework/src/http/egress.ts IS the egress() implementation.
50
51
  const ALLOWLIST = [/^kumiko-framework\/packages\/framework\/src\/http\/egress\.ts$/];
51
- const ALLOW_MARKER = "guard-allow: same-origin fetch";
52
+ const IGNORE_TAG = "kumiko-lint-ignore direct-fetch";
53
+ const IGNORE_TAG_WITH_REASON_RE =
54
+ /kumiko-lint-ignore\s+(?:[\w-]+,)*direct-fetch(?:,[\w-]+)*(?=\s|$)(.*)$/;
52
55
 
53
56
  interface Violation {
54
57
  line: number;
@@ -61,14 +64,12 @@ const GLOBAL_FETCH_RECEIVERS = new Set(["globalThis", "window"]);
61
64
 
62
65
  // The marker is a per-line skip for a security guard, so it only counts with a
63
66
  // non-generic reason after it — a bare marker still reports the finding.
64
- function hasAllowMarkerWithReason(sf: SourceFile, line: number): boolean {
67
+ function hasIgnoreTagWithReason(sf: SourceFile, line: number): boolean {
65
68
  const lines = sf.getFullText().split("\n");
66
69
  const idx = line - 1;
67
70
  return [lines[idx] ?? "", lines[idx - 1] ?? ""].some((text) => {
68
- const markerAt = text.indexOf(ALLOW_MARKER);
69
- if (markerAt < 0) return false;
70
- const reason = text
71
- .slice(markerAt + ALLOW_MARKER.length)
71
+ if (!lineHasIgnoreTag(text, IGNORE_TAG)) return false;
72
+ const reason = (IGNORE_TAG_WITH_REASON_RE.exec(text)?.[1] ?? "")
72
73
  .replace(/^\s*(?:—|–|-|:)\s*/, "")
73
74
  .trim();
74
75
  return !isGenericReason(reason);
@@ -127,7 +128,7 @@ export const guard: AstGuard = {
127
128
  security: true,
128
129
  hint:
129
130
  "Replace raw fetch(...) with egress(policy)(...) from @cosmicdrift/kumiko-framework/http (framework#2147). " +
130
- 'Same-origin path literal (`"/api/..."`) is allowed; otherwise put `// guard-allow: same-origin fetch — <specific reason>` on the line above (a bare marker does not count). ' +
131
+ 'Same-origin path literal (`"/api/..."`) is allowed; otherwise put `// kumiko-lint-ignore direct-fetch <specific reason>` on the line above (a bare marker does not count). ' +
131
132
  "Local bindings named fetch are flagged (false positive — unblock via that marker). " +
132
133
  'Known gap: bracket access (globalThis["fetch"](...)).',
133
134
  run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
@@ -142,7 +143,7 @@ export const guard: AstGuard = {
142
143
  root === undefined ? undefined : `${root.name}/${pathRelative(root.absPath, file)}`;
143
144
  if (key !== undefined && isAllowlisted(key, ALLOWLIST)) continue;
144
145
  for (const v of findRawFetchCalls(sf)) {
145
- if (hasAllowMarkerWithReason(sf, v.line)) continue;
146
+ if (hasIgnoreTagWithReason(sf, v.line)) continue;
146
147
  violations.push({
147
148
  // cwd-relative, not repo-relative `rel` — the security baseline
148
149
  // needs an unambiguous path to resolve back to (repo, relPath).
@@ -128,6 +128,7 @@ function scannableFiles(files: readonly SourceFile[]): SourceFile[] {
128
128
  export function systemScopeDirs(files: readonly SourceFile[]): string[] {
129
129
  const dirs: string[] = [];
130
130
  for (const sf of scannableFiles(files)) {
131
+ if (!sf.getFullText().includes("systemScope")) continue;
131
132
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
132
133
  const expr = call.getExpression();
133
134
  if (
@@ -189,6 +190,10 @@ function isTenantDbExpression(node: Node): boolean {
189
190
  return (node.getSymbol()?.getDeclarations() ?? []).some(isTenantDbDeclaration);
190
191
  }
191
192
 
193
+ // Text prefilters: a node walk per rule per file is the guard's dominant cost, and
194
+ // a rule's anchor name must appear verbatim in any file it can fire on.
195
+ const RAW_ACCESS = /\.\s*raw\b/;
196
+
192
197
  function findRawFindings(
193
198
  sf: SourceFile,
194
199
  root: string,
@@ -196,6 +201,7 @@ function findRawFindings(
196
201
  ): EscapeHatchFinding[] {
197
202
  if (isInSystemScope(sf.getFilePath(), systemDirs)) return [];
198
203
  const out: EscapeHatchFinding[] = [];
204
+ if (!RAW_ACCESS.test(sf.getFullText())) return out;
199
205
  for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
200
206
  if (pa.getName() !== "raw") continue;
201
207
  if (!isTenantDbExpression(pa.getExpression())) continue;
@@ -217,6 +223,7 @@ function findUnsafeRawFindings(
217
223
  ): EscapeHatchFinding[] {
218
224
  if (isInSystemScope(sf.getFilePath(), systemDirs)) return [];
219
225
  const out: EscapeHatchFinding[] = [];
226
+ if (!sf.getFullText().includes("unsafeRaw")) return out;
220
227
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
221
228
  const expr = call.getExpression();
222
229
  if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
@@ -531,6 +538,8 @@ function findSystemIdentityFindings(
531
538
  systemDirs: readonly string[],
532
539
  ): EscapeHatchFinding[] {
533
540
  const out: EscapeHatchFinding[] = [];
541
+ const text = sf.getFullText();
542
+ if (!text.includes("queryAs") && !text.includes("writeAs")) return out;
534
543
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
535
544
  const expr = call.getExpression();
536
545
  if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
@@ -562,6 +571,7 @@ function findUnsafeAllTenantsFindings(
562
571
  systemDirs: readonly string[],
563
572
  ): EscapeHatchFinding[] {
564
573
  const out: EscapeHatchFinding[] = [];
574
+ if (!sf.getFullText().includes("unsafeAllTenants")) return out;
565
575
  for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
566
576
  const nameNode = prop.getNameNode();
567
577
  if (!nameNode.isKind(SyntaxKind.Identifier) || nameNode.getText() !== "unsafeAllTenants") {
@@ -609,6 +619,8 @@ const GENERIC_REASON_METHODS = new Set(["acknowledgeCrossTenant", "unsafeRaw"]);
609
619
 
610
620
  function findGenericReasonMethodCalls(sf: SourceFile, root: string): GenericReasonFinding[] {
611
621
  const out: GenericReasonFinding[] = [];
622
+ const text = sf.getFullText();
623
+ if (![...GENERIC_REASON_METHODS].some((method) => text.includes(method))) return out;
612
624
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
613
625
  const expr = call.getExpression();
614
626
  if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
@@ -634,6 +646,7 @@ function findGenericReasonDeclareEscapeHatchCalls(
634
646
  root: string,
635
647
  ): GenericReasonFinding[] {
636
648
  const out: GenericReasonFinding[] = [];
649
+ if (!sf.getFullText().includes("declareEscapeHatch")) return out;
637
650
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
638
651
  const callee = call.getExpression();
639
652
  if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
@@ -662,6 +675,7 @@ function findGenericReasonObjectProperty(
662
675
  propertyName: string,
663
676
  ): GenericReasonFinding[] {
664
677
  const out: GenericReasonFinding[] = [];
678
+ if (!sf.getFullText().includes(propertyName)) return out;
665
679
  for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
666
680
  if (pa.getName() !== propertyName) continue;
667
681
  const init = pa.getInitializer();
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Fails when docs/reference/guards.md differs from what the generator renders from the guard sources.
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
8
+ import {
9
+ GUARDS_DOC_REL,
10
+ GUARDS_SRC_REL,
11
+ hasGuardsSource,
12
+ renderGuardsDoc,
13
+ } from "./_lib/guards-doc";
14
+
15
+ const REGENERATE_HINT = "run `bun run gen:guards-doc` and commit the result";
16
+
17
+ export const check: RepoCheck = {
18
+ name: "guards-doc-drift",
19
+ hint: "Run `bun run gen:guards-doc` and commit docs/reference/guards.md.",
20
+ run(roots) {
21
+ const frameworkRoots = roots.filter((root) => hasGuardsSource(root.absPath));
22
+ const violations: { file: string; line: number; message: string }[] = [];
23
+ for (const root of frameworkRoots) {
24
+ const docPath = join(root.absPath, GUARDS_DOC_REL);
25
+ const expected = renderGuardsDoc(root.absPath);
26
+ if (!existsSync(docPath)) {
27
+ violations.push({ file: GUARDS_DOC_REL, line: 1, message: `missing; ${REGENERATE_HINT}` });
28
+ } else if (readFileSync(docPath, "utf-8") !== expected) {
29
+ violations.push({
30
+ file: GUARDS_DOC_REL,
31
+ line: 1,
32
+ message: `differs from the generated output (sources in ${GUARDS_SRC_REL}); ${REGENERATE_HINT}`,
33
+ });
34
+ }
35
+ }
36
+ return {
37
+ violations,
38
+ matchedFiles: frameworkRoots.length,
39
+ notApplicable: frameworkRoots.length === 0,
40
+ };
41
+ },
42
+ };
43
+
44
+ if (import.meta.main) {
45
+ const failed = reportResults(await runRepoChecks([check]));
46
+ process.exit(failed > 0 ? 1 : 0);
47
+ }