@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,211 @@
1
+ import { base, code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
+ // A schema definition file — where defineTable declarations live.
3
+ const SCHEMA_FILE = /schema\.ts$/;
4
+ // A migration module — a backfill lives here (convex/migrations.ts or a
5
+ // convex/migrations/ folder).
6
+ const MIGRATION_FILE = /migration/i;
7
+ const TABLE_OPEN = /^\s*([A-Za-z0-9_]+)\s*:\s*defineTable\s*\(/;
8
+ // a field line inside a defineTable block: `name: v.something(`.
9
+ const FIELD_LINE = /^\s*[A-Za-z0-9_]+\s*:\s*v\./;
10
+ // a foreign-key reference to ANOTHER table.
11
+ const ID_REF = /v\.id\s*\(\s*["'`]([A-Za-z0-9_]+)["'`]\s*\)/;
12
+ // any index-bearing method — the side-table signal.
13
+ const HAS_INDEX = /\.(?:index|searchIndex|vectorIndex)\s*\(/;
14
+ // a direct insert of a named table.
15
+ const INSERT_OF = (table) => new RegExp(`ctx\\.db\\.insert\\s*\\(\\s*["'\`]${table}["'\`]`);
16
+ // a query/paginate of a named table (loops the source rows).
17
+ const QUERY_OF = (table) => new RegExp(`\\.(?:query|paginate)\\s*\\(\\s*["'\`]${table}["'\`]`);
18
+ // a create-through-the-write-path reference in a test — a `createX` mutation the
19
+ // fixture drives (directly or via `t.mutation(api.mod.createX, ...)`), the write
20
+ // path that populates the derived structure as a side effect. Requires an
21
+ // upper-case letter after `create` so `createdAt`/`created` never match.
22
+ const CREATE_CALL = /\bcreate[A-Z][A-Za-z0-9_]*/;
23
+ /** a test / __tests__ file — R-45's material (the opposite selection of
24
+ * isJudgeable, which EXCLUDES tests). */
25
+ function isTest(f) {
26
+ return base(f.rel).endsWith(".test.ts") || f.rel.includes("__tests__");
27
+ }
28
+ /**
29
+ * Parse schema.ts defineTable blocks and select the DERIVED STRUCTURES: a thin
30
+ * side/junction table (≤4 fields) that carries a foreign-key v.id("<other>")
31
+ * AND is index-bearing. This is the junction / index-bearing side-table class
32
+ * R-44 names — deliberately NOT a rich domain table (tasks, missions) which
33
+ * carries many fields, and NOT a table that is its own FK target.
34
+ */
35
+ export function derivedStructures(files) {
36
+ const out = [];
37
+ for (const f of files) {
38
+ if (!isJudgeable(f))
39
+ continue;
40
+ if (!SCHEMA_FILE.test(f.rel))
41
+ continue;
42
+ let name = "";
43
+ let openLine = 0;
44
+ let fields = 0;
45
+ let fkTarget = "";
46
+ let hasIndex = false;
47
+ const flush = () => {
48
+ if (!name)
49
+ return;
50
+ // a derived structure: thin, foreign-keyed, index-bearing, and its FK
51
+ // points at ANOTHER table (not itself).
52
+ if (fields > 0 &&
53
+ fields <= 4 &&
54
+ fkTarget &&
55
+ fkTarget !== name &&
56
+ hasIndex) {
57
+ out.push({ table: name, source: fkTarget, rel: f.rel, n: openLine });
58
+ }
59
+ };
60
+ for (const l of f.lines) {
61
+ const open = TABLE_OPEN.exec(l.raw);
62
+ if (open) {
63
+ flush();
64
+ name = open[1];
65
+ openLine = l.n;
66
+ fields = 0;
67
+ fkTarget = "";
68
+ hasIndex = false;
69
+ continue;
70
+ }
71
+ if (!name)
72
+ continue;
73
+ if (FIELD_LINE.test(l.raw))
74
+ fields += 1;
75
+ const id = ID_REF.exec(l.raw);
76
+ if (id && !fkTarget)
77
+ fkTarget = id[1];
78
+ if (HAS_INDEX.test(l.raw))
79
+ hasIndex = true;
80
+ }
81
+ flush();
82
+ }
83
+ return out;
84
+ }
85
+ // ===========================================================================
86
+ // R-44 — a derived structure carries a same-delivery idempotent backfill.
87
+ // ===========================================================================
88
+ export function scanR44(files) {
89
+ const scan = emptyScan();
90
+ const derived = derivedStructures(files);
91
+ const migFiles = files.filter((f) => isJudgeable(f) && MIGRATION_FILE.test(f.rel));
92
+ for (const d of derived) {
93
+ const insertRe = INSERT_OF(d.table);
94
+ const queryRe = QUERY_OF(d.source);
95
+ const stem = d.table.toLowerCase();
96
+ let directInsert = false;
97
+ let loopsSource = false;
98
+ let referenced = false;
99
+ for (const f of migFiles) {
100
+ for (const l of f.lines) {
101
+ if (!code(l))
102
+ continue;
103
+ if (insertRe.test(l.raw))
104
+ directInsert = true;
105
+ if (queryRe.test(l.raw))
106
+ loopsSource = true;
107
+ // a backfill function / identifier naming the derived table (the
108
+ // write may be via a helper, e.g. syncParticipantIndex).
109
+ if (l.raw.toLowerCase().includes(stem))
110
+ referenced = true;
111
+ }
112
+ }
113
+ if (directInsert && loopsSource) {
114
+ scan.conform.push({
115
+ rel: d.rel,
116
+ n: d.n,
117
+ note: `derived structure ${d.table} has a backfill migration that loops the source table ${d.source} and inserts ${d.table} directly — populates existing rows`,
118
+ });
119
+ }
120
+ else if (directInsert) {
121
+ scan.cnj.push({
122
+ rel: d.rel,
123
+ n: d.n,
124
+ note: `derived structure ${d.table} has a migration inserting it, but no query/paginate of the source table ${d.source} is visible — coverage of existing source rows is not statically decidable (name the migration site)`,
125
+ });
126
+ }
127
+ else if (referenced) {
128
+ scan.cnj.push({
129
+ rel: d.rel,
130
+ n: d.n,
131
+ note: `derived structure ${d.table} has a backfill migration NAMED for it, but it writes the structure via a helper (no direct ctx.db.insert("${d.table}") in the migration) — its idempotence/coverage of existing source rows is not statically decidable (human verifies)`,
132
+ });
133
+ }
134
+ else {
135
+ scan.violate.push({
136
+ rel: d.rel,
137
+ n: d.n,
138
+ note: `derived structure ${d.table} (computed from ${d.source}) has NO backfill migration — it is populated only by the write path, so it is inert for every row that predates that path (R-44 inert-fix defect)`,
139
+ });
140
+ }
141
+ }
142
+ return scan;
143
+ }
144
+ export const R44 = srcDetector("R-44", "R-44 a derived structure carries a same-delivery idempotent migration that backfills it from existing source rows", "every derived structure (junction / index-bearing side table) has a backfill migration that populates it from the existing source rows", scanR44, "R-44: no derived structure (a thin, foreign-keyed, index-bearing side/junction table) matched in the scanned tree — cannot certify backfill coverage. NOT a clean bill.");
145
+ // ===========================================================================
146
+ // R-45 — a derived structure carries a test whose fixture bypasses the write
147
+ // path and brackets the migration.
148
+ // ===========================================================================
149
+ export function scanR45(files) {
150
+ const scan = emptyScan();
151
+ const derived = derivedStructures(files);
152
+ const testFiles = files.filter(isTest);
153
+ for (const d of derived) {
154
+ const insertSourceRe = INSERT_OF(d.source);
155
+ const stem = d.table.toLowerCase();
156
+ const srcStem = d.source.toLowerCase();
157
+ let directSourceInsert = false;
158
+ let referencesSubject = false;
159
+ let hasCreateFixture = false;
160
+ for (const f of testFiles) {
161
+ for (const l of f.lines) {
162
+ if (!code(l))
163
+ continue;
164
+ const low = l.raw.toLowerCase();
165
+ if (low.includes(stem) || low.includes(srcStem))
166
+ referencesSubject = true;
167
+ if (insertSourceRe.test(l.raw))
168
+ directSourceInsert = true;
169
+ if (CREATE_CALL.test(l.raw))
170
+ hasCreateFixture = true;
171
+ }
172
+ }
173
+ if (directSourceInsert) {
174
+ // The decidable half of R-45 — a fixture constructs the source row WITHOUT
175
+ // the write path (a direct ctx.db.insert of the source table). Whether it
176
+ // brackets the migration before AND after is the human half; the fixture
177
+ // independence R-45's static half owns is present.
178
+ scan.conform.push({
179
+ rel: d.rel,
180
+ n: d.n,
181
+ note: `derived structure ${d.table}: a test constructs the source row ${d.source} via a direct ctx.db.insert (bypassing the write path) — the fixture-independence R-45 requires is present`,
182
+ });
183
+ }
184
+ else if (referencesSubject && hasCreateFixture) {
185
+ // The PR #1200 defect exactly: the subject is tested, but ONLY through the
186
+ // write path (create), so the suite is structurally incapable of failing
187
+ // on the pre-migration state.
188
+ scan.violate.push({
189
+ rel: d.rel,
190
+ n: d.n,
191
+ note: `derived structure ${d.table} is exercised by tests, but EVERY fixture is built through the write path (create) — no direct ctx.db.insert("${d.source}") bypass, so the suite cannot fail on the pre-migration (inert) state (R-45 defect)`,
192
+ });
193
+ }
194
+ else {
195
+ // No test reaches the subject at all — the test-independence property
196
+ // cannot be judged. A could-not-judge work-item, never a silent green.
197
+ scan.cnj.push({
198
+ rel: d.rel,
199
+ n: d.n,
200
+ note: `derived structure ${d.table}: no test constructs its source row ${d.source} without the write path (and no create-only fixture to mark red) — its fixture-independence is not statically decidable; a human must verify a test brackets the migration before AND after`,
201
+ });
202
+ }
203
+ }
204
+ return scan;
205
+ }
206
+ export const R45 = srcDetector("R-45", "R-45 a derived structure carries a test that constructs the source row without the write path and brackets the migration", "at least one test builds the source row via a direct ctx.db.insert (not create) and asserts before AND after the migration", scanR45, "R-45: no derived structure matched in the scanned tree — no fixture-independence to certify. NOT a clean bill.");
207
+ /** In the form the orchestrator spreads into REGISTRY. */
208
+ export const SOURCE_BACKFILL_DETECTORS = [
209
+ { detector: R44, ruleId: "R-44", standardPin: SOURCE_STANDARD_PIN },
210
+ { detector: R45, ruleId: "R-45", standardPin: SOURCE_STANDARD_PIN },
211
+ ];
@@ -0,0 +1,33 @@
1
+ import type { Detector, Tree } from "../../engine-types.js";
2
+ /** The standard all four are pinned against (short hash a9d7877b). */
3
+ export declare const SOURCE_CONTRACT_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 mirrors registry.ts's RegistryEntry so the
6
+ * orchestrator owns the integration; we do NOT import registry.ts. */
7
+ export interface SourceDetectorEntry {
8
+ detector: Detector;
9
+ ruleId: string;
10
+ standardPin: string;
11
+ }
12
+ interface Site {
13
+ rel: string;
14
+ n: number;
15
+ note: string;
16
+ }
17
+ interface Scan {
18
+ conform: Site[];
19
+ violate: Site[];
20
+ cnj: Site[];
21
+ }
22
+ export declare function scanR11(files: SrcFile[]): Scan;
23
+ export declare function scanR24(files: SrcFile[]): Scan;
24
+ export declare function scanR25(files: SrcFile[]): Scan;
25
+ export declare function scanR26(files: SrcFile[]): Scan;
26
+ export declare const R11: Detector;
27
+ export declare const R24: Detector;
28
+ export declare const R25: Detector;
29
+ export declare const R26: Detector;
30
+ /** The four, in a form the orchestrator spreads into REGISTRY. */
31
+ export declare const SOURCE_CONTRACT_DETECTORS: SourceDetectorEntry[];
32
+ export declare function contractRootProbe(root: string): boolean;
33
+ export {};
@@ -0,0 +1,385 @@
1
+ // The FOUR SOURCE-TREE contract/isolation detectors — R-11, R-24, R-25, R-26.
2
+ //
3
+ // Same kind as the six in security.ts: each reads the REAL backend SOURCE the
4
+ // engine hands it in `run(tree)` — it does NOT consult the oracle CSV. Every
5
+ // predicate below is derived from the ACTUAL convex source at
6
+ // /root/coding/vantage-memory/convex, NEVER from the standard's §15 prose. Where
7
+ // the standard's illustration disagrees with the source, the SOURCE wins and the
8
+ // detector records the stale line (see R-11's stale note).
9
+ //
10
+ // THE LAW (the one that caught R-5 and found §9a stale): a scan that matched no
11
+ // applicable site abstains with a machine-readable reason — never a silent pass;
12
+ // a concrete violation is a real file:line and outranks an abstain; every
13
+ // zero-count branch names the real site it matched so the branch is reachable;
14
+ // predicates read EXECUTING lines (never a comment-only line), so a neuter of the
15
+ // guard lands on code.
16
+ //
17
+ // Three states only (present / non-conforming / could-not-judge), plus the
18
+ // zero-applicable-site abstain that resolves to could-not-judge WITH a reason.
19
+ import { existsSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ /** The standard all four are pinned against (short hash a9d7877b). */
22
+ export const SOURCE_CONTRACT_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15";
23
+ function emptyScan() {
24
+ return { conform: [], violate: [], cnj: [] };
25
+ }
26
+ /** basename of a `rel` path, lower-cased. */
27
+ function base(rel) {
28
+ return (rel.split("/").pop() ?? rel).toLowerCase();
29
+ }
30
+ /** a source file we should judge — never a test or generated file. */
31
+ function isJudgeable(f) {
32
+ const b = base(f.rel);
33
+ return !b.endsWith(".test.ts") && !f.rel.includes("__tests__") && !f.rel.includes("_generated/");
34
+ }
35
+ /** an EXECUTING line (not a comment-only line) — predicates read code, not prose. */
36
+ function code(l) {
37
+ return !l.comment;
38
+ }
39
+ /** true when `rel` lives under a convex/ path (or IS a bare convex file name when
40
+ * the engine hands relative paths rooted at the convex dir). */
41
+ function isConvex(f) {
42
+ return /(^|\/)convex\//.test(f.rel) || (!f.rel.includes("/") && f.rel.endsWith(".ts"));
43
+ }
44
+ /** index of the start of the enclosing top-level declaration (an export or a
45
+ * function) — a guard/index at the handler TOP is in scope for a site deep in the
46
+ * same handler, so scans bound their backward search here, not at a fixed window. */
47
+ const DECL_HEAD = /^\s*export (const|default|async function|function)\b|^\s*(async )?function \w/;
48
+ function enclosingStart(lines, i) {
49
+ for (let j = i; j >= 0; j--) {
50
+ if (code(lines[j]) && DECL_HEAD.test(lines[j].raw))
51
+ return j;
52
+ }
53
+ return 0;
54
+ }
55
+ /** exclusive end of the enclosing declaration — the next top-level head, else EOF. */
56
+ function blockEnd(lines, i) {
57
+ for (let j = i + 1; j < lines.length; j++) {
58
+ if (code(lines[j]) && DECL_HEAD.test(lines[j].raw))
59
+ return j;
60
+ }
61
+ return lines.length;
62
+ }
63
+ /**
64
+ * Reduce a scan to ONE engine DetectorResult. Precedence for the SOURCE kind: a
65
+ * concrete VIOLATION site is decisive (a found bad site is never a silent pass);
66
+ * else any could-not-judge → could-not-judge WITH a reason; else any conform site
67
+ * → present; else zero applicable sites → could-not-judge naming the empty scan.
68
+ */
69
+ function decide(ruleId, id, check, scan, emptyReason) {
70
+ const nC = scan.conform.length;
71
+ const nV = scan.violate.length;
72
+ const nJ = scan.cnj.length;
73
+ const breakdown = `[conform=${nC} non-conforming=${nV} could-not-judge=${nJ}]`;
74
+ const fmt = (s) => s.slice(0, 3).map((x) => `${x.rel}:${x.n} (${x.note})`).join("; ");
75
+ if (nV > 0) {
76
+ return {
77
+ id,
78
+ anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
79
+ state: "non-conforming",
80
+ verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
81
+ };
82
+ }
83
+ if (nJ > 0) {
84
+ const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`;
85
+ return { id, anchor: `${scan.cnj[0].rel}:${scan.cnj[0].n}`, state: "could-not-judge", verdict: reason, reason };
86
+ }
87
+ if (nC > 0) {
88
+ return {
89
+ id,
90
+ anchor: `${scan.conform[0].rel}:${scan.conform[0].n}`,
91
+ state: "present",
92
+ verdict: `${ruleId}: ${nC} applicable site(s) conform (${check}); 0 violations. ${breakdown} Conform: ${fmt(scan.conform)}.`,
93
+ };
94
+ }
95
+ return { id, anchor: "source:no-applicable-site", state: "could-not-judge", verdict: emptyReason, reason: emptyReason };
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // R-11 — isolation locus: the tenant boundary is an index-range predicate INSIDE
99
+ // the query (`.withIndex(...).eq("<scopeKey>", ...)`), never a post-query handler
100
+ // filter. (standard §15 line 400 / §17 line 501 — "40 filterByOrgScope(in-handler,
101
+ // post-query) rows".)
102
+ //
103
+ // SOURCE READ (the LAW — derived from the ACTUAL handlers, not §17's count):
104
+ // - convex/messages.ts:282 and :422 — after merging instanceReceipts (queried
105
+ // by `by_instance_unread`) with roleReceipts (`by_recipient_unread`), the
106
+ // handler does `receipts = receipts.filter((r) => r.tenantId === args.tenantId)`.
107
+ // NO `.withIndex(... .eq("tenantId" ...))` precedes it — the comment itself
108
+ // admits "No tenant+instance index — JS filter". This is the R-11 defect.
109
+ // - convex/messages.ts:699 — `rows.filter((r) => r.tenantId === orgSlug)` IS
110
+ // preceded (line 691) by `.withIndex("by_tenant_created", q => q.eq("tenantId",
111
+ // orgSlug))`; it is a belt-and-suspenders pass over an already-indexed scope.
112
+ // - convex/messages.ts:958 and convex/briefingNotes.ts:310 — same shape: a
113
+ // post-query `.filter` on tenantId/orgId sitting AFTER a
114
+ // `.withSearchIndex(... .eq("tenantId"/"orgId", scope.orgSlug))`.
115
+ // - convex/tasks.ts (list) — uses `.withIndex` for every scope read; conform.
116
+ //
117
+ // STALE-LINE FINDING: §17 line 501 tallies "40 post-query isolation filters CAUGHT"
118
+ // as though every filterByOrgScope row is a live defect. The CURRENT source has
119
+ // remediated the majority — messages:699/958 and briefingNotes:310 now pair the
120
+ // post-query filter with an adjacent index-range predicate on the SAME scope key
121
+ // (belt-and-suspenders), so they are conform. Only the instance-merge branch
122
+ // (messages:282/422), which has no tenant index, remains a true R-11 defect. The
123
+ // SOURCE wins; the "40 CAUGHT" count is stale relative to the current handlers.
124
+ //
125
+ // PATTERN derived from the source: a line applying a `.filter(...)` whose predicate
126
+ // references a scope key (tenantId | orgId | orgSlug | namespace) is CONFORM iff a
127
+ // `.withIndex`/`.withSearchIndex` carrying `.eq("<sameScopeKey>", ...)` occurs in
128
+ // the preceding code window; otherwise it is the post-query-filter VIOLATION.
129
+ // ---------------------------------------------------------------------------
130
+ const SCOPE_KEYS = ["tenantId", "orgId", "orgSlug", "namespace"];
131
+ const SCOPE_FILTER = /\.filter\s*\(/;
132
+ const INDEX_CALL = /\.(withIndex|withSearchIndex)\s*\(/;
133
+ function scopeKeyOnFilterLine(raw) {
134
+ if (!SCOPE_FILTER.test(raw))
135
+ return null;
136
+ // only the arrow-predicate portion after `.filter(` counts as the filter body.
137
+ const body = raw.slice(raw.search(SCOPE_FILTER));
138
+ for (const k of SCOPE_KEYS) {
139
+ if (new RegExp(`\\b${k}\\b`).test(body))
140
+ return k;
141
+ }
142
+ return null;
143
+ }
144
+ /** true when the enclosing handler binds `key` as an index-range predicate before
145
+ * the post-query filter at line i. */
146
+ function precededByIndexOnKey(lines, i, key) {
147
+ let sawIndex = false;
148
+ let sawEq = false;
149
+ for (let j = enclosingStart(lines, i); j < i; j++) {
150
+ const l = lines[j];
151
+ if (!code(l))
152
+ continue;
153
+ if (INDEX_CALL.test(l.raw))
154
+ sawIndex = true;
155
+ if (new RegExp(`\\.eq\\(\\s*["'\`]${key}["'\`]`).test(l.raw))
156
+ sawEq = true;
157
+ }
158
+ return sawIndex && sawEq;
159
+ }
160
+ export function scanR11(files) {
161
+ const scan = emptyScan();
162
+ for (const f of files) {
163
+ if (!isJudgeable(f) || !isConvex(f))
164
+ continue;
165
+ for (let i = 0; i < f.lines.length; i++) {
166
+ const l = f.lines[i];
167
+ if (!code(l))
168
+ continue;
169
+ const key = scopeKeyOnFilterLine(l.raw);
170
+ if (!key)
171
+ continue;
172
+ if (precededByIndexOnKey(f.lines, i, key)) {
173
+ scan.conform.push({ rel: f.rel, n: l.n, note: `post-query .filter on ${key} paired with an adjacent .withIndex/.withSearchIndex .eq("${key}") — belt-and-suspenders over an indexed scope` });
174
+ }
175
+ else {
176
+ scan.violate.push({ rel: f.rel, n: l.n, note: `in-handler .filter on scope key ${key} with NO .withIndex on that key — post-query isolation filter (R-11 defect)` });
177
+ }
178
+ }
179
+ }
180
+ return scan;
181
+ }
182
+ // ---------------------------------------------------------------------------
183
+ // R-24 — a filter-selected bulk-write defaults to a dry run and applies only on an
184
+ // explicit confirm flag, with a caller-required guard. (standard §15 line 413.)
185
+ //
186
+ // SOURCE READ: convex/tasks.ts `bulkComplete` (export at line 2104):
187
+ // - line 2139 `const dryRun = args.dryRun !== false;` — defaults dryRun TRUE.
188
+ // - lines 2142-2145 `if (!dryRun && !args.callerOrchestrator) throw ... BULK_
189
+ // CALLER_REQUIRED` — the live path requires an explicit caller.
190
+ // - line 2234 `if (dryRun) { ... }` preview; line 2246 the mutate path behind
191
+ // `dryRun=false`.
192
+ // PATTERN: a mutation whose export name contains "bulk" must, in its handler body,
193
+ // (a) default dryRun safe (`args.dryRun !== false` / `args.dryRun ?? true`) and
194
+ // (b) key writes behind that flag with a caller-required guard. (a) absent →
195
+ // violation (a bulk-write that applies by default); (a) present but (b) absent →
196
+ // could-not-judge (safe-by-default but no caller guard statically confirmed).
197
+ // ---------------------------------------------------------------------------
198
+ const DRYRUN_DEFAULT_SAFE = /dryRun\s*=\s*args\.dryRun\s*!==\s*false|args\.dryRun\s*\?\?\s*true|dryRun\s*=\s*args\.dryRun\s*===\s*true\s*\?[^:]*:\s*true/;
199
+ const CALLER_GUARD = /!\s*dryRun\s*&&\s*!\s*args\.\w+|BULK_CALLER_REQUIRED|callerOrchestrator/;
200
+ /** slice a file into top-level `export const NAME = ...` blocks. */
201
+ function exportBlocks(lines) {
202
+ const heads = [];
203
+ for (let i = 0; i < lines.length; i++) {
204
+ if (!code(lines[i]))
205
+ continue;
206
+ const m = lines[i].raw.match(/^export const (\w+)\s*=/);
207
+ if (m)
208
+ heads.push({ name: m[1], idx: i });
209
+ }
210
+ return heads.map((h, k) => ({ name: h.name, start: h.idx, end: k + 1 < heads.length ? heads[k + 1].idx : lines.length }));
211
+ }
212
+ export function scanR24(files) {
213
+ const scan = emptyScan();
214
+ for (const f of files) {
215
+ if (!isJudgeable(f) || !isConvex(f))
216
+ continue;
217
+ for (const blk of exportBlocks(f.lines)) {
218
+ if (!/bulk/i.test(blk.name))
219
+ continue;
220
+ const body = f.lines.slice(blk.start, blk.end);
221
+ const isMutation = body.some((l) => code(l) && /\bmutation\s*\(/.test(l.raw));
222
+ if (!isMutation)
223
+ continue;
224
+ const defLine = body.find((l) => code(l) && DRYRUN_DEFAULT_SAFE.test(l.raw));
225
+ const guardLine = body.find((l) => code(l) && CALLER_GUARD.test(l.raw));
226
+ const anchorN = f.lines[blk.start].n;
227
+ if (!defLine) {
228
+ scan.violate.push({ rel: f.rel, n: anchorN, note: `bulk-write mutation "${blk.name}" does not default dryRun safe — applies by default (unbounded blast radius)` });
229
+ }
230
+ else if (!guardLine) {
231
+ scan.cnj.push({ rel: f.rel, n: defLine.n, note: `bulk-write "${blk.name}" defaults dryRun safe but no caller-required guard found on the live path — not statically confirmed` });
232
+ }
233
+ else {
234
+ scan.conform.push({ rel: f.rel, n: defLine.n, note: `bulk-write "${blk.name}" defaults dryRun true and gates apply behind an explicit flag + caller-required guard (line ${guardLine.n})` });
235
+ }
236
+ }
237
+ }
238
+ return scan;
239
+ }
240
+ // ---------------------------------------------------------------------------
241
+ // R-25 — a minted storage address or a cross-boundary signed bundle is org-bound
242
+ // and checked at BOTH mint and redemption (with an expiry on the minted URL).
243
+ // (standard §15 line 414 / §16 line 258.)
244
+ //
245
+ // SOURCE READ:
246
+ // - MINT convex/kbMutations.ts `generateUploadUrl` — line 164
247
+ // `assertOrgArgs(args.orgId, ...)` precedes line 165
248
+ // `ctx.storage.generateUploadUrl()`: the mint is org-bound.
249
+ // - MINT convex/okfBundleNode.ts `exportOkfBundle` — line 314
250
+ // `assertCanExportNamespace(ctx, args.namespace)` precedes the getUrl mint at
251
+ // line 447; expiry via `ttlSeconds`/`urlExpiresAt` (lines 455-463) +
252
+ // scheduled deleteExpiredStorage.
253
+ // - REDEEM convex/okfBundleNode.ts `importOkfBundle` — line 888
254
+ // `assertCanImport(ctx, args.targetNamespace)` precedes the storage.get/fetch
255
+ // at lines 907/916: the redemption end is org-bound too.
256
+ // PATTERN: a storage mint (`storage.generateUploadUrl` | `storage.getUrl` |
257
+ // `storage.store`) or a redemption (`storage.get(` in an import handler) must have
258
+ // an org-binding guard (assertOrgArgs | assertCanExport* | assertCanImport |
259
+ // AUTH_NAMESPACE_DENIED | AUTH_NO_ORG) in the preceding window. Absent → violation.
260
+ // A bundle-export mint (getUrl/store) org-bound but with NO expiry token in the
261
+ // handler → could-not-judge (expiry not statically confirmed).
262
+ // ---------------------------------------------------------------------------
263
+ const MINT_TOKEN = /storage\.(generateUploadUrl|getUrl|store)\s*\(/;
264
+ const REDEEM_TOKEN = /storage\.get\s*\(/;
265
+ // org-binding guards seen in the real source: assertOrgArgs (kb), assertCan* (okf
266
+ // export/import/validate all funnel through assertCanExportNamespace), and the
267
+ // fail-closed error tokens they throw.
268
+ const ORG_BIND_GUARD = /assertOrgArgs|assertCan\w+|AUTH_NAMESPACE_DENIED|AUTH_NO_ORG/;
269
+ const EXPIRY_TOKEN = /ttl|expir|urlExpiresAt|DEFAULT_URL_TTL/i;
270
+ function isArtifactFile(f) {
271
+ const b = base(f.rel);
272
+ return /^kb/.test(b) || /okfbundle/.test(b);
273
+ }
274
+ /** the enclosing handler binds the artifact to a verified org before line i. */
275
+ function guardedBefore(lines, i) {
276
+ for (let j = enclosingStart(lines, i); j <= i; j++) {
277
+ if (code(lines[j]) && ORG_BIND_GUARD.test(lines[j].raw))
278
+ return lines[j].n;
279
+ }
280
+ return 0;
281
+ }
282
+ export function scanR25(files) {
283
+ const scan = emptyScan();
284
+ for (const f of files) {
285
+ if (!isJudgeable(f) || !isArtifactFile(f))
286
+ continue;
287
+ for (let i = 0; i < f.lines.length; i++) {
288
+ const l = f.lines[i];
289
+ if (!code(l))
290
+ continue;
291
+ const isMint = MINT_TOKEN.test(l.raw);
292
+ const isRedeem = !isMint && REDEEM_TOKEN.test(l.raw);
293
+ if (!isMint && !isRedeem)
294
+ continue;
295
+ const guardLine = guardedBefore(f.lines, i);
296
+ if (!guardLine) {
297
+ scan.violate.push({ rel: f.rel, n: l.n, note: `${isMint ? "storage mint" : "bundle redemption"} with no org-binding guard (assertOrgArgs/assertCanExport/assertCanImport) in the preceding window` });
298
+ continue;
299
+ }
300
+ const isBundleMint = /storage\.(getUrl|store)\s*\(/.test(l.raw);
301
+ const hasExpiry = f.lines.slice(enclosingStart(f.lines, i), blockEnd(f.lines, i)).some((x) => code(x) && EXPIRY_TOKEN.test(x.raw));
302
+ if (isBundleMint && !hasExpiry) {
303
+ scan.cnj.push({ rel: f.rel, n: l.n, note: `bundle mint org-bound at line ${guardLine} but no expiry (ttl/urlExpiresAt) token found in the handler — expiry not statically confirmed` });
304
+ }
305
+ else {
306
+ scan.conform.push({ rel: f.rel, n: l.n, note: `${isMint ? "mint" : "redemption"} org-bound by guard at line ${guardLine}${isBundleMint ? " + expiry present" : ""}` });
307
+ }
308
+ }
309
+ }
310
+ return scan;
311
+ }
312
+ // ---------------------------------------------------------------------------
313
+ // R-26 — a library-owned (component) table declares its boundary (authority tier +
314
+ // boundary key) at the component seam; absent that declaration a component is used
315
+ // fleet-internal/master only, never org-scoped on trust. (standard §15 line 415 /
316
+ // §8 line 137 / §17 line 506.)
317
+ //
318
+ // SOURCE READ: convex/convex.config.ts is the mount seam —
319
+ // `app.use(rag)` and `app.use(agentEngine)`. Neither mount carries an
320
+ // authority-tier / boundary-key declaration: rag has only an import comment; the
321
+ // agentEngine block documents durability/versioning ("long-task survival",
322
+ // "Pinned exactly at 0.1.0-alpha.2"), not an isolation contract. Per §8 an
323
+ // undeclared seam is non-conforming as an app-owned table with a blank field —
324
+ // but the DISPOSITION (whether the library's tables are in fact used
325
+ // fleet-internal/master only, which would justify the silence) is human. So the
326
+ // detector flags an undeclared mount as could-not-judge WITH a reason (never a
327
+ // silent pass); a mount whose window declares an authority tier + boundary key
328
+ // is conform.
329
+ // PATTERN: each `app.use(<component>)` line in a *.config.ts seam is conform iff its
330
+ // surrounding window (comments included — the seam contract is documented prose)
331
+ // declares an authority tier + boundary key; else could-not-judge.
332
+ // ---------------------------------------------------------------------------
333
+ const MOUNT_CALL = /app\.use\s*\(\s*(\w+)/;
334
+ const SEAM_CONTRACT = /authority tier|boundary key|isolation contract|org-scoped|fleet-internal|master-only|namespace-bound|tenant boundary/i;
335
+ const R26_WINDOW = 6;
336
+ function isConfigSeam(f) {
337
+ return /\.config\.ts$/.test(base(f.rel));
338
+ }
339
+ export function scanR26(files) {
340
+ const scan = emptyScan();
341
+ for (const f of files) {
342
+ if (!isJudgeable(f) || !isConfigSeam(f))
343
+ continue;
344
+ for (let i = 0; i < f.lines.length; i++) {
345
+ const l = f.lines[i];
346
+ if (!code(l))
347
+ continue;
348
+ const m = l.raw.match(MOUNT_CALL);
349
+ if (!m)
350
+ continue;
351
+ const comp = m[1];
352
+ // seam contract is documented as prose — scan ALL lines in the window.
353
+ const declared = f.lines
354
+ .slice(Math.max(0, i - R26_WINDOW), i + R26_WINDOW)
355
+ .some((x) => SEAM_CONTRACT.test(x.raw));
356
+ if (declared) {
357
+ scan.conform.push({ rel: f.rel, n: l.n, note: `component "${comp}" mount declares an authority tier + boundary key at the seam` });
358
+ }
359
+ else {
360
+ scan.cnj.push({ rel: f.rel, n: l.n, note: `component "${comp}" mounted with no stated authority tier / boundary key at the seam — usable fleet-internal/master only; org-scoped disposition needs a human ruling (§8)` });
361
+ }
362
+ }
363
+ }
364
+ return scan;
365
+ }
366
+ // ---- detector wrappers ----------------------------------------------------
367
+ function srcDetector(ruleId, id, check, scanFn, emptyReason) {
368
+ return { id, run: (tree) => decide(ruleId, id, check, scanFn(tree.files), emptyReason) };
369
+ }
370
+ export const R11 = srcDetector("R-11", "R-11 tenant isolation is an in-query index predicate, not a post-query filter", "a .filter on a scope key is paired with an adjacent .withIndex/.withSearchIndex .eq on that key", scanR11, "R-11: zero scope-key .filter sites matched in the scanned convex tree — cannot certify the isolation locus. NOT a clean bill.");
371
+ export const R24 = srcDetector("R-24", "R-24 filter-selected bulk-write defaults to dry run", "a bulk-write mutation defaults dryRun true and gates apply behind an explicit flag + caller guard", scanR24, "R-24: zero bulk-write mutations matched in the scanned convex tree — cannot certify dry-run-by-default. NOT a clean bill.");
372
+ export const R25 = srcDetector("R-25", "R-25 minted address / cross-boundary bundle org-bound at both ends", "a storage mint or bundle redemption is org-bound by an auth guard; a minted bundle URL expires", scanR25, "R-25: zero storage mint/redemption sites matched (kb*/okfBundle*) — cannot certify org-binding. NOT a clean bill.");
373
+ export const R26 = srcDetector("R-26", "R-26 library-owned component declares its seam boundary", "each app.use(component) seam declares an authority tier + boundary key", scanR26, "R-26: no component mount seam (*.config.ts app.use) found — cannot certify the component seam contract. NOT a clean bill.");
374
+ /** The four, in a form the orchestrator spreads into REGISTRY. */
375
+ export const SOURCE_CONTRACT_DETECTORS = [
376
+ { detector: R11, ruleId: "R-11", standardPin: SOURCE_CONTRACT_STANDARD_PIN },
377
+ { detector: R24, ruleId: "R-24", standardPin: SOURCE_CONTRACT_STANDARD_PIN },
378
+ { detector: R25, ruleId: "R-25", standardPin: SOURCE_CONTRACT_STANDARD_PIN },
379
+ { detector: R26, ruleId: "R-26", standardPin: SOURCE_CONTRACT_STANDARD_PIN },
380
+ ];
381
+ // Co-artifact reader retained for parity with security.ts's evalR36 shape — the
382
+ // contract detectors are tree-only, but the orchestrator may pass a root probe.
383
+ export function contractRootProbe(root) {
384
+ return existsSync(join(root, "convex", "convex.config.ts")) || existsSync(join(root, "convex.config.ts"));
385
+ }