@vantageos/backend-doctor 0.1.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.
Files changed (108) hide show
  1. package/README.md +88 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +94 -0
  4. package/dist/contract.d.ts +18 -0
  5. package/dist/contract.js +29 -0
  6. package/dist/coverage-cli.d.ts +2 -0
  7. package/dist/coverage-cli.js +17 -0
  8. package/dist/coverage.d.ts +32 -0
  9. package/dist/coverage.js +77 -0
  10. package/dist/detectors/oracle-axis1.d.ts +86 -0
  11. package/dist/detectors/oracle-axis1.js +221 -0
  12. package/dist/detectors/oracle-axis2.d.ts +22 -0
  13. package/dist/detectors/oracle-axis2.js +149 -0
  14. package/dist/detectors/predicates.d.ts +19 -0
  15. package/dist/detectors/predicates.js +334 -0
  16. package/dist/detectors/shared.d.ts +56 -0
  17. package/dist/detectors/shared.js +103 -0
  18. package/dist/detectors/source/_scan.d.ts +43 -0
  19. package/dist/detectors/source/_scan.js +82 -0
  20. package/dist/detectors/source/backfill.d.ts +27 -0
  21. package/dist/detectors/source/backfill.js +211 -0
  22. package/dist/detectors/source/contract.d.ts +33 -0
  23. package/dist/detectors/source/contract.js +385 -0
  24. package/dist/detectors/source/identity.d.ts +47 -0
  25. package/dist/detectors/source/identity.js +410 -0
  26. package/dist/detectors/source/jobs.d.ts +10 -0
  27. package/dist/detectors/source/jobs.js +209 -0
  28. package/dist/detectors/source/lifecycle.d.ts +6 -0
  29. package/dist/detectors/source/lifecycle.js +147 -0
  30. package/dist/detectors/source/readbudget.d.ts +6 -0
  31. package/dist/detectors/source/readbudget.js +133 -0
  32. package/dist/detectors/source/refusal.d.ts +6 -0
  33. package/dist/detectors/source/refusal.js +125 -0
  34. package/dist/detectors/source/security.d.ts +37 -0
  35. package/dist/detectors/source/security.js +392 -0
  36. package/dist/detectors/source/writesafety.d.ts +14 -0
  37. package/dist/detectors/source/writesafety.js +345 -0
  38. package/dist/detectors/standard/_doc.d.ts +66 -0
  39. package/dist/detectors/standard/_doc.js +152 -0
  40. package/dist/detectors/standard/r41-frontier.d.ts +58 -0
  41. package/dist/detectors/standard/r41-frontier.js +176 -0
  42. package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
  43. package/dist/detectors/standard/r46-returns-validator.js +474 -0
  44. package/dist/detectors/standard/standard-doc.d.ts +12 -0
  45. package/dist/detectors/standard/standard-doc.js +271 -0
  46. package/dist/detectors/structural.d.ts +14 -0
  47. package/dist/detectors/structural.js +83 -0
  48. package/dist/engine-types.d.ts +10 -0
  49. package/dist/engine-types.js +1 -0
  50. package/dist/not-checkable.d.ts +22 -0
  51. package/dist/not-checkable.js +37 -0
  52. package/dist/oracle.d.ts +33 -0
  53. package/dist/oracle.js +133 -0
  54. package/dist/registry.d.ts +11 -0
  55. package/dist/registry.js +140 -0
  56. package/dist/report.d.ts +35 -0
  57. package/dist/report.js +138 -0
  58. package/dist/severity.d.ts +30 -0
  59. package/dist/severity.js +121 -0
  60. package/dist/tally.d.ts +57 -0
  61. package/dist/tally.js +152 -0
  62. package/dist/trigger.d.ts +47 -0
  63. package/dist/trigger.js +143 -0
  64. package/fixtures/backend-standard-51ec7af1.md +648 -0
  65. package/fixtures/backend-standard-a9d7877b.md +629 -0
  66. package/fixtures/mcp-app-standard-a74a042b.md +279 -0
  67. package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
  68. package/fixtures/mcp-server-standard-a74a042b.md +434 -0
  69. package/fixtures/mcp-standard-a74a042b.md +354 -0
  70. package/fixtures/vp-1200-green/.pin +1 -0
  71. package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
  72. package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
  73. package/fixtures/vp-1200-red/.pin +1 -0
  74. package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
  75. package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
  76. package/fixtures/vp-by-tool-day158.csv +190 -0
  77. package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
  78. package/fixtures/vp-convex/.pin +1 -0
  79. package/fixtures/vp-convex/bun.lock +783 -0
  80. package/fixtures/vp-convex/convex/auth.config.ts +8 -0
  81. package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
  82. package/fixtures/vp-convex/convex/convex.config.ts +14 -0
  83. package/fixtures/vp-convex/convex/credentials.ts +591 -0
  84. package/fixtures/vp-convex/convex/crons.ts +72 -0
  85. package/fixtures/vp-convex/convex/dashboard.ts +199 -0
  86. package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
  87. package/fixtures/vp-convex/convex/issues.ts +544 -0
  88. package/fixtures/vp-convex/convex/kb.ts +305 -0
  89. package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
  90. package/fixtures/vp-convex/convex/kbShared.ts +56 -0
  91. package/fixtures/vp-convex/convex/licenses.ts +443 -0
  92. package/fixtures/vp-convex/convex/memories.ts +406 -0
  93. package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
  94. package/fixtures/vp-convex/convex/messages.ts +971 -0
  95. package/fixtures/vp-convex/convex/migrations.ts +239 -0
  96. package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
  97. package/fixtures/vp-convex/convex/oauth.ts +1608 -0
  98. package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
  99. package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
  100. package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
  101. package/fixtures/vp-convex/convex/schema.ts +1229 -0
  102. package/fixtures/vp-convex/convex/tasks.ts +2782 -0
  103. package/fixtures/vp-r31-green/.pin +1 -0
  104. package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
  105. package/fixtures/vp-r31-red/.pin +1 -0
  106. package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
  107. package/fixtures/vr-by-tool-day159.csv +89 -0
  108. package/package.json +39 -0
@@ -0,0 +1,147 @@
1
+ import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
+ // An ADMIN-PROVISIONABLE resource: a config/template row an admin creates —
3
+ // profiles, clients, tenants, templates, mappings, filter rules, allowlists.
4
+ // Deliberately NOT org/user data (tasks, messages, memories, licenses) and NOT
5
+ // issued credentials (tokens, codes) which have their own issue/expire lifecycle.
6
+ const ADMIN_TABLE = /profile|client|tenant|template|mapping|filterrule|allowlist/i;
7
+ const INSERT_TABLE = /ctx\.db\.insert\s*\(\s*["'`]([A-Za-z0-9_]+)["'`]/;
8
+ const QUERY_TABLE = /\.query\s*\(\s*["'`]([A-Za-z0-9_]+)["'`]/;
9
+ const DB_DELETE = /ctx\.db\.delete\s*\(/;
10
+ // a soft-delete / GC by tombstone patch — the revoke/deleted-at convention.
11
+ const SOFT_DELETE = /revokedat|deletedat/i;
12
+ // a function whose NAME declares it removes a resource.
13
+ const DELETE_NAMED = /delete|remove|purge|\bgc\b/i;
14
+ const EXPORT_FN = /export\s+const\s+(\w+)\s*=\s*(?:internal)?(?:mutation|action)\s*\(/;
15
+ const NEXT_EXPORT = /export\s+const\s+\w+\s*=/;
16
+ // the rule's second limb — a written out-of-scope reason.
17
+ const ALLOW_OPT_OUT = /allow-no-delete-path/i;
18
+ /**
19
+ * Build the set of tables that have a delete/GC path. Association is scoped to a
20
+ * function so a delete on a NEIGHBOUR table never resolves a resource the
21
+ * function merely reads:
22
+ * • a DELETE-NAMED function (deleteClient, softDelete) that also deletes or
23
+ * tombstones associates its FIRST queried table — the subject it removes;
24
+ * • an UNNAMED function that calls ctx.db.delete associates a table only when
25
+ * it queries EXACTLY ONE distinct table (an unambiguous single-subject
26
+ * delete, e.g. consumeAuthorizationCode). A multi-table function
27
+ * (patchScopeProfileEmergency: reads the profile, deletes tokens) resolves
28
+ * nothing, because which table it deletes is not statically unambiguous.
29
+ */
30
+ function deletePaths(files) {
31
+ const paths = new Map();
32
+ for (const f of files) {
33
+ if (!isJudgeable(f))
34
+ continue;
35
+ let fnName = "";
36
+ let openLine = 0;
37
+ let queried = [];
38
+ let hasDelete = false;
39
+ let softDelete = false;
40
+ const flush = () => {
41
+ if (!fnName)
42
+ return;
43
+ const distinct = [...new Set(queried)];
44
+ const nameDelete = DELETE_NAMED.test(fnName);
45
+ let subject;
46
+ if (nameDelete && (hasDelete || softDelete)) {
47
+ subject = distinct[0];
48
+ }
49
+ else if (hasDelete && distinct.length === 1) {
50
+ subject = distinct[0];
51
+ }
52
+ if (subject && !paths.has(subject)) {
53
+ paths.set(subject, {
54
+ rel: f.rel,
55
+ n: openLine,
56
+ note: `${fnName} removes ${subject} (${hasDelete ? "ctx.db.delete" : "tombstone patch"})`,
57
+ });
58
+ }
59
+ };
60
+ for (const l of f.lines) {
61
+ if (!code(l))
62
+ continue;
63
+ const open = EXPORT_FN.exec(l.raw);
64
+ if (open) {
65
+ flush();
66
+ fnName = open[1];
67
+ openLine = l.n;
68
+ queried = [];
69
+ hasDelete = false;
70
+ softDelete = false;
71
+ continue;
72
+ }
73
+ if (fnName && NEXT_EXPORT.test(l.raw) && !EXPORT_FN.test(l.raw)) {
74
+ flush();
75
+ fnName = "";
76
+ continue;
77
+ }
78
+ if (!fnName)
79
+ continue;
80
+ const q = QUERY_TABLE.exec(l.raw);
81
+ if (q)
82
+ queried.push(q[1]);
83
+ if (DB_DELETE.test(l.raw))
84
+ hasDelete = true;
85
+ if (SOFT_DELETE.test(l.raw))
86
+ softDelete = true;
87
+ }
88
+ flush();
89
+ }
90
+ return paths;
91
+ }
92
+ /** Every admin-provisionable create/upsert site (first per table), and the set
93
+ * of files carrying a written out-of-scope reason. */
94
+ function adminCreates(files) {
95
+ const creates = new Map();
96
+ const optOutFiles = new Set();
97
+ for (const f of files) {
98
+ if (!isJudgeable(f))
99
+ continue;
100
+ for (const l of f.lines) {
101
+ if (ALLOW_OPT_OUT.test(l.raw))
102
+ optOutFiles.add(f.rel);
103
+ if (!code(l))
104
+ continue;
105
+ const m = INSERT_TABLE.exec(l.raw);
106
+ if (m && ADMIN_TABLE.test(m[1]) && !creates.has(m[1])) {
107
+ creates.set(m[1], { table: m[1], rel: f.rel, n: l.n });
108
+ }
109
+ }
110
+ }
111
+ return { creates, optOutFiles };
112
+ }
113
+ export function scanR43(files) {
114
+ const scan = emptyScan();
115
+ const dels = deletePaths(files);
116
+ const { creates, optOutFiles } = adminCreates(files);
117
+ for (const c of creates.values()) {
118
+ const del = dels.get(c.table);
119
+ if (del) {
120
+ scan.conform.push({
121
+ rel: c.rel,
122
+ n: c.n,
123
+ note: `admin resource ${c.table} is create/upsert-provisioned AND has a delete/GC path (${del.rel}:${del.n})`,
124
+ });
125
+ }
126
+ else if (optOutFiles.has(c.rel)) {
127
+ scan.conform.push({
128
+ rel: c.rel,
129
+ n: c.n,
130
+ note: `admin resource ${c.table} has no delete/GC path but carries a written out-of-scope reason (allow-no-delete-path) in ${c.rel}`,
131
+ });
132
+ }
133
+ else {
134
+ scan.cnj.push({
135
+ rel: c.rel,
136
+ n: c.n,
137
+ note: `admin resource ${c.table} is create/upsert-provisioned but exposes NO delete/GC path and carries no written out-of-scope reason — admin-resource lifecycle asymmetry (a resource you can create but never remove, orphaning any dependents)`,
138
+ });
139
+ }
140
+ }
141
+ return scan;
142
+ }
143
+ export const R43 = srcDetector("R-43", "R-43 an admin-provisionable resource exposes a delete/GC path or a written out-of-scope reason", "every admin create/upsert-provisioned resource has a delete/GC path (master-gated) or a written out-of-scope reason", scanR43, "R-43: no admin-provisionable resource (create/upsert on a config/profile/client/tenant table) matched in the scanned tree — cannot certify lifecycle symmetry. NOT a clean bill.");
144
+ /** In the form the orchestrator spreads into REGISTRY. */
145
+ export const SOURCE_LIFECYCLE_DETECTORS = [
146
+ { detector: R43, ruleId: "R-43", standardPin: SOURCE_STANDARD_PIN },
147
+ ];
@@ -0,0 +1,6 @@
1
+ import type { Detector } from "../../engine-types.js";
2
+ import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ export declare function scanR31(files: SrcFile[]): Scan;
4
+ export declare const R31: Detector;
5
+ /** In the form the orchestrator spreads into REGISTRY. */
6
+ export declare const SOURCE_READBUDGET_DETECTORS: SourceDetectorEntry[];
@@ -0,0 +1,133 @@
1
+ import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
+ // A mutation opener — the function class R-31 governs (a write-capable ctx).
3
+ const MUTATION_OPEN = /\b(?:internalMutation|mutation)\s*\(/;
4
+ // An UNBOUNDED terminal read — loads every matching row (and, per R-4's field
5
+ // axis, every field). `.collect()` is unbounded by construction.
6
+ const COLLECT = /\.collect\s*\(\s*\)/;
7
+ // A BOUNDED read — a page or a capped take, the shape R-31 requires.
8
+ const BOUNDED = /\.(?:take|paginate)\s*\(/;
9
+ // the table a query names.
10
+ const QUERY_TABLE = /\.query\s*\(\s*["'`]([A-Za-z0-9_]+)["'`]/;
11
+ // the self-reschedule that continues a bulk drain across transactions.
12
+ const RESCHEDULE = /ctx\.scheduler\.runAfter\s*\(/;
13
+ // the exported function name the mutation opener belongs to — `export const NAME =`.
14
+ const EXPORT_NAME = /export\s+const\s+([A-Za-z0-9_]+)\s*=/;
15
+ /** Group a block's CODE lines into logical statements (joined until a `;`), each
16
+ * keeping its start line. Robust to the multi-line `.query(...).collect()` chain
17
+ * the real migration uses. */
18
+ function statements(lines) {
19
+ const out = [];
20
+ let buf = "";
21
+ let start = 0;
22
+ for (const l of lines) {
23
+ if (!code(l))
24
+ continue;
25
+ if (buf === "")
26
+ start = l.n;
27
+ buf += (buf ? " " : "") + l.raw.trim();
28
+ if (l.raw.includes(";")) {
29
+ out.push({ n: start, text: buf });
30
+ buf = "";
31
+ }
32
+ }
33
+ if (buf)
34
+ out.push({ n: start, text: buf });
35
+ return out;
36
+ }
37
+ /** From the mutation opener at index `i`, delimit its body by matching braces.
38
+ * Returns the block's lines and whether it closed cleanly. */
39
+ function mutationBlock(lines, i) {
40
+ let depth = 0;
41
+ let seenOpen = false;
42
+ const body = [];
43
+ for (let j = i; j < lines.length; j++) {
44
+ const l = lines[j];
45
+ body.push(l);
46
+ for (const ch of l.raw) {
47
+ if (ch === "{") {
48
+ depth += 1;
49
+ seenOpen = true;
50
+ }
51
+ else if (ch === "}") {
52
+ depth -= 1;
53
+ }
54
+ }
55
+ if (seenOpen && depth <= 0)
56
+ return { lines: body, unterminated: false };
57
+ }
58
+ return { lines: body, unterminated: seenOpen };
59
+ }
60
+ export function scanR31(files) {
61
+ const scan = emptyScan();
62
+ for (const f of files) {
63
+ if (!isJudgeable(f))
64
+ continue;
65
+ const lines = f.lines;
66
+ for (let i = 0; i < lines.length; i++) {
67
+ if (!code(lines[i]))
68
+ continue;
69
+ if (!MUTATION_OPEN.test(lines[i].raw))
70
+ continue;
71
+ // the exported function name this mutation belongs to — on the opener line
72
+ // or the line just above it (`export const NAME = internalMutation({`).
73
+ const fn = EXPORT_NAME.exec(lines[i].raw)?.[1] ??
74
+ (i > 0 ? EXPORT_NAME.exec(lines[i - 1].raw)?.[1] : undefined) ??
75
+ "<anonymous>";
76
+ const block = mutationBlock(lines, i);
77
+ // advance the outer cursor past this block so nested scans don't double-open.
78
+ i += block.lines.length - 1;
79
+ if (block.unterminated) {
80
+ scan.cnj.push({
81
+ rel: f.rel,
82
+ n: lines[Math.min(i, lines.length - 1)].n,
83
+ note: "a mutation block could not be delimited (unbalanced braces) — its read budget is not statically decidable; a human must judge whether its reads are bounded (never a silent green)",
84
+ });
85
+ continue;
86
+ }
87
+ const stmts = statements(block.lines);
88
+ const blockText = block.lines
89
+ .filter(code)
90
+ .map((l) => l.raw)
91
+ .join("\n");
92
+ const reschedules = RESCHEDULE.test(blockText);
93
+ for (const st of stmts) {
94
+ if (COLLECT.test(st.text) && !BOUNDED.test(st.text)) {
95
+ // An UNBOUNDED read inside a mutation. It aborts on the read cap
96
+ // whether or not the loop writes — the read budget binds
97
+ // INDEPENDENTLY of the write budget (refined R-31). Non-conforming.
98
+ const table = QUERY_TABLE.exec(st.text)?.[1] ?? "<unknown table>";
99
+ scan.violate.push({
100
+ rel: f.rel,
101
+ n: st.n,
102
+ note: `${fn}: unbounded read \`.query("${table}").collect()\` inside a mutation — loads every row (and every field, R-4) in ONE transaction; exceeds the per-execution read-byte cap independently of the write budget (an unbounded read that writes NOTHING still aborts on the read cap). Bound it with \`.take(n)\`/\`.paginate\` + \`ctx.scheduler.runAfter(0, self)\``,
103
+ });
104
+ }
105
+ else if (BOUNDED.test(st.text)) {
106
+ // A bounded read. Conforming only when the mutation also
107
+ // self-reschedules (the read side pages exactly as the write side).
108
+ const table = QUERY_TABLE.exec(st.text)?.[1] ?? "<unknown table>";
109
+ if (reschedules) {
110
+ scan.conform.push({
111
+ rel: f.rel,
112
+ n: st.n,
113
+ note: `${fn}: bounded read on "${table}" via \`.take(n)\`/\`.paginate\` with a \`ctx.scheduler.runAfter\` self-reschedule — the read stays within the per-transaction read budget across the drain (R-31 conforming)`,
114
+ });
115
+ }
116
+ else {
117
+ scan.cnj.push({
118
+ rel: f.rel,
119
+ n: st.n,
120
+ note: `${fn}: bounded read on "${table}" via \`.take(n)\`/\`.paginate\` but no \`ctx.scheduler.runAfter\` self-reschedule visible in the mutation — whether one page covers the whole set or the drain is continued elsewhere is not statically decidable (human verifies)`,
121
+ });
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ return scan;
128
+ }
129
+ export const R31 = srcDetector("R-31", "R-31 a bulk operation over an unbounded set batches BOTH its reads and its writes (`.take(n)`/`.paginate` + `ctx.scheduler.runAfter(0, self)`); the read budget and the write budget are distinct and both bind", "every mutation over an unbounded set bounds its reads (never a bare `.collect()`) and self-reschedules — an unbounded read aborts on the read cap independently of whether it writes", scanR31, "R-31: no mutation with a database read matched in the scanned tree — no bulk-operation read budget to certify. NOT a clean bill.");
130
+ /** In the form the orchestrator spreads into REGISTRY. */
131
+ export const SOURCE_READBUDGET_DETECTORS = [
132
+ { detector: R31, ruleId: "R-31", standardPin: SOURCE_STANDARD_PIN },
133
+ ];
@@ -0,0 +1,6 @@
1
+ import type { Detector } from "../../engine-types.js";
2
+ import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ export declare function scanR47(files: SrcFile[]): Scan;
4
+ export declare const R47: Detector;
5
+ /** In the form the orchestrator spreads into REGISTRY. */
6
+ export declare const SOURCE_REFUSAL_DETECTORS: SourceDetectorEntry[];
@@ -0,0 +1,125 @@
1
+ import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
+ // a read-only query opener — the function class R-47 governs.
3
+ const QUERY_OPEN = /\b(?:internalQuery|query)\s*\(/;
4
+ // an UNBOUNDED terminal read.
5
+ const COLLECT = /\.collect\s*\(\s*\)/;
6
+ // a BOUNDED read — a page or a capped take (out of R-47's wide-scan scope).
7
+ const BOUNDED = /\.(?:take|paginate|first|unique)\s*\(/;
8
+ // an index-narrowing predicate — a wide range is one WITHOUT this.
9
+ const INDEX_NARROW = /\.withIndex\s*\(/;
10
+ const QUERY_TABLE = /\.query\s*\(\s*["'`]([A-Za-z0-9_]+)["'`]/;
11
+ const EXPORT_NAME = /export\s+const\s+([A-Za-z0-9_]+)\s*=/;
12
+ // the REFUSAL contract — the correct behaviour R-47 requires. A throw naming an
13
+ // incomplete/uncertain scan, or the SCAN_CAP token the incident used, or prose
14
+ // that the result would be indistinguishable from a complete one.
15
+ const REFUSAL = /SCAN_CAP|scan cap|incomplete|indistinguishable|would (?:be )?(?:narrow|truncat)|narrow(?:ing)? (?:the )?(?:scan|args|arguments)|refus/i;
16
+ /** group a block's CODE lines into `;`-terminated statements, each with a start line. */
17
+ function statements(lines) {
18
+ const out = [];
19
+ let buf = "";
20
+ let start = 0;
21
+ for (const l of lines) {
22
+ if (!code(l))
23
+ continue;
24
+ if (buf === "")
25
+ start = l.n;
26
+ buf += (buf ? " " : "") + l.raw.trim();
27
+ if (l.raw.includes(";")) {
28
+ out.push({ n: start, text: buf });
29
+ buf = "";
30
+ }
31
+ }
32
+ if (buf)
33
+ out.push({ n: start, text: buf });
34
+ return out;
35
+ }
36
+ /** delimit a query body from its opener at index i by matching braces. */
37
+ function queryBlock(lines, i) {
38
+ let depth = 0;
39
+ let seenOpen = false;
40
+ const body = [];
41
+ for (let j = i; j < lines.length; j++) {
42
+ const l = lines[j];
43
+ body.push(l);
44
+ for (const ch of l.raw) {
45
+ if (ch === "{") {
46
+ depth += 1;
47
+ seenOpen = true;
48
+ }
49
+ else if (ch === "}") {
50
+ depth -= 1;
51
+ }
52
+ }
53
+ if (seenOpen && depth <= 0)
54
+ return { lines: body, unterminated: false };
55
+ }
56
+ return { lines: body, unterminated: seenOpen };
57
+ }
58
+ export function scanR47(files) {
59
+ const scan = emptyScan();
60
+ for (const f of files) {
61
+ if (!isJudgeable(f))
62
+ continue;
63
+ const lines = f.lines;
64
+ for (let i = 0; i < lines.length; i++) {
65
+ if (!code(lines[i]))
66
+ continue;
67
+ if (!QUERY_OPEN.test(lines[i].raw))
68
+ continue;
69
+ // exclude a mutation opener that also matches "query(" as a substring only —
70
+ // QUERY_OPEN's word boundary already handles internalQuery/query.
71
+ const fn = EXPORT_NAME.exec(lines[i].raw)?.[1] ??
72
+ (i > 0 ? EXPORT_NAME.exec(lines[i - 1].raw)?.[1] : undefined) ??
73
+ "<anonymous>";
74
+ const block = queryBlock(lines, i);
75
+ i += block.lines.length - 1;
76
+ if (block.unterminated) {
77
+ scan.cnj.push({
78
+ rel: f.rel,
79
+ n: lines[Math.min(i, lines.length - 1)].n,
80
+ note: `${fn}: a query block could not be delimited (unbalanced braces) — whether its scan is refusal-guarded is not statically decidable; a human must judge (never a silent green)`,
81
+ });
82
+ continue;
83
+ }
84
+ const blockText = block.lines
85
+ .filter(code)
86
+ .map((l) => l.raw)
87
+ .join("\n");
88
+ const hasRefusal = REFUSAL.test(blockText);
89
+ for (const st of statements(block.lines)) {
90
+ const isUnbounded = COLLECT.test(st.text) && !BOUNDED.test(st.text);
91
+ if (!isUnbounded)
92
+ continue;
93
+ const table = QUERY_TABLE.exec(st.text)?.[1] ?? "<unknown table>";
94
+ const indexNarrowed = INDEX_NARROW.test(st.text);
95
+ if (indexNarrowed) {
96
+ scan.conform.push({
97
+ rel: f.rel,
98
+ n: st.n,
99
+ note: `${fn}: unbounded \`.collect()\` on "${table}" is index-narrowed (\`.withIndex\`) — a bounded range, not the wide scan R-47 governs`,
100
+ });
101
+ }
102
+ else if (hasRefusal) {
103
+ scan.conform.push({
104
+ rel: f.rel,
105
+ n: st.n,
106
+ note: `${fn}: wide non-index-bounded scan on "${table}" but the handler carries a REFUSAL path (R-16 error contract naming the narrowing args) — the correct R-47 behaviour (the tasks.list SCAN_CAP_EXCEEDED shape)`,
107
+ });
108
+ }
109
+ else {
110
+ scan.cnj.push({
111
+ rel: f.rel,
112
+ n: st.n,
113
+ note: `${fn}: wide, non-index-bounded \`.query("${table}").collect()\` in a query with NO refusal path — the read cap can truncate it BEFORE any filter runs, returning a fraction indistinguishable from a full match. The human half is owed: refuse (R-16), add a tighter index, or declare a bound (R-30)? Never a silent green`,
114
+ });
115
+ }
116
+ }
117
+ }
118
+ }
119
+ return scan;
120
+ }
121
+ export const R47 = srcDetector("R-47", "R-47 an incompletable read refuses, naming what would narrow it, never returns a partial set indistinguishable from a complete one", "a wide, non-index-bounded query scan either refuses (R-16 error contract naming the narrowing args) or is bounded/index-narrowed — it never silently returns a truncated fraction", scanR47, "R-47: no unbounded query read matched in the scanned tree — no incompletable scan to certify. NOT a clean bill.");
122
+ /** In the form the orchestrator spreads into REGISTRY. */
123
+ export const SOURCE_REFUSAL_DETECTORS = [
124
+ { detector: R47, ruleId: "R-47", standardPin: SOURCE_STANDARD_PIN },
125
+ ];
@@ -0,0 +1,37 @@
1
+ import type { Detector, Tree } from "../../engine-types.js";
2
+ /** The standard all six are pinned against (short hash a9d7877b). */
3
+ export declare const SOURCE_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) \u00A715";
4
+ type SrcFile = Tree["files"][number];
5
+ /** A registrable entry — structurally the RegistryEntry the orchestrator spreads
6
+ * into REGISTRY. We do NOT import registry.ts; we mirror its shape so the
7
+ * orchestrator owns the integration. */
8
+ export interface SourceDetectorEntry {
9
+ detector: Detector;
10
+ ruleId: string;
11
+ standardPin: string;
12
+ }
13
+ interface Site {
14
+ rel: string;
15
+ n: number;
16
+ note: string;
17
+ }
18
+ interface Scan {
19
+ conform: Site[];
20
+ violate: Site[];
21
+ cnj: Site[];
22
+ }
23
+ export declare function scanR21(files: SrcFile[]): Scan;
24
+ export declare function scanR22(files: SrcFile[]): Scan;
25
+ export declare function scanR33(files: SrcFile[]): Scan;
26
+ export declare function scanR34(files: SrcFile[]): Scan;
27
+ export declare function scanR35(files: SrcFile[]): Scan;
28
+ export declare function evalR36(root: string): Scan;
29
+ export declare const R21: Detector;
30
+ export declare const R22: Detector;
31
+ export declare const R33: Detector;
32
+ export declare const R34: Detector;
33
+ export declare const R35: Detector;
34
+ export declare const R36: Detector;
35
+ /** The six, in a form the orchestrator spreads into REGISTRY. */
36
+ export declare const SOURCE_SECURITY_DETECTORS: SourceDetectorEntry[];
37
+ export {};