@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.
- package/README.md +3 -2
- package/hooks/pre-push +25 -5
- package/package.json +3 -3
- package/src/_lib/guards-doc.ts +89 -0
- package/src/_lib/run-slots.ts +181 -0
- package/src/changes.json +55 -0
- package/src/check-subpath-exports.ts +75 -0
- package/src/guard-api-object-form.ts +42 -28
- package/src/guard-app-feature-structure.ts +28 -1
- package/src/guard-direct-entity-writes.ts +315 -58
- package/src/guard-direct-fetch.ts +9 -8
- package/src/guard-escape-hatch-declared.ts +14 -0
- package/src/guard-guards-doc-drift.ts +47 -0
- package/src/guard-pre-es-patterns.ts +4 -0
- package/src/guard-renderer-boundaries.ts +4 -4
- package/src/guard-restricted-symbols.ts +9 -1
- package/src/index.ts +1 -0
- package/src/run-guards.ts +10 -3
- package/src/run-repo-checks.ts +4 -0
- package/src/runtime-isolation-classify.ts +5 -1
|
@@ -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 {
|
|
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:
|
|
132
|
+
function declIdOf(id: Identifier): TableId | undefined {
|
|
160
133
|
const symbol = id.getSymbol();
|
|
161
134
|
if (!symbol) return undefined;
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
450
|
-
if (
|
|
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:
|
|
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:
|
|
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
|
|
490
|
-
//
|
|
491
|
-
|
|
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<{
|
|
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
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
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 `//
|
|
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 (
|
|
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
|
+
}
|