@vantageos/backend-doctor 0.2.0 → 0.3.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 +48 -4
- package/dist/args.d.ts +1 -1
- package/dist/args.js +19 -1
- package/dist/build-stamp.json +2 -2
- package/dist/caller-resolution.js +11 -8
- package/dist/cli.js +164 -15
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.js +5 -4
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +32 -16
- package/dist/detectors/predicates.js +12 -3
- package/dist/detectors/shared.js +3 -2
- package/dist/detectors/source/_ast.d.ts +92 -0
- package/dist/detectors/source/_ast.js +353 -0
- package/dist/detectors/source/_scan.js +4 -3
- package/dist/detectors/source/backfill.js +62 -17
- package/dist/detectors/source/contract.js +21 -13
- package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
- package/dist/detectors/source/cross-tenant-write.js +1408 -0
- package/dist/detectors/source/guard-helper-flow.d.ts +22 -0
- package/dist/detectors/source/guard-helper-flow.js +49 -6
- package/dist/detectors/source/identity-by-id.d.ts +80 -0
- package/dist/detectors/source/identity-by-id.js +1783 -0
- package/dist/detectors/source/identity.d.ts +8 -1
- package/dist/detectors/source/identity.js +364 -35
- package/dist/detectors/source/jobs.js +13 -2
- package/dist/detectors/source/lifecycle.js +14 -2
- package/dist/detectors/source/local-identity.d.ts +26 -0
- package/dist/detectors/source/local-identity.js +661 -0
- package/dist/detectors/source/package-principal.d.ts +9 -0
- package/dist/detectors/source/package-principal.js +177 -0
- package/dist/detectors/source/r6-shapes.d.ts +20 -0
- package/dist/detectors/source/r6-shapes.js +285 -0
- package/dist/detectors/source/readbudget.js +291 -0
- package/dist/detectors/source/recipient-by-id.d.ts +40 -0
- package/dist/detectors/source/recipient-by-id.js +1108 -0
- package/dist/detectors/source/refusal.d.ts +14 -0
- package/dist/detectors/source/refusal.js +75 -2
- package/dist/detectors/source/security.js +455 -26
- package/dist/detectors/source/writesafety.js +27 -14
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +5 -4
- package/dist/detectors/standard/r41-frontier.js +3 -2
- package/dist/detectors/standard/r46-returns-validator.js +3 -2
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +75 -43
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +11 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +27 -3
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +14 -6
- package/dist/violation-count.d.ts +48 -0
- package/dist/violation-count.js +94 -0
- package/fixtures/backend-standard-2825122.md +722 -0
- package/fixtures/backend-standard-4dcd3e0.md +722 -0
- package/fixtures/rule-classes-2825122.json +228 -0
- package/package.json +1 -1
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { Detector } from "../engine-types.js";
|
|
2
2
|
import { type OracleRow } from "../oracle.js";
|
|
3
3
|
import type { RulePredicate } from "./shared.js";
|
|
4
|
+
export declare const JUSTIFICATION_TOKEN_RE: RegExp;
|
|
5
|
+
/** The oracle-row form: reads the row's remediation cell (`rbac_adjustment_needed`). */
|
|
4
6
|
export declare function hasJustificationToken(r: OracleRow): boolean;
|
|
7
|
+
/** The raw-text form — for a SOURCE-scan site with no oracle row: a nearby code
|
|
8
|
+
* comment window is that site's "remediation cell". Same regex, same word-
|
|
9
|
+
* boundary/negation guards; never a second matcher. */
|
|
10
|
+
export declare function hasJustificationTokenInText(text: string): boolean;
|
|
5
11
|
export declare const r7: RulePredicate;
|
|
6
12
|
export declare const r8: RulePredicate;
|
|
7
13
|
export interface DivergenceSite {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isInventoryRow, oracleLabel, readOracle, refuseWithoutOracle, } from "../oracle.js";
|
|
2
|
+
import { itemCount, withCount } from "../violation-count.js";
|
|
2
3
|
// A written justification token turns a flagged divergence from a DEFECT the
|
|
3
4
|
// author owes into a recorded, accepted divergence. It is the mechanical pole
|
|
4
5
|
// that replaced "a human decides whether the divergence is sound": the control
|
|
@@ -7,22 +8,37 @@ import { isInventoryRow, oracleLabel, readOracle, refuseWithoutOracle, } from ".
|
|
|
7
8
|
// are remediation-owed markers (R-28) — a fix still owed, NOT a justification —
|
|
8
9
|
// so they do not satisfy this predicate; only an explicit JUSTIFIÉ/JUSTIFIED/
|
|
9
10
|
// STATELESS/ACCEPTED token does.
|
|
11
|
+
//
|
|
12
|
+
// Anchored on the DOCUMENTED shape: the standard writes the token with a
|
|
13
|
+
// trailing colon (`JUSTIFIÉ:` / `JUSTIFIED:` / `STATELESS:`), at a word
|
|
14
|
+
// boundary. Two failures a bare substring match let through, both closed here:
|
|
15
|
+
// • a LEADING-letter negation — "UNJUSTIFIED" contains "JUSTIFIED", so the
|
|
16
|
+
// leading `(?<![A-Za-zÀ-ÿ-])` (not preceded by a letter OR a hyphen — a
|
|
17
|
+
// hyphen-joined negation like French `non-justifié:` clears a letter-only
|
|
18
|
+
// lookbehind, Eta #12 round-2) rejects it; and R-37's own output prints
|
|
19
|
+
// "N UNJUSTIFIED divergence(s)".
|
|
20
|
+
// • a token with NO colon — "not justified", "justified by design" — cleared
|
|
21
|
+
// the guard though the standard requires the colon; the trailing `\s*:`
|
|
22
|
+
// now rejects it. Only the recorded token form (`TOKEN:`) is a justification.
|
|
23
|
+
// No trailing \b before the colon: an accented final letter (É in JUSTIFIÉ) is
|
|
24
|
+
// not an ASCII word char, so \b would not match after it — the colon is the anchor.
|
|
25
|
+
//
|
|
26
|
+
// ONE regex, shared by every axis (oracle-row remediation cells here, and a
|
|
27
|
+
// source-scan's nearby comment window in the SOURCE_STANDARD_PIN detectors) —
|
|
28
|
+
// task k179jnhx: "Do not write a second token matcher; extract/share the
|
|
29
|
+
// existing one." Two implementations of one predicate is the defect class the
|
|
30
|
+
// fleet keeps filing.
|
|
31
|
+
export const JUSTIFICATION_TOKEN_RE = /(?<![A-Za-zÀ-ÿ-])(JUSTIFI(É|E|ED)|STATELESS|ACCEPT(É|E|ED)|WONTFIX)\s*:/i;
|
|
32
|
+
/** The oracle-row form: reads the row's remediation cell (`rbac_adjustment_needed`). */
|
|
10
33
|
export function hasJustificationToken(r) {
|
|
11
34
|
const cell = (r.by.rbac_adjustment_needed ?? "").trim();
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// "N UNJUSTIFIED divergence(s)".
|
|
20
|
-
// • a token with NO colon — "not justified", "justified by design" — cleared
|
|
21
|
-
// the guard though the standard requires the colon; the trailing `\s*:`
|
|
22
|
-
// now rejects it. Only the recorded token form (`TOKEN:`) is a justification.
|
|
23
|
-
// No trailing \b before the colon: an accented final letter (É in JUSTIFIÉ) is
|
|
24
|
-
// not an ASCII word char, so \b would not match after it — the colon is the anchor.
|
|
25
|
-
return /(?<![A-Za-zÀ-ÿ-])(JUSTIFI(É|E|ED)|STATELESS|ACCEPT(É|E|ED)|WONTFIX)\s*:/i.test(cell);
|
|
35
|
+
return JUSTIFICATION_TOKEN_RE.test(cell);
|
|
36
|
+
}
|
|
37
|
+
/** The raw-text form — for a SOURCE-scan site with no oracle row: a nearby code
|
|
38
|
+
* comment window is that site's "remediation cell". Same regex, same word-
|
|
39
|
+
* boundary/negation guards; never a second matcher. */
|
|
40
|
+
export function hasJustificationTokenInText(text) {
|
|
41
|
+
return JUSTIFICATION_TOKEN_RE.test(text);
|
|
26
42
|
}
|
|
27
43
|
// ---- R-7 read/write coherence divergence (INCOHERENT* table-aggregate flag) -
|
|
28
44
|
// §18: "`INCOHERENT*` flag (mechanical); divergence justified (human)". The
|
|
@@ -182,12 +198,12 @@ export function makeR37(rowsProvider = readOracle) {
|
|
|
182
198
|
.map((s) => `${s.table}.${s.field}: ${s.toolA} "${s.valA.slice(0, 32)}" vs ${s.toolB} "${s.valB.slice(0, 32)}"`)
|
|
183
199
|
.join("; ");
|
|
184
200
|
const reason = `R-37: ${sites.length} UNJUSTIFIED filled-neighbour divergence(s) across ${tables.size} tables — the mechanical compare names WHICH TWO TOOLS diverge on a shared contract field, and none carries a written per-tool justification token (JUSTIFIÉ:/JUSTIFIED: in rbac_adjustment_needed); each owes a fix or a recorded justification. This IS a violation, not an abstention. ${breakdown} Examples: ${examples}.`;
|
|
185
|
-
return {
|
|
201
|
+
return withCount({
|
|
186
202
|
id: R37_ID,
|
|
187
203
|
anchor: `${oracleLabel()} table=${sites[0].table}`,
|
|
188
204
|
state: "non-conforming",
|
|
189
205
|
verdict: reason,
|
|
190
|
-
};
|
|
206
|
+
}, itemCount(sites.length));
|
|
191
207
|
}),
|
|
192
208
|
};
|
|
193
209
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// structural.ts (they judge the inventory SCHEMA, not a row).
|
|
5
5
|
import { basename } from "node:path";
|
|
6
6
|
import { WRITER_TIER_NAME, readWriterTier } from "../oracle.js";
|
|
7
|
+
import { hasJustificationToken } from "./oracle-axis2.js";
|
|
7
8
|
import { LIST_VERBS, WRITE_VERBS, isMcpPublic } from "./shared.js";
|
|
8
9
|
// ---- R-1 exposure declared ------------------------------------------------
|
|
9
10
|
// §2 line 32: exposure -> `surface` = `public` (MCP surface) / `internal` /
|
|
@@ -181,10 +182,18 @@ export const r5 = {
|
|
|
181
182
|
// no arg= in the boundary: NOW the identity-evidencing label may confirm.
|
|
182
183
|
if (rbacEvidencesIdentity(r.by.rbac_qui))
|
|
183
184
|
return { kind: "conform" };
|
|
184
|
-
// maps to a tier but the row evidences NEITHER identity nor arg —
|
|
185
|
+
// maps to a tier but the row evidences NEITHER identity nor arg — flagged.
|
|
186
|
+
// judgement is retired (task k179jnhx, §23): the disposition is decided by
|
|
187
|
+
// the written justification token's PRESENCE in the remediation cell, never
|
|
188
|
+
// by a person's opinion of whether the ambiguity is sound. No token →
|
|
189
|
+
// non-conforming; a JUSTIFIÉ:/JUSTIFIED: token → present. could-not-judge
|
|
190
|
+
// is reserved for a row the instrument could not read (never reached here —
|
|
191
|
+
// this row parsed fine, the disposition is undecided-by-token, not unreadable).
|
|
192
|
+
if (hasJustificationToken(r))
|
|
193
|
+
return { kind: "conform" };
|
|
185
194
|
return {
|
|
186
|
-
kind: "
|
|
187
|
-
note: `tier maps to ${tier} but rbac_qui="${r.by.rbac_qui}"/namespace_ou_org="${ns}" do not evidence identity-resolution; R-11 flags the locus`,
|
|
195
|
+
kind: "violate",
|
|
196
|
+
note: `tier maps to ${tier} but rbac_qui="${r.by.rbac_qui}"/namespace_ou_org="${ns}" do not evidence identity-resolution, and no written JUSTIFIÉ:/JUSTIFIED: token is recorded in the remediation cell (rbac_adjustment_needed="${(r.by.rbac_adjustment_needed ?? "").trim() || "<empty>"}"); R-11 flags the locus`,
|
|
188
197
|
};
|
|
189
198
|
},
|
|
190
199
|
};
|
package/dist/detectors/shared.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isInventoryRow, oracleLabel, readOracle, refuseWithoutOracle, } from "../oracle.js";
|
|
2
|
+
import { itemCount, withCount } from "../violation-count.js";
|
|
2
3
|
/** Run a predicate over a row set, tallying per-kind. */
|
|
3
4
|
export function tally(spec, rows) {
|
|
4
5
|
const t = { conform: 0, violate: 0, cnj: 0, na: 0, violations: [], unjudged: [] };
|
|
@@ -63,12 +64,12 @@ export function evaluate(spec, rows) {
|
|
|
63
64
|
.slice(0, 3)
|
|
64
65
|
.map((v) => `row#${v.row.n} "${v.row.by.outil || v.row.by.table}" — ${v.note}`)
|
|
65
66
|
.join("; ");
|
|
66
|
-
return {
|
|
67
|
+
return withCount({
|
|
67
68
|
id: spec.id,
|
|
68
69
|
anchor: anchorFor(t.violations, oracleLabel()),
|
|
69
70
|
state: "non-conforming",
|
|
70
71
|
verdict: `${spec.ruleId}: ${t.violate} of ${applicable} applicable oracle rows VIOLATE (${spec.check}). ${breakdown} Examples: ${examples}.`,
|
|
71
|
-
};
|
|
72
|
+
}, itemCount(t.violate));
|
|
72
73
|
}
|
|
73
74
|
return {
|
|
74
75
|
id: spec.id,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import type { Tree } from "../../engine-types.js";
|
|
3
|
+
export type SrcFile = Tree["files"][number];
|
|
4
|
+
export interface Parsed {
|
|
5
|
+
rel: string;
|
|
6
|
+
sf: ts.SourceFile;
|
|
7
|
+
}
|
|
8
|
+
export declare function sourceOf(f: SrcFile): string;
|
|
9
|
+
export declare function parseFile(f: SrcFile): Parsed;
|
|
10
|
+
export declare function lineOf(n: ts.Node): number;
|
|
11
|
+
/** Strip `await`, parentheses, `!` non-null, `as`/`satisfies` casts. */
|
|
12
|
+
export declare function unwrap(e: ts.Expression): ts.Expression;
|
|
13
|
+
/** A property read: `x.prop`, or `x["prop"]` (a string-literal element access
|
|
14
|
+
* is the same read, spelled differently). A computed key is not a read of a
|
|
15
|
+
* known property and gives undefined. */
|
|
16
|
+
export declare function propRead(e: ts.Node): {
|
|
17
|
+
obj: ts.Expression;
|
|
18
|
+
name: string;
|
|
19
|
+
} | undefined;
|
|
20
|
+
/** The initializer of the NEAREST `const <id> = <init>` visible from `id`, or
|
|
21
|
+
* undefined (a parameter or a `let` in between shadows it, so does a
|
|
22
|
+
* destructure). Exactly one level: the caller never follows an alias of an
|
|
23
|
+
* alias. */
|
|
24
|
+
export declare function constInit(id: ts.Identifier): ts.Expression | undefined;
|
|
25
|
+
/** `e` as a property read, one const alias deep: `x.p`, `x["p"]`, or an
|
|
26
|
+
* identifier declared `const a = x.p` / `const a = x["p"]` (read as `x.p`). */
|
|
27
|
+
export declare function propReadResolved(e: ts.Expression): {
|
|
28
|
+
obj: ts.Expression;
|
|
29
|
+
name: string;
|
|
30
|
+
} | undefined;
|
|
31
|
+
/** Visit every descendant of `n` (pre-order), NOT descending into nested
|
|
32
|
+
* function bodies when `ownOnly` is set. */
|
|
33
|
+
export declare function walk(n: ts.Node, visit: (x: ts.Node) => void, ownOnly?: boolean): void;
|
|
34
|
+
/** `a.b.c` as text, or undefined when the expression is not a dotted chain. */
|
|
35
|
+
export declare function dotted(e: ts.Expression): string | undefined;
|
|
36
|
+
/** The callee's last name: `ctx.db.patch` -> `patch`, `helper` -> `helper`. */
|
|
37
|
+
export declare function calleeName(c: ts.CallExpression): string | undefined;
|
|
38
|
+
export type Builder = "mutation" | "action" | "query" | "internalMutation" | "internalAction" | "internalQuery";
|
|
39
|
+
export interface Registration {
|
|
40
|
+
name: string;
|
|
41
|
+
builder: Builder;
|
|
42
|
+
rel: string;
|
|
43
|
+
line: number;
|
|
44
|
+
/** the handler function (`handler: async (ctx, args) => {…}`). */
|
|
45
|
+
handler: ts.FunctionLikeDeclaration;
|
|
46
|
+
/** arg name -> referenced table, for `v.id("T")` / `v.optional(v.id("T"))`. */
|
|
47
|
+
argIdTable: Map<string, string>;
|
|
48
|
+
}
|
|
49
|
+
export declare function idTableOf(text: string): string | undefined;
|
|
50
|
+
/** The ONE table a validator's text references through `v.id("T")` — through
|
|
51
|
+
* `v.optional`, `v.array`, or a nested `v.object` — or undefined when it names
|
|
52
|
+
* none or several. */
|
|
53
|
+
export declare function singleIdTable(text: string): string | undefined;
|
|
54
|
+
/** Every `export const NAME = <builder>({ … handler … })` of a file. */
|
|
55
|
+
export declare function registrations(p: Parsed): Registration[];
|
|
56
|
+
export interface FnDef {
|
|
57
|
+
name: string;
|
|
58
|
+
rel: string;
|
|
59
|
+
line: number;
|
|
60
|
+
fn: ts.FunctionLikeDeclaration;
|
|
61
|
+
}
|
|
62
|
+
/** Every top-level `function f` / `const f = (…) => …` across the parsed files. */
|
|
63
|
+
export declare function indexFunctions(ps: Parsed[]): Map<string, FnDef[]>;
|
|
64
|
+
/** Names of the plain-identifier functions a body calls. */
|
|
65
|
+
export declare function calledNames(fn: ts.Node): Set<string>;
|
|
66
|
+
/** A per-function boolean property closed over the call graph: `own(fn)` holds,
|
|
67
|
+
* or the function calls (by name) another function for which it holds. */
|
|
68
|
+
export declare function closeOverCalls(idx: Map<string, FnDef[]>, own: (d: FnDef) => boolean): Set<string>;
|
|
69
|
+
/** The tenant columns, as standard 2825122 §4 R-52 lists them:
|
|
70
|
+
* `orgId`/`tenantId`/`workspaceId`/`orgSlug` (vantage-peers stamps its agents
|
|
71
|
+
* under `orgSlug: client_org_mapping.clerkOrgSlug`). A `namespace` is NOT in this
|
|
72
|
+
* set: the standard counts it only when it is "checked against the caller's
|
|
73
|
+
* allowed prefixes", which is a property of the code around the table, not of
|
|
74
|
+
* the schema. `readSchema` records it apart (`SchemaTable.namespace`) and R-52
|
|
75
|
+
* decides it per table (cross-tenant-write.ts `namespaceDecisions`). */
|
|
76
|
+
export declare const TENANT_COLUMNS: ReadonlySet<string>;
|
|
77
|
+
/** The column that is a tenant column only where it is prefix-checked. */
|
|
78
|
+
export declare const NAMESPACE_COLUMN = "namespace";
|
|
79
|
+
export type TenantKind = "required" | "optional" | "none";
|
|
80
|
+
export interface SchemaTable {
|
|
81
|
+
name: string;
|
|
82
|
+
/** from the TENANT_COLUMNS alone. */
|
|
83
|
+
tenant: TenantKind;
|
|
84
|
+
/** whether a `namespace` column exists and is optional — a tenant column
|
|
85
|
+
* only where the code checks it against the caller's allowed prefixes. */
|
|
86
|
+
namespace: TenantKind;
|
|
87
|
+
fields: string[];
|
|
88
|
+
/** field -> the table its `v.id("T")` references. */
|
|
89
|
+
idRef: Map<string, string>;
|
|
90
|
+
}
|
|
91
|
+
/** Read every `name: defineTable({...})` of a schema file. */
|
|
92
|
+
export declare function readSchema(p: Parsed): Map<string, SchemaTable>;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// Shared TypeScript-AST primitives for the R-52 / R-53 source detectors.
|
|
2
|
+
//
|
|
3
|
+
// Both rules read the SAME delivered material — the schema's tenant columns, every
|
|
4
|
+
// public write handler, and the helper functions a handler calls — and both need
|
|
5
|
+
// the parser rather than a line regex: R-52 traces a selector's provenance across
|
|
6
|
+
// statements, R-53 reads the operands of an admission comparison. One parse per
|
|
7
|
+
// file, one function index per tree; nothing here decides a verdict.
|
|
8
|
+
import * as ts from "typescript";
|
|
9
|
+
export function sourceOf(f) {
|
|
10
|
+
return f.lines.map((l) => l.raw).join("\n");
|
|
11
|
+
}
|
|
12
|
+
export function parseFile(f) {
|
|
13
|
+
return {
|
|
14
|
+
rel: f.rel,
|
|
15
|
+
sf: ts.createSourceFile(f.rel, sourceOf(f), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function lineOf(n) {
|
|
19
|
+
const sf = n.getSourceFile();
|
|
20
|
+
return sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1;
|
|
21
|
+
}
|
|
22
|
+
/** Strip `await`, parentheses, `!` non-null, `as`/`satisfies` casts. */
|
|
23
|
+
export function unwrap(e) {
|
|
24
|
+
let x = e;
|
|
25
|
+
for (;;) {
|
|
26
|
+
if (ts.isAwaitExpression(x) ||
|
|
27
|
+
ts.isParenthesizedExpression(x) ||
|
|
28
|
+
ts.isNonNullExpression(x) ||
|
|
29
|
+
ts.isAsExpression(x) ||
|
|
30
|
+
ts.isSatisfiesExpression(x))
|
|
31
|
+
x = x.expression;
|
|
32
|
+
else
|
|
33
|
+
return x;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** A property read: `x.prop`, or `x["prop"]` (a string-literal element access
|
|
37
|
+
* is the same read, spelled differently). A computed key is not a read of a
|
|
38
|
+
* known property and gives undefined. */
|
|
39
|
+
export function propRead(e) {
|
|
40
|
+
if (ts.isPropertyAccessExpression(e))
|
|
41
|
+
return { obj: e.expression, name: e.name.text };
|
|
42
|
+
if (ts.isElementAccessExpression(e) &&
|
|
43
|
+
ts.isStringLiteralLike(e.argumentExpression))
|
|
44
|
+
return { obj: e.expression, name: e.argumentExpression.text };
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
/** The initializer of the NEAREST `const <id> = <init>` visible from `id`, or
|
|
48
|
+
* undefined (a parameter or a `let` in between shadows it, so does a
|
|
49
|
+
* destructure). Exactly one level: the caller never follows an alias of an
|
|
50
|
+
* alias. */
|
|
51
|
+
export function constInit(id) {
|
|
52
|
+
for (let x = id.parent; x; x = x.parent) {
|
|
53
|
+
if (ts.isFunctionLike(x) &&
|
|
54
|
+
x.parameters.some((q) => ts.isIdentifier(q.name) && q.name.text === id.text))
|
|
55
|
+
return undefined;
|
|
56
|
+
const stmts = ts.isBlock(x) || ts.isSourceFile(x) || ts.isModuleBlock(x)
|
|
57
|
+
? x.statements
|
|
58
|
+
: ts.isCaseClause(x) || ts.isDefaultClause(x)
|
|
59
|
+
? x.statements
|
|
60
|
+
: undefined;
|
|
61
|
+
if (!stmts)
|
|
62
|
+
continue;
|
|
63
|
+
for (const st of stmts) {
|
|
64
|
+
if (!ts.isVariableStatement(st))
|
|
65
|
+
continue;
|
|
66
|
+
for (const d of st.declarationList.declarations)
|
|
67
|
+
if (ts.isIdentifier(d.name) && d.name.text === id.text)
|
|
68
|
+
return st.declarationList.flags & ts.NodeFlags.Const
|
|
69
|
+
? d.initializer
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
/** `e` as a property read, one const alias deep: `x.p`, `x["p"]`, or an
|
|
76
|
+
* identifier declared `const a = x.p` / `const a = x["p"]` (read as `x.p`). */
|
|
77
|
+
export function propReadResolved(e) {
|
|
78
|
+
const u = unwrap(e);
|
|
79
|
+
const direct = propRead(u);
|
|
80
|
+
if (direct)
|
|
81
|
+
return direct;
|
|
82
|
+
if (!ts.isIdentifier(u))
|
|
83
|
+
return undefined;
|
|
84
|
+
const init = constInit(u);
|
|
85
|
+
return init ? propRead(unwrap(init)) : undefined;
|
|
86
|
+
}
|
|
87
|
+
/** Visit every descendant of `n` (pre-order), NOT descending into nested
|
|
88
|
+
* function bodies when `ownOnly` is set. */
|
|
89
|
+
export function walk(n, visit, ownOnly = false) {
|
|
90
|
+
const rec = (x) => {
|
|
91
|
+
visit(x);
|
|
92
|
+
if (ownOnly &&
|
|
93
|
+
x !== n &&
|
|
94
|
+
(ts.isFunctionDeclaration(x) ||
|
|
95
|
+
ts.isFunctionExpression(x) ||
|
|
96
|
+
ts.isArrowFunction(x)))
|
|
97
|
+
return;
|
|
98
|
+
ts.forEachChild(x, rec);
|
|
99
|
+
};
|
|
100
|
+
rec(n);
|
|
101
|
+
}
|
|
102
|
+
/** `a.b.c` as text, or undefined when the expression is not a dotted chain. */
|
|
103
|
+
export function dotted(e) {
|
|
104
|
+
const x = unwrap(e);
|
|
105
|
+
if (ts.isIdentifier(x))
|
|
106
|
+
return x.text;
|
|
107
|
+
if (ts.isPropertyAccessExpression(x)) {
|
|
108
|
+
const head = dotted(x.expression);
|
|
109
|
+
return head === undefined ? undefined : `${head}.${x.name.text}`;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
/** The callee's last name: `ctx.db.patch` -> `patch`, `helper` -> `helper`. */
|
|
114
|
+
export function calleeName(c) {
|
|
115
|
+
const e = unwrap(c.expression);
|
|
116
|
+
if (ts.isIdentifier(e))
|
|
117
|
+
return e.text;
|
|
118
|
+
if (ts.isPropertyAccessExpression(e))
|
|
119
|
+
return e.name.text;
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const BUILDERS = new Set([
|
|
123
|
+
"mutation",
|
|
124
|
+
"action",
|
|
125
|
+
"query",
|
|
126
|
+
"internalMutation",
|
|
127
|
+
"internalAction",
|
|
128
|
+
"internalQuery",
|
|
129
|
+
]);
|
|
130
|
+
export function idTableOf(text) {
|
|
131
|
+
const m = /^\s*(?:v\.optional\(\s*)?v\.id\(\s*["'`]([A-Za-z0-9_]+)["'`]\s*\)/.exec(text);
|
|
132
|
+
return m?.[1];
|
|
133
|
+
}
|
|
134
|
+
/** The ONE table a validator's text references through `v.id("T")` — through
|
|
135
|
+
* `v.optional`, `v.array`, or a nested `v.object` — or undefined when it names
|
|
136
|
+
* none or several. */
|
|
137
|
+
export function singleIdTable(text) {
|
|
138
|
+
const ts_ = new Set([...text.matchAll(/v\.id\(\s*["'`]([A-Za-z0-9_]+)["'`]\s*\)/g)].map((m) => m[1]));
|
|
139
|
+
return ts_.size === 1 ? [...ts_][0] : undefined;
|
|
140
|
+
}
|
|
141
|
+
/** The object literal of an `args:` validator: inline, `v.object({…})`, or a
|
|
142
|
+
* same-file top-level const naming either. */
|
|
143
|
+
function argsObject(p, e0) {
|
|
144
|
+
const e = unwrap(e0);
|
|
145
|
+
if (ts.isObjectLiteralExpression(e))
|
|
146
|
+
return e;
|
|
147
|
+
if (ts.isCallExpression(e) &&
|
|
148
|
+
calleeName(e) === "object" &&
|
|
149
|
+
e.arguments[0] &&
|
|
150
|
+
ts.isObjectLiteralExpression(unwrap(e.arguments[0])))
|
|
151
|
+
return unwrap(e.arguments[0]);
|
|
152
|
+
if (ts.isIdentifier(e)) {
|
|
153
|
+
for (const st of p.sf.statements) {
|
|
154
|
+
if (!ts.isVariableStatement(st))
|
|
155
|
+
continue;
|
|
156
|
+
for (const d of st.declarationList.declarations)
|
|
157
|
+
if (ts.isIdentifier(d.name) && d.name.text === e.text && d.initializer)
|
|
158
|
+
return argsObject(p, d.initializer);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
/** Every `export const NAME = <builder>({ … handler … })` of a file. */
|
|
164
|
+
export function registrations(p) {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const st of p.sf.statements) {
|
|
167
|
+
if (!ts.isVariableStatement(st))
|
|
168
|
+
continue;
|
|
169
|
+
for (const d of st.declarationList.declarations) {
|
|
170
|
+
if (!ts.isIdentifier(d.name) || !d.initializer)
|
|
171
|
+
continue;
|
|
172
|
+
const init = unwrap(d.initializer);
|
|
173
|
+
if (!ts.isCallExpression(init))
|
|
174
|
+
continue;
|
|
175
|
+
const b = calleeName(init);
|
|
176
|
+
if (b === undefined || !BUILDERS.has(b))
|
|
177
|
+
continue;
|
|
178
|
+
const cfg = init.arguments[0];
|
|
179
|
+
if (!cfg || !ts.isObjectLiteralExpression(cfg))
|
|
180
|
+
continue;
|
|
181
|
+
let handler;
|
|
182
|
+
const argIdTable = new Map();
|
|
183
|
+
for (const prop of cfg.properties) {
|
|
184
|
+
const key = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : undefined;
|
|
185
|
+
if (key === "handler") {
|
|
186
|
+
if (ts.isMethodDeclaration(prop))
|
|
187
|
+
handler = prop;
|
|
188
|
+
else if (ts.isPropertyAssignment(prop)) {
|
|
189
|
+
const h = unwrap(prop.initializer);
|
|
190
|
+
if (ts.isArrowFunction(h) || ts.isFunctionExpression(h))
|
|
191
|
+
handler = h;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (key === "args" && ts.isPropertyAssignment(prop)) {
|
|
195
|
+
const obj = argsObject(p, prop.initializer);
|
|
196
|
+
for (const a of obj?.properties ?? []) {
|
|
197
|
+
if (ts.isPropertyAssignment(a) && ts.isIdentifier(a.name)) {
|
|
198
|
+
const t = singleIdTable(a.initializer.getText(p.sf));
|
|
199
|
+
if (t)
|
|
200
|
+
argIdTable.set(a.name.text, t);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (handler === undefined)
|
|
206
|
+
continue;
|
|
207
|
+
out.push({
|
|
208
|
+
name: d.name.text,
|
|
209
|
+
builder: b,
|
|
210
|
+
rel: p.rel,
|
|
211
|
+
line: lineOf(d),
|
|
212
|
+
handler,
|
|
213
|
+
argIdTable,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
/** Every top-level `function f` / `const f = (…) => …` across the parsed files. */
|
|
220
|
+
export function indexFunctions(ps) {
|
|
221
|
+
const idx = new Map();
|
|
222
|
+
const add = (d) => {
|
|
223
|
+
const xs = idx.get(d.name) ?? [];
|
|
224
|
+
xs.push(d);
|
|
225
|
+
idx.set(d.name, xs);
|
|
226
|
+
};
|
|
227
|
+
for (const p of ps) {
|
|
228
|
+
for (const st of p.sf.statements) {
|
|
229
|
+
if (ts.isFunctionDeclaration(st) && st.name && st.body)
|
|
230
|
+
add({ name: st.name.text, rel: p.rel, line: lineOf(st), fn: st });
|
|
231
|
+
else if (ts.isVariableStatement(st))
|
|
232
|
+
for (const d of st.declarationList.declarations) {
|
|
233
|
+
const init = d.initializer && unwrap(d.initializer);
|
|
234
|
+
if (ts.isIdentifier(d.name) &&
|
|
235
|
+
init &&
|
|
236
|
+
(ts.isArrowFunction(init) || ts.isFunctionExpression(init)))
|
|
237
|
+
add({ name: d.name.text, rel: p.rel, line: lineOf(d), fn: init });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return idx;
|
|
242
|
+
}
|
|
243
|
+
/** Names of the plain-identifier functions a body calls. */
|
|
244
|
+
export function calledNames(fn) {
|
|
245
|
+
const out = new Set();
|
|
246
|
+
walk(fn, (x) => {
|
|
247
|
+
if (ts.isCallExpression(x)) {
|
|
248
|
+
const e = unwrap(x.expression);
|
|
249
|
+
if (ts.isIdentifier(e))
|
|
250
|
+
out.add(e.text);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
/** A per-function boolean property closed over the call graph: `own(fn)` holds,
|
|
256
|
+
* or the function calls (by name) another function for which it holds. */
|
|
257
|
+
export function closeOverCalls(idx, own) {
|
|
258
|
+
const holds = new Set();
|
|
259
|
+
for (const [n, ds] of idx)
|
|
260
|
+
if (ds.some(own))
|
|
261
|
+
holds.add(n);
|
|
262
|
+
let grew = true;
|
|
263
|
+
while (grew) {
|
|
264
|
+
grew = false;
|
|
265
|
+
for (const [n, ds] of idx) {
|
|
266
|
+
if (holds.has(n))
|
|
267
|
+
continue;
|
|
268
|
+
if (ds.some((d) => [...calledNames(d.fn)].some((c) => holds.has(c)))) {
|
|
269
|
+
holds.add(n);
|
|
270
|
+
grew = true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return holds;
|
|
275
|
+
}
|
|
276
|
+
// ---- schema ---------------------------------------------------------------
|
|
277
|
+
/** The tenant columns, as standard 2825122 §4 R-52 lists them:
|
|
278
|
+
* `orgId`/`tenantId`/`workspaceId`/`orgSlug` (vantage-peers stamps its agents
|
|
279
|
+
* under `orgSlug: client_org_mapping.clerkOrgSlug`). A `namespace` is NOT in this
|
|
280
|
+
* set: the standard counts it only when it is "checked against the caller's
|
|
281
|
+
* allowed prefixes", which is a property of the code around the table, not of
|
|
282
|
+
* the schema. `readSchema` records it apart (`SchemaTable.namespace`) and R-52
|
|
283
|
+
* decides it per table (cross-tenant-write.ts `namespaceDecisions`). */
|
|
284
|
+
export const TENANT_COLUMNS = new Set([
|
|
285
|
+
"orgId",
|
|
286
|
+
"tenantId",
|
|
287
|
+
"workspaceId",
|
|
288
|
+
"orgSlug",
|
|
289
|
+
]);
|
|
290
|
+
/** The column that is a tenant column only where it is prefix-checked. */
|
|
291
|
+
export const NAMESPACE_COLUMN = "namespace";
|
|
292
|
+
/** Read every `name: defineTable({...})` of a schema file. */
|
|
293
|
+
export function readSchema(p) {
|
|
294
|
+
const out = new Map();
|
|
295
|
+
walk(p.sf, (x) => {
|
|
296
|
+
if (!ts.isPropertyAssignment(x) || !ts.isIdentifier(x.name))
|
|
297
|
+
return;
|
|
298
|
+
// the initializer is `defineTable({...})` possibly chained `.index(...)`.
|
|
299
|
+
let e = x.initializer;
|
|
300
|
+
for (;;) {
|
|
301
|
+
const u = unwrap(e);
|
|
302
|
+
if (ts.isCallExpression(u) &&
|
|
303
|
+
ts.isPropertyAccessExpression(u.expression)) {
|
|
304
|
+
e = u.expression.expression;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
e = u;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
if (!ts.isCallExpression(e) ||
|
|
311
|
+
calleeName(e) !== "defineTable" ||
|
|
312
|
+
!e.arguments[0] ||
|
|
313
|
+
!ts.isObjectLiteralExpression(e.arguments[0]))
|
|
314
|
+
return;
|
|
315
|
+
const fields = [];
|
|
316
|
+
const idRef = new Map();
|
|
317
|
+
let tenant = "none";
|
|
318
|
+
let namespace = "none";
|
|
319
|
+
for (const prop of e.arguments[0].properties) {
|
|
320
|
+
if (!ts.isPropertyAssignment(prop))
|
|
321
|
+
continue;
|
|
322
|
+
const k = ts.isIdentifier(prop.name)
|
|
323
|
+
? prop.name.text
|
|
324
|
+
: ts.isStringLiteral(prop.name)
|
|
325
|
+
? prop.name.text
|
|
326
|
+
: undefined;
|
|
327
|
+
if (k === undefined)
|
|
328
|
+
continue;
|
|
329
|
+
fields.push(k);
|
|
330
|
+
const text = prop.initializer.getText(p.sf);
|
|
331
|
+
const t = idTableOf(text);
|
|
332
|
+
if (t)
|
|
333
|
+
idRef.set(k, t);
|
|
334
|
+
const opt = /^\s*v\.optional\(/.test(text);
|
|
335
|
+
if (TENANT_COLUMNS.has(k)) {
|
|
336
|
+
if (!opt)
|
|
337
|
+
tenant = "required";
|
|
338
|
+
else if (tenant === "none")
|
|
339
|
+
tenant = "optional";
|
|
340
|
+
}
|
|
341
|
+
if (k === NAMESPACE_COLUMN)
|
|
342
|
+
namespace = opt ? "optional" : "required";
|
|
343
|
+
}
|
|
344
|
+
out.set(x.name.text, {
|
|
345
|
+
name: x.name.text,
|
|
346
|
+
tenant,
|
|
347
|
+
namespace,
|
|
348
|
+
fields,
|
|
349
|
+
idRef,
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { siteCount, withCount } from "../../violation-count.js";
|
|
1
2
|
/** The standard both wave-2 files are pinned against (short hash a9d7877b). */
|
|
2
3
|
export const SOURCE_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15";
|
|
3
4
|
export function emptyScan() {
|
|
@@ -45,12 +46,12 @@ export function decide(ruleId, id, check, scan, emptyReason) {
|
|
|
45
46
|
// verdict is otherwise the only one printed without an address (Eta REVISE
|
|
46
47
|
// blocker 1: R-51 CRM could-not-judge=1 was counted but never located). The
|
|
47
48
|
// data is already in scan.cnj; list it under its own label whenever non-empty.
|
|
48
|
-
return {
|
|
49
|
+
return withCount({
|
|
49
50
|
id,
|
|
50
51
|
anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
|
|
51
52
|
state: "non-conforming",
|
|
52
|
-
verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}${nJ ? ` could-not-judge sites (
|
|
53
|
-
};
|
|
53
|
+
verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}${nJ ? ` could-not-judge sites (not decided; each names why): ${fmt(scan.cnj)}.` : ""}`,
|
|
54
|
+
}, siteCount(scan.violate));
|
|
54
55
|
}
|
|
55
56
|
if (nJ > 0) {
|
|
56
57
|
const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}`;
|