@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 +3 -2
- package/hooks/pre-push +25 -5
- package/package.json +2 -2
- package/src/changes.json +24 -0
- package/src/guard-api-object-form.ts +42 -28
- package/src/guard-direct-entity-writes.ts +37 -5
- package/src/guard-escape-hatch-declared.ts +14 -0
- package/src/guard-pre-es-patterns.ts +4 -0
- package/src/guard-renderer-boundaries.ts +4 -4
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
|
|
71
|
-
|
|
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
|
-
|
|
15
|
-
if [
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
159
|
+
function declIdOf(id: Identifier): TableId | undefined {
|
|
160
160
|
const symbol = id.getSymbol();
|
|
161
161
|
if (!symbol) return undefined;
|
|
162
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
10
|
-
* the apex page builder emits HTML strings
|
|
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;
|