@cosmicdrift/kumiko-guards 0.377.0 → 0.378.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 CHANGED
@@ -67,8 +67,9 @@ drops it.
67
67
  A POSIX-sh pre-push hook, wired by copying the package's `hooks/pre-push`
68
68
  shim to `.husky/pre-push`. The shim execs the nearest executable
69
69
  `node_modules/.bin/kumiko-pre-push` from the repo root upwards (so worktrees
70
- without their own `node_modules` resolve via the parent install) and fails
71
- closed with a `bun install` hint when none exists. Inside
70
+ without their own `node_modules` resolve via the parent install, up to and
71
+ including `$HOME`, skipping ancestors owned by another user or writable by
72
+ group/others) and fails closed with a `bun install` hint when none exists. Inside
72
73
  the cosmicdriftgamestudio parent workspace it runs a scoped `bun check` for
73
74
  the pushing repo only; in a standalone clone it runs the repo's own
74
75
  `package.json` `test` script. From a `.wt/<name>` worktree under the parent
package/hooks/pre-push CHANGED
@@ -8,15 +8,35 @@ fi
8
8
  # Walk up so a worktree without its own node_modules resolves via the parent
9
9
  # install, but not past an enclosing repository: a bin above that boundary
10
10
  # belongs to another project. `[ -x ]` also skips a broken (uninstalled) symlink.
11
+ # A bin in an ancestor that is owned by someone else or group/world-writable
12
+ # (/tmp, /Users/Shared) is attacker-plantable and must never be exec'd, and
13
+ # $HOME is the upper trust boundary (inclusive), compared by physical path
14
+ # because git prints REPO_ROOT resolved: a trailing slash or a symlinked home
15
+ # would otherwise never match and the walk would continue to /.
16
+ HOME_BOUND=""
17
+ if [ -n "$HOME" ] && [ -d "$HOME" ]; then
18
+ HOME_BOUND="$(cd "$HOME" 2>/dev/null && pwd -P)"
19
+ fi
11
20
  BIN=""
12
21
  DIR="$REPO_ROOT"
13
22
  while true; do
14
- CANDIDATE="$DIR/node_modules/.bin/kumiko-pre-push"
15
- if [ -x "$CANDIDATE" ]; then
16
- BIN="$CANDIDATE"
17
- break
23
+ TRUSTED=1
24
+ if [ "$DIR" != "$REPO_ROOT" ]; then
25
+ if [ ! -O "$DIR" ] || [ -n "$(find "$DIR" -maxdepth 0 \( -perm -0020 -o -perm -0002 \) -print 2>/dev/null)" ]; then
26
+ TRUSTED=0
27
+ fi
28
+ fi
29
+ if [ "$TRUSTED" = 1 ]; then
30
+ BIN_DIR="$DIR/node_modules/.bin"
31
+ CANDIDATE="$BIN_DIR/kumiko-pre-push"
32
+ # The bin dir itself must be user-owned and not group/world-writable, or a
33
+ # planted entry could replace the real bin.
34
+ if [ -x "$CANDIDATE" ] && [ -O "$BIN_DIR" ] && [ -z "$(find "$BIN_DIR" -maxdepth 0 \( -perm -0020 -o -perm -0002 \) -print 2>/dev/null)" ]; then
35
+ BIN="$CANDIDATE"
36
+ break
37
+ fi
18
38
  fi
19
- if [ "$DIR" = "/" ]; then
39
+ if [ "$DIR" = "/" ] || { [ -n "$HOME_BOUND" ] && [ "$DIR" = "$HOME_BOUND" ]; }; then
20
40
  break
21
41
  fi
22
42
  if [ "$DIR" != "$REPO_ROOT" ] && [ -e "$DIR/.git" ]; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.377.0",
3
+ "version": "0.378.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -59,6 +59,6 @@
59
59
  }
60
60
  },
61
61
  "devDependencies": {
62
- "@cosmicdrift/kumiko-framework": "0.377.0"
62
+ "@cosmicdrift/kumiko-framework": "0.378.0"
63
63
  }
64
64
  }
package/src/changes.json CHANGED
@@ -1,4 +1,28 @@
1
1
  [
2
+ {
3
+ "version": "0.378.0",
4
+ "type": "fix",
5
+ "title": "Direct-entity-writes guard catches imported event-store tables",
6
+ "detail": "Table identifiers are resolved through import aliases to their definition, so direct insert/update/delete on an event-store table imported from another file of the scanned repo is no longer missed. Findings now show the full receiver (ctx.db). Existing consumers can get new findings that were previously hidden."
7
+ },
8
+ {
9
+ "version": "0.378.0",
10
+ "type": "improvement",
11
+ "title": "Faster, leaner guard run",
12
+ "detail": "Four AST guards skip files that cannot contain their anchor names before walking every node, and Direct-Entity-Writes only resolves symbols for table arguments named like an event-sourced table. Findings are identical."
13
+ },
14
+ {
15
+ "version": "0.378.0",
16
+ "type": "fix",
17
+ "title": "Pre-push shim skips untrusted ancestors and stops at $HOME",
18
+ "detail": "The shim walked up to / and could exec a kumiko-pre-push planted in /tmp or another shared directory. The walk now ends at $HOME and skips ancestors that another user owns or that group or others can write to."
19
+ },
20
+ {
21
+ "version": "0.378.0",
22
+ "type": "fix",
23
+ "title": "Pre-push shim also vets node_modules/.bin",
24
+ "detail": "The shim now requires node_modules/.bin itself to be owned by the user and not group or world writable before executing kumiko-pre-push from it, in the guards hook and the dev-server husky template."
25
+ },
2
26
  {
3
27
  "version": "0.377.0",
4
28
  "type": "improvement",
@@ -41,6 +41,8 @@ const REGISTRAR_TYPE_MARKER = "FeatureRegistrar";
41
41
  const FEATURE_FACTORY_NAME = "defineFeature";
42
42
  const REGISTRAR_RECEIVER_NAMES: ReadonlySet<string> = new Set(["r", "registrar"]);
43
43
  const EXCLUDED_PATH = /(\/__tests__\/fixtures\/|\.d\.ts$)/;
44
+ // Cheap text superset of what positionalCallMethod can match; skips the AST walk for most files.
45
+ const REGISTRAR_METHOD_ACCESS = new RegExp(`\\.\\s*(?:${REGISTRAR_METHODS.join("|")})\\b`);
44
46
  const UPGRADE_HINT = "run `bun kumiko-upgrade --apply` (codemod object-form)";
45
47
 
46
48
  type ReceiverResolution = "registrar" | "other" | "unresolved";
@@ -145,39 +147,51 @@ function positionalCallMethod(call: CallExpression): RegistrarMethod | undefined
145
147
 
146
148
  export type ObjectFormFinding = { file: string; line: number; message: string };
147
149
 
150
+ function positionalCallFindings(sf: SourceFile, file: string): ObjectFormFinding[] {
151
+ if (!REGISTRAR_METHOD_ACCESS.test(sf.getFullText())) return [];
152
+ const out: ObjectFormFinding[] = [];
153
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
154
+ const method = positionalCallMethod(call);
155
+ if (method === undefined) continue;
156
+ out.push({
157
+ file,
158
+ line: call.getStartLineNumber(),
159
+ message: `positional r.${method}(…) removed in 0.366 — ${UPGRADE_HINT}`,
160
+ });
161
+ }
162
+ return out;
163
+ }
164
+
165
+ function crudFindings(sf: SourceFile, file: string): ObjectFormFinding[] {
166
+ // Every crud local name is CRUD_FUNCTION_NAME itself or an import alias of it.
167
+ if (!sf.getFullText().includes(CRUD_FUNCTION_NAME)) return [];
168
+ const crudNames = crudLocalNames(sf);
169
+ const out: ObjectFormFinding[] = [];
170
+ for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
171
+ if (!crudNames.has(id.getText())) continue;
172
+ const parent = id.getParent();
173
+ const isCall = Node.isCallExpression(parent) && parent.getExpression() === id;
174
+ const isImportOrExport =
175
+ Node.isImportSpecifier(parent) ||
176
+ Node.isExportSpecifier(parent) ||
177
+ id.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) !== undefined;
178
+ if (!isCall && !isImportOrExport) continue;
179
+ if (Node.isImportSpecifier(parent) && parent.getAliasNode() === id) continue;
180
+ out.push({
181
+ file,
182
+ line: id.getStartLineNumber(),
183
+ message: `registerEntityCrud removed in 0.366 — use r.crud(…); ${UPGRADE_HINT}`,
184
+ });
185
+ }
186
+ return out;
187
+ }
188
+
148
189
  export function findPositionalRegistrarCalls(files: readonly SourceFile[]): ObjectFormFinding[] {
149
190
  const out: ObjectFormFinding[] = [];
150
191
  for (const sf of files) {
151
192
  if (EXCLUDED_PATH.test(sf.getFilePath())) continue;
152
193
  const file = relativeToCwd(sf.getFilePath());
153
- const crudNames = crudLocalNames(sf);
154
-
155
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
156
- const method = positionalCallMethod(call);
157
- if (method === undefined) continue;
158
- out.push({
159
- file,
160
- line: call.getStartLineNumber(),
161
- message: `positional r.${method}(…) removed in 0.366 — ${UPGRADE_HINT}`,
162
- });
163
- }
164
-
165
- for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
166
- if (!crudNames.has(id.getText())) continue;
167
- const parent = id.getParent();
168
- const isCall = Node.isCallExpression(parent) && parent.getExpression() === id;
169
- const isImportOrExport =
170
- Node.isImportSpecifier(parent) ||
171
- Node.isExportSpecifier(parent) ||
172
- id.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) !== undefined;
173
- if (!isCall && !isImportOrExport) continue;
174
- if (Node.isImportSpecifier(parent) && parent.getAliasNode() === id) continue;
175
- out.push({
176
- file,
177
- line: id.getStartLineNumber(),
178
- message: `registerEntityCrud removed in 0.366 — use r.crud(…); ${UPGRADE_HINT}`,
179
- });
180
- }
194
+ out.push(...positionalCallFindings(sf, file), ...crudFindings(sf, file));
181
195
  }
182
196
  return out;
183
197
  }
@@ -156,21 +156,26 @@ export function unwrapTableExpression(node: Node): Node {
156
156
  // declaration disambiguates them.
157
157
  type TableId = string; // "${filePath}::${name}"
158
158
 
159
- function declIdOf(id: import("ts-morph").Identifier): TableId | undefined {
159
+ function declIdOf(id: Identifier): TableId | undefined {
160
160
  const symbol = id.getSymbol();
161
161
  if (!symbol) return undefined;
162
- const decls = symbol.getDeclarations();
162
+ // An imported identifier's own declaration is the ImportSpecifier; follow the alias
163
+ // chain (barrels, renames) so the ID matches the one built at the definition.
164
+ const resolved = symbol.isAlias() ? (symbol.getAliasedSymbol() ?? symbol) : symbol;
165
+ const decls = resolved.getDeclarations();
163
166
  if (decls.length === 0) return undefined;
164
167
  // Param-only symbols are factory pass-throughs — skip.
165
168
  if (decls.every((d) => d.getKind() === SyntaxKind.Parameter)) return undefined;
166
169
  const first = decls[0];
167
170
  if (!first) return undefined;
168
- return `${first.getSourceFile().getFilePath()}::${id.getText()}`;
171
+ const name = Node.hasName(first) ? first.getName() : id.getText();
172
+ return `${first.getSourceFile().getFilePath()}::${name}`;
169
173
  }
170
174
 
171
175
  export function collectEsTables(files: readonly SourceFile[]): Set<TableId> {
172
176
  const tables = new Set<TableId>();
173
177
  for (const sf of files) {
178
+ if (!sf.getFullText().includes("createEventStoreExecutor")) continue;
174
179
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
175
180
  const expr = call.getExpression();
176
181
  if (expr.getText() !== "createEventStoreExecutor") continue;
@@ -251,6 +256,8 @@ function rebuildableEntityTableDeclId(
251
256
  return declIdOf(nameNode.asKindOrThrow(SyntaxKind.Identifier));
252
257
  }
253
258
 
259
+ const ENTITY_METHOD_ACCESS = /\.\s*entity\b/;
260
+
254
261
  // Tables registered via `r.entity({ name, ...entityDef })` as a rebuildable implicit
255
262
  // projection, linked entity→table via the shared entity-def symbol (see header), not by name string.
256
263
  export function collectEntityProjectionTables(files: readonly SourceFile[]): Set<TableId> {
@@ -259,6 +266,7 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
259
266
 
260
267
  // Pass 1: entity-def decls that r.entity makes rebuildable (+ {table} override).
261
268
  for (const sf of files) {
269
+ if (!ENTITY_METHOD_ACCESS.test(sf.getFullText())) continue;
262
270
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
263
271
  const registration = entityRegistrationOf(call);
264
272
  if (!registration) continue;
@@ -270,6 +278,7 @@ export function collectEntityProjectionTables(files: readonly SourceFile[]): Set
270
278
  // Pass 2: `const xTable = buildEntityTable(<name>, <entityDef>)` where entityDef
271
279
  // is in the rebuildable set → xTable is a rebuildable entity table.
272
280
  for (const sf of files) {
281
+ if (!sf.getFullText().includes("buildEntityTable")) continue;
273
282
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
274
283
  const did = rebuildableEntityTableDeclId(call, rebuildableEntities);
275
284
  if (did) tables.add(did);
@@ -427,14 +436,37 @@ function hasAnyTableWriteOrDeclaration(files: readonly SourceFile[]): boolean {
427
436
  return false;
428
437
  }
429
438
 
439
+ // A TableId ends in `::<identifier text>`, so a table argument whose text is no ES table name
440
+ // can never match; filtering on the name avoids a symbol lookup (TS program build) per call.
441
+ function tableNamesOf(tableIds: ReadonlySet<TableId>): Set<string> {
442
+ return new Set([...tableIds].map((tableId) => tableId.slice(tableId.lastIndexOf("::") + 2)));
443
+ }
444
+
445
+ // Local names an import can give a table in this file (`import { a as b }` -> b). Syntactic,
446
+ // so a rename or barrel re-export never hides an ES table from the name prefilter.
447
+ function importedBindingNames(sf: SourceFile): Set<string> {
448
+ const names = new Set<string>();
449
+ for (const declaration of sf.getImportDeclarations()) {
450
+ const defaultName = declaration.getDefaultImport()?.getText();
451
+ if (defaultName) names.add(defaultName);
452
+ for (const specifier of declaration.getNamedImports()) {
453
+ names.add(specifier.getAliasNode()?.getText() ?? specifier.getName());
454
+ }
455
+ }
456
+ return names;
457
+ }
458
+
430
459
  export function scanDirectWrites(
431
460
  sf: SourceFile,
432
461
  esTables: ReadonlySet<TableId>,
433
462
  ): Omit<Violation, "file">[] {
434
463
  const out: Omit<Violation, "file">[] = [];
464
+ // Superset of every name a write may use for an ES table: definition names plus import bindings.
465
+ const candidateNames = new Set([...tableNamesOf(esTables), ...importedBindingNames(sf)]);
435
466
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
436
467
  const w = resolveWrite(call);
437
468
  if (!w) continue;
469
+ if (!candidateNames.has(w.tableArg.getText())) continue;
438
470
 
439
471
  // Walk the receiver chain down to its leftmost identifier. For `db.insert`
440
472
  // that's `db`; for `ctx.db.raw` (function-form arg0) that's `ctx`; for
@@ -457,7 +489,7 @@ export function scanDirectWrites(
457
489
  if (isInsideProjectionApply(call)) continue;
458
490
  out.push({
459
491
  line: call.getStartLineNumber(),
460
- receiver: receiverName,
492
+ receiver: w.receiver.getText(),
461
493
  op: w.op,
462
494
  table: w.tableArg.getText(),
463
495
  snippet: call.getText().slice(0, 120),
@@ -468,7 +500,7 @@ export function scanDirectWrites(
468
500
 
469
501
  out.push({
470
502
  line: call.getStartLineNumber(),
471
- receiver: receiverName,
503
+ receiver: w.receiver.getText(),
472
504
  op: w.op,
473
505
  table: w.tableArg.getText(),
474
506
  snippet: call.getText().slice(0, 120),
@@ -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();
@@ -68,6 +68,8 @@ type Pattern = {
68
68
  // identifier in the AST (not in strings, not in comments).
69
69
  function identifierHits(sf: SourceFile, name: string): Array<{ line: number; snippet: string }> {
70
70
  const out: Array<{ line: number; snippet: string }> = [];
71
+ // Cheap text prefilter: a full descendant walk over every file costs seconds.
72
+ if (!sf.getFullText().includes(name)) return out;
71
73
  for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
72
74
  if (id.getText() !== name) continue;
73
75
  out.push({
@@ -85,6 +87,7 @@ function stringLiteralHits(
85
87
  literal: string,
86
88
  ): Array<{ line: number; snippet: string }> {
87
89
  const out: Array<{ line: number; snippet: string }> = [];
90
+ if (!sf.getFullText().includes(literal)) return out;
88
91
  for (const lit of sf.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
89
92
  if (lit.getLiteralText() !== literal) continue;
90
93
  out.push({
@@ -129,6 +132,7 @@ const PATTERNS: readonly Pattern[] = [
129
132
  // Match `.postEvent(` property accesses to distinguish from the word
130
133
  // "postEvent" in prose. Identifier scan would miss method invocations.
131
134
  const out: Array<{ line: number; snippet: string }> = [];
135
+ if (!sf.getFullText().includes("postEvent")) return out;
132
136
  for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
133
137
  if (pa.getName() !== "postEvent") continue;
134
138
  // Only flag when invoked as a method — a property read named
@@ -6,9 +6,9 @@
6
6
  * belongs in `src/web` instead.
7
7
  *
8
8
  * `src/web/**` is the browser layer and not scanned at all: it may import
9
- * everything. `src/headless/**` and `src/live/**` only get the import rules:
10
- * the apex page builder emits HTML strings and the live dispatcher is a fetch
11
- * client, so the symbol and JSX-tag rules would be noise there. They stay free
9
+ * everything. `src/headless/**`, `src/flow-diagram/**` and `src/live/**` only get
10
+ * the import rules: the apex page builder emits HTML strings, the flow diagram
11
+ * emits SVG strings and the live dispatcher is a fetch client, so the symbol and JSX-tag rules would be noise there. They stay free
12
12
  * of react-dom, jsdom and any import of `src/web`.
13
13
  *
14
14
  * Deliberately simple: a regex scan over the source text. False positives
@@ -70,7 +70,7 @@ const FORBIDDEN_SYMBOLS = [
70
70
  const FORBIDDEN_JSX_TAG = /(^|\s|>|\()<([a-z][a-zA-Z0-9-]*)(?=[\s/>])/g;
71
71
 
72
72
  // Subtrees of packages/renderer/src that carry only the import rules.
73
- const IMPORT_RULES_ONLY_DIRS = ["headless", "live"] as const;
73
+ const IMPORT_RULES_ONLY_DIRS = ["headless", "flow-diagram", "live"] as const;
74
74
 
75
75
  // The browser layer may import anything, so it is not scanned.
76
76
  const UNSCANNED_TOP_LEVEL_DIRS = ["web"] as const;