@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,334 @@
1
+ import { LIST_VERBS, WRITE_VERBS, isMcpPublic } from "./shared.js";
2
+ // ---- R-1 exposure declared ------------------------------------------------
3
+ // §2 line 32: exposure -> `surface` = `public` (MCP surface) / `internal` /
4
+ // `library-owned` — THREE canonical values. The oracle stores raw values, so
5
+ // (mirroring R-5's collapse) the detector collapses raw→canonical FIRST, then
6
+ // flags only a value that maps to none of the three.
7
+ const SURFACE_COLLAPSE = {
8
+ "mcp-public": "public",
9
+ public: "public",
10
+ component: "library-owned",
11
+ "library-owned": "library-owned",
12
+ "hors-mcp": "internal",
13
+ internal: "internal",
14
+ };
15
+ /** Collapse a raw `surface` to one of the three canonical exposures, or null. */
16
+ export function collapseSurface(raw) {
17
+ return SURFACE_COLLAPSE[raw.trim().toLowerCase()] ?? null;
18
+ }
19
+ export const r1 = {
20
+ ruleId: "R-1",
21
+ id: "R-1 exposure declared",
22
+ check: "raw `surface` collapses to one of the three canonical exposures {public/internal/library-owned}",
23
+ classify: (r) => collapseSurface(r.by.surface)
24
+ ? { kind: "conform" }
25
+ : { kind: "violate", note: `surface="${r.by.surface}" maps to none of {public, internal, library-owned}` },
26
+ };
27
+ // ---- R-2 operation verb, closed set, rpc refused --------------------------
28
+ // §2 operation verb [shared] -> `crud`. §15: verb one of the closed set; no rpc.
29
+ const VERB_MAP = {
30
+ "READ-LIST": "list",
31
+ "READ-GET": "get",
32
+ SEARCH: "search",
33
+ AGGREGATE: "aggregate",
34
+ CREATE: "create",
35
+ UPDATE: "update",
36
+ DELETE: "delete",
37
+ UPSERT: "upsert",
38
+ TRANSITION: "transition",
39
+ "BULK-WRITE": "bulk-write",
40
+ "EXTERNAL-EFFECT": "external-effect",
41
+ };
42
+ export const r2 = {
43
+ ruleId: "R-2",
44
+ id: "R-2 operation verb, rpc refused",
45
+ check: "verb one of the closed set; `rpc` refused",
46
+ classify: (r) => {
47
+ const v = r.by.crud;
48
+ if (v === "n/a" || v === "")
49
+ return { kind: "na" }; // component / non-exposed, no verb
50
+ if (v.toUpperCase() === "RPC")
51
+ return { kind: "violate", note: "crud=RPC — the closed set refuses rpc; it publishes nothing about effect" };
52
+ if (VERB_MAP[v.toUpperCase()])
53
+ return { kind: "conform" };
54
+ return { kind: "violate", note: `crud="${v}" is outside the closed verb set` };
55
+ },
56
+ };
57
+ // ---- R-3 pagination, tool-enforced ----------------------------------------
58
+ // §2 pagination [shared] -> `search_mecanisme`. §15 line 392: "cursor declared;
59
+ // `default`/`max` numeric and tool-set; truncated≠exhausted". All three clauses:
60
+ // - cursor declared (a `pas de curseur` cell is no cursor),
61
+ // - default numeric,
62
+ // - max numeric AND bounded (`non-borné` is the unbounded max R-30 refuses),
63
+ // - truncated≠exhausted is a RESPONSE-SHAPE property with no oracle column — a
64
+ // row otherwise fine is could-not-judge on that sub-clause, never a silent pass.
65
+ export const r3 = {
66
+ ruleId: "R-3",
67
+ id: "R-3 pagination, tool-enforced",
68
+ check: "cursor declared; default numeric; max numeric-and-bounded; truncated≠exhausted (response-shape)",
69
+ classify: (r) => {
70
+ if (!LIST_VERBS.has(r.by.crud))
71
+ return { kind: "na" };
72
+ const cell = r.by.search_mecanisme;
73
+ const m = cell.toLowerCase();
74
+ const cursorDeclared = !m.includes("pas de curseur") && /curseur\s*:?\s*oui|cursor/.test(m);
75
+ const defaultMatch = /defaut\s*:?\s*(\d+)|default\s*:?\s*(\d+)/.exec(m);
76
+ const maxUnbounded = /max\s*:?\s*non-?bor/.test(m);
77
+ const maxMatch = /max\s*:?\s*(\d+)/.exec(m);
78
+ const fails = [];
79
+ if (!cursorDeclared)
80
+ fails.push("no cursor declared");
81
+ if (!defaultMatch)
82
+ fails.push("no numeric `default`");
83
+ if (maxUnbounded)
84
+ fails.push("`max: non-borné` — an unbounded max R-30 refuses");
85
+ else if (!maxMatch)
86
+ fails.push("no numeric `max`");
87
+ if (fails.length > 0)
88
+ return { kind: "violate", note: `${fails.join("; ")} (search_mecanisme="${cell || "<empty>"}")` };
89
+ // cursor + default + bounded max all present; the truncated≠exhausted clause
90
+ // is not expressible in the oracle columns — abstain on it, never pass.
91
+ return {
92
+ kind: "cnj",
93
+ note: "cursor + default + bounded max present, but truncated≠exhausted is a response-shape property absent from the oracle columns; deferred",
94
+ };
95
+ },
96
+ };
97
+ // ---- R-4 retrieval mechanism, unindexed scan refused ----------------------
98
+ // §2 retrieval [shared] -> `search_mecanisme`. §15: mechanism one of the set,
99
+ // every list included; no unindexed scan.
100
+ const INDEXED = ["bm25", "vectoriel", "vector", "hybrid", "rrf", "indexed-range", "range", "searchindex"];
101
+ export const r4 = {
102
+ ruleId: "R-4",
103
+ id: "R-4 retrieval, no unindexed scan",
104
+ check: "mechanism one of the indexed set; `unindexed-scan` refused",
105
+ classify: (r) => {
106
+ if (!LIST_VERBS.has(r.by.crud))
107
+ return { kind: "na" };
108
+ const m = r.by.search_mecanisme.toLowerCase();
109
+ if (m.includes("substring scan") || m.includes(".collect()") || m.includes("unindexed"))
110
+ return { kind: "violate", note: "unindexed-scan (.collect()+.includes()) — refused at publication" };
111
+ if (m.trim() === "")
112
+ return { kind: "violate", note: "empty retrieval cell — a list is a retrieval and must declare a mechanism" };
113
+ if (INDEXED.some((tok) => m.includes(tok)))
114
+ return { kind: "conform" };
115
+ return { kind: "violate", note: `retrieval mechanism "${r.by.search_mecanisme}" not in the closed set` };
116
+ },
117
+ };
118
+ // ---- R-5 reader authority, resolved from identity -------------------------
119
+ // §2 reader authority [shared] -> `rbac_qui`. Standard line 61: reader authority
120
+ // is one tier from public/org-member/org-admin/fleet-internal/master, and "VP's
121
+ // eight raw rbac_qui values COLLAPSE to these five … resolved from a verified
122
+ // identity, never a caller-supplied value — a tier read from an argument is not
123
+ // a tier." So the detector FIRST collapses raw→tier (they all map, so "not a
124
+ // tier" is ~0), THEN judges identity-resolution — the real violation half — by
125
+ // the row's `namespace_ou_org` evidence (the same column R-6 reads).
126
+ const TIER_MAP = {
127
+ fleet_interne: "fleet-internal",
128
+ "fleet-interne": "fleet-internal",
129
+ public: "public",
130
+ "org-member": "org-member",
131
+ "org-admin": "org-admin",
132
+ master: "master",
133
+ };
134
+ /** Collapse a raw `rbac_qui` value to one of the five canonical tiers, or null
135
+ * if it maps to none. `fleet_interne` → fleet-internal; every `toutes_orgs(...)`
136
+ * variant → an org tier (the documented collapse, standard line 61). */
137
+ export function collapseTier(raw) {
138
+ const v = raw.trim().toLowerCase();
139
+ if (TIER_MAP[v])
140
+ return TIER_MAP[v];
141
+ if (v.startsWith("toutes_orgs"))
142
+ return "org-member";
143
+ return null;
144
+ }
145
+ /** rbac_qui variants whose value itself evidences identity-resolution. */
146
+ function rbacEvidencesIdentity(rq) {
147
+ return /identity-gated|namespace-derived/.test(rq) || rq === "fleet_interne";
148
+ }
149
+ export const r5 = {
150
+ ruleId: "R-5",
151
+ id: "R-5 reader authority from identity",
152
+ check: "raw rbac_qui collapses to one of five tiers, resolved from a verified identity (not an argument)",
153
+ classify: (r) => {
154
+ if (!isMcpPublic(r))
155
+ return { kind: "na" }; // only the served surface carries a reader tier
156
+ // component / non-MCP rows are judged by R-26, not R-5 — exclude from the denominator.
157
+ if (r.by.rbac_qui === "n/a" || r.by.rbac_qui === "")
158
+ return { kind: "na" };
159
+ const tier = collapseTier(r.by.rbac_qui);
160
+ if (!tier)
161
+ return { kind: "violate", note: `rbac_qui="${r.by.rbac_qui}" maps to none of the five canonical tiers` };
162
+ const ns = r.by.namespace_ou_org;
163
+ // BOUNDARY EVIDENCE OUTRANKS THE TIER LABEL. `arg=` lives INSIDE the value as
164
+ // `(arg=<field>)`, never at the start (0 rows start with it; 46 contain it) —
165
+ // so we CONTAINS-match. A boundary derived from a caller argument is not
166
+ // identity-resolved however `rbac_qui` is LABELLED: standard line 71 names
167
+ // `create_briefing_note` (labelled `identity-gated`, boundary `arg=createdBy`)
168
+ // as the founding example — "the boundary is the argument, so there is no
169
+ // boundary". The label must never override the evidence.
170
+ if (/\(arg=|\barg=/.test(ns))
171
+ return {
172
+ kind: "violate",
173
+ note: `reader tier not resolved from a verified identity — boundary derives from a caller argument namespace_ou_org="${ns}" (label rbac_qui="${r.by.rbac_qui}" does not override the boundary evidence)`,
174
+ };
175
+ // no arg= in the boundary: NOW the identity-evidencing label may confirm.
176
+ if (rbacEvidencesIdentity(r.by.rbac_qui))
177
+ return { kind: "conform" };
178
+ // maps to a tier but the row evidences NEITHER identity nor arg — abstain, never a silent pass.
179
+ return {
180
+ kind: "cnj",
181
+ 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`,
182
+ };
183
+ },
184
+ };
185
+ // ---- R-9 per-tool token cost ----------------------------------------------
186
+ // §2 token cost -> `tokens_estime_contexte`. §15 line 398: "a number — a measured
187
+ // OR method-stated estimate — not merely non-empty (red: a placeholder such as
188
+ // TBD/?/~)". So conform on a number OR a method-stated estimate; red ONLY on a
189
+ // placeholder or empty.
190
+ const TOKEN_PLACEHOLDERS = new Set(["", "tbd", "?", "~", "n/a", "todo"]);
191
+ export const r9 = {
192
+ ruleId: "R-9",
193
+ id: "R-9 token cost declared",
194
+ check: "`tokens_estime_contexte` is a number or a method-stated estimate; red only a placeholder",
195
+ classify: (r) => {
196
+ const v = r.by.tokens_estime_contexte.trim();
197
+ if (TOKEN_PLACEHOLDERS.has(v.toLowerCase()))
198
+ return { kind: "violate", note: `tokens_estime_contexte="${v || "<empty>"}" is a placeholder, not an estimate` };
199
+ return { kind: "conform" };
200
+ },
201
+ };
202
+ // ---- R-10 writer authority, a distinct resolved tier ----------------------
203
+ // §2 writer authority [shared] -> `rbac_qui`. §15: writer tier present, DISTINCT,
204
+ // one of five. The oracle has a SINGLE `rbac_qui` column, so no write can carry
205
+ // a writer tier distinct from its reader tier — the headline merge (R-14).
206
+ export const r10 = {
207
+ ruleId: "R-10",
208
+ id: "R-10 writer authority, distinct tier",
209
+ check: "writer tier present, distinct from reader, one of five",
210
+ classify: (r) => {
211
+ if (!WRITE_VERBS.has(r.by.crud))
212
+ return { kind: "na" };
213
+ return {
214
+ kind: "violate",
215
+ note: "writer authority is not recorded as a field distinct from reader authority — both merged into the single `rbac_qui` column",
216
+ };
217
+ },
218
+ };
219
+ // ---- R-12 vector search declares namespace + topK -------------------------
220
+ // §2 vector boundary -> `search_mecanisme`. §15: real namespace + numeric topK.
221
+ export const r12 = {
222
+ ruleId: "R-12",
223
+ id: "R-12 vector namespace + topK",
224
+ check: "an indexed-vector tool declares a namespace and a numeric topK",
225
+ classify: (r) => {
226
+ const m = r.by.search_mecanisme.toLowerCase();
227
+ if (!m.includes("vectoriel") && !m.includes("vector"))
228
+ return { kind: "na" };
229
+ const hasTopK = /topk\s*=?\s*\d+|topk\s+\d+/i.test(r.by.search_mecanisme);
230
+ const hasNs = m.includes("rag") || m.includes("namespace");
231
+ if (hasTopK && hasNs)
232
+ return { kind: "conform" };
233
+ const missing = [!hasTopK ? "topK" : null, !hasNs ? "namespace" : null].filter(Boolean).join(" + ");
234
+ return { kind: "violate", note: `vector search missing ${missing} (search_mecanisme="${r.by.search_mecanisme}")` };
235
+ },
236
+ };
237
+ // ---- R-13 purpose & disposition declared ----------------------------------
238
+ // §2 disposition -> table_purpose + table_conserver_supprimer + statut_suppression.
239
+ // §15: purpose non-empty; disposition ∈ {stays, goes, undecided}; a bare
240
+ // `undecided` (no owner + deadline) is refused.
241
+ export const r13 = {
242
+ ruleId: "R-13",
243
+ id: "R-13 purpose & disposition",
244
+ check: "purpose non-empty; disposition decided, or undecided with owner + deadline",
245
+ classify: (r) => {
246
+ if (r.by.table_purpose.trim() === "")
247
+ return { kind: "violate", note: "table_purpose is empty — a table with no stated purpose is a deletion candidate" };
248
+ const d = r.by.table_conserver_supprimer;
249
+ if (d.trim() === "")
250
+ return { kind: "violate", note: "disposition (table_conserver_supprimer) is empty" };
251
+ // §15 line 402: an `undecided` (`a confirmer`) disposition CONFORMS when it
252
+ // carries an owner AND a deadline; only a BARE undecided is red.
253
+ if (/\(a confirmer\)|undecided/i.test(d)) {
254
+ const hasOwner = /owner\s*:/i.test(d);
255
+ const hasDeadline = /deadline\s*:|\b\d{4}-\d{2}-\d{2}\b/i.test(d);
256
+ if (hasOwner && hasDeadline)
257
+ return { kind: "conform" };
258
+ return {
259
+ kind: "violate",
260
+ note: `bare undecided disposition — missing ${!hasOwner ? "owner" : ""}${!hasOwner && !hasDeadline ? " + " : ""}${!hasDeadline ? "deadline" : ""}: "${d.slice(0, 60)}"`,
261
+ };
262
+ }
263
+ if (/^CONSERVER|^SUPPRIMER/.test(d))
264
+ return { kind: "conform" };
265
+ return { kind: "violate", note: `disposition "${d.slice(0, 40)}" outside {stays, goes}` };
266
+ },
267
+ };
268
+ // ---- R-27 removed tool names survivor + change ----------------------------
269
+ // §2 disposition -> `statut_suppression`. §15 line 416: "the named survivor
270
+ // RESOLVES to a tool in the current inventory and the change ref to a real
271
+ // PR/commit, not merely non-empty strings (red: survivor absent, or an
272
+ // unresolvable ref)". The survivor half is statically decidable (is it in the
273
+ // inventory?). The change-ref RESOLUTION is NOT statically decidable — it needs
274
+ // the target repo at a pin — so a row whose survivor resolves but whose ref is
275
+ // only shape-valid is could-not-judge, NEVER conform (a bare shape test is what
276
+ // the line refuses).
277
+ export const r27 = {
278
+ ruleId: "R-27",
279
+ id: "R-27 removed tool names survivor",
280
+ check: "survivor resolves to a current tool (static); change-ref resolution needs the target repo (deferred)",
281
+ classify: (r, all) => {
282
+ const s = r.by.statut_suppression;
283
+ if (s.trim() === "")
284
+ return { kind: "na" }; // not a removed tool
285
+ const survivor = /survivor:\s*([^)]+)\)/.exec(s)?.[1]?.trim();
286
+ const hasRefShape = /PR#\d+|[0-9a-f]{7,40}/.test(s);
287
+ if (!survivor)
288
+ return { kind: "violate", note: `removed tool names no survivor: "${s}"` };
289
+ const resolves = all.some((row) => row.by.outil === survivor);
290
+ if (!resolves)
291
+ return { kind: "violate", note: `survivor "${survivor}" absent from the inventory — does not resolve` };
292
+ if (!hasRefShape)
293
+ return { kind: "violate", note: `removed tool names no PR/commit ref shape: "${s}"` };
294
+ return {
295
+ kind: "cnj",
296
+ note: `survivor "${survivor}" resolves; change ref shape valid but resolution requires the target repo at a pin — deferred (not statically decidable)`,
297
+ };
298
+ },
299
+ };
300
+ // ---- R-28 per-tool remediation --------------------------------------------
301
+ // §2 remediation -> `rbac_adjustment_needed`. §15: a non-conforming tool names a
302
+ // concrete CORRIGER/REVOIR remediation, not a bare placeholder.
303
+ function isNonConforming(r) {
304
+ return /^INCOHERENT/i.test(r.by.rbac_coherence_table);
305
+ }
306
+ export const r28 = {
307
+ ruleId: "R-28",
308
+ id: "R-28 per-tool remediation",
309
+ check: "a non-conforming tool names a concrete CORRIGER/REVOIR remediation",
310
+ classify: (r) => {
311
+ if (!isNonConforming(r))
312
+ return { kind: "na" };
313
+ const fix = r.by.rbac_adjustment_needed;
314
+ if (/^(CORRIGER|REVOIR)/i.test(fix))
315
+ return { kind: "conform" };
316
+ return {
317
+ kind: "violate",
318
+ note: `non-conforming (${r.by.rbac_coherence_table.slice(0, 30)}) but remediation is not a concrete fix: "${fix.slice(0, 40)}"`,
319
+ };
320
+ },
321
+ };
322
+ export const PER_ROW_PREDICATES = [
323
+ r1,
324
+ r2,
325
+ r3,
326
+ r4,
327
+ r5,
328
+ r9,
329
+ r10,
330
+ r12,
331
+ r13,
332
+ r27,
333
+ r28,
334
+ ];
@@ -0,0 +1,56 @@
1
+ import type { Detector, DetectorResult } from "../engine-types.js";
2
+ import { type OracleRow } from "../oracle.js";
3
+ export type RowKind = "conform" | "violate" | "na" | "cnj";
4
+ export interface RowVerdict {
5
+ kind: RowKind;
6
+ /** why the row violated / could-not-be-judged (for the report). */
7
+ note?: string;
8
+ }
9
+ export interface RulePredicate {
10
+ /** the rule id this detector targets, e.g. "R-1". */
11
+ ruleId: string;
12
+ /** the detector display id, e.g. "R-1 exposure declared". */
13
+ id: string;
14
+ /** what §15 says the check reads and the criterion that passes it. */
15
+ check: string;
16
+ /** classify ONE row. `all` is the full row set (for rules like R-27 that must
17
+ * resolve a reference against the rest of the inventory). Rows that are not
18
+ * this rule's concern return `na`. */
19
+ classify(row: OracleRow, all: OracleRow[]): RowVerdict;
20
+ }
21
+ export interface Tally {
22
+ conform: number;
23
+ violate: number;
24
+ cnj: number;
25
+ na: number;
26
+ violations: {
27
+ row: OracleRow;
28
+ note: string;
29
+ }[];
30
+ unjudged: {
31
+ row: OracleRow;
32
+ note: string;
33
+ }[];
34
+ }
35
+ /** Run a predicate over a row set, tallying per-kind. */
36
+ export declare function tally(spec: RulePredicate, rows: OracleRow[]): Tally;
37
+ /** Aggregate a tally into ONE engine DetectorResult.
38
+ *
39
+ * Precedence: any could-not-judge row makes the whole detector abstain (an
40
+ * abstain is never a silent pass); else any violation is non-conforming; else
41
+ * present. Every verdict carries the FULL breakdown so a reader sees the four
42
+ * counts regardless of the reduced state. */
43
+ export declare function evaluate(spec: RulePredicate, rows: OracleRow[]): DetectorResult;
44
+ /**
45
+ * Wrap a predicate into an engine Detector. `rows` is injectable for tests;
46
+ * production reads the memoized real oracle. The `run(tree)` ARGUMENT IS IGNORED
47
+ * — the tree is .ts-only and does not carry the CSV.
48
+ */
49
+ export declare function makeOracleDetector(spec: RulePredicate, rowsProvider?: () => OracleRow[]): Detector;
50
+ /** verbs that write (used by writer-authority / verb reasoning). */
51
+ export declare const WRITE_VERBS: Set<string>;
52
+ /** verbs that list/search (retrieval + pagination rules apply). */
53
+ export declare const LIST_VERBS: Set<string>;
54
+ export declare function isMcpPublic(row: OracleRow): boolean;
55
+ /** placeholder tokens that are a declared-but-empty answer. */
56
+ export declare function isPlaceholder(v: string): boolean;
@@ -0,0 +1,103 @@
1
+ import { isInventoryRow, readOracle } from "../oracle.js";
2
+ /** Run a predicate over a row set, tallying per-kind. */
3
+ export function tally(spec, rows) {
4
+ const t = { conform: 0, violate: 0, cnj: 0, na: 0, violations: [], unjudged: [] };
5
+ for (const row of rows) {
6
+ // a malformed row is the third state before the predicate ever runs.
7
+ if (row.malformed) {
8
+ t.cnj++;
9
+ t.unjudged.push({ row, note: row.malformed });
10
+ continue;
11
+ }
12
+ if (!isInventoryRow(row)) {
13
+ t.na++;
14
+ continue;
15
+ }
16
+ const v = spec.classify(row, rows);
17
+ if (v.kind === "conform")
18
+ t.conform++;
19
+ else if (v.kind === "na")
20
+ t.na++;
21
+ else if (v.kind === "violate") {
22
+ t.violate++;
23
+ t.violations.push({ row, note: v.note ?? "violation" });
24
+ }
25
+ else {
26
+ t.cnj++;
27
+ t.unjudged.push({ row, note: v.note ?? "could not judge" });
28
+ }
29
+ }
30
+ return t;
31
+ }
32
+ function anchorFor(rows, fallback) {
33
+ if (rows.length === 0)
34
+ return fallback;
35
+ const first = rows.slice(0, 3).map((r) => `row#${r.row.n}`);
36
+ return `oracle:${first.join(",")}${rows.length > 3 ? ",…" : ""}`;
37
+ }
38
+ /** Aggregate a tally into ONE engine DetectorResult.
39
+ *
40
+ * Precedence: any could-not-judge row makes the whole detector abstain (an
41
+ * abstain is never a silent pass); else any violation is non-conforming; else
42
+ * present. Every verdict carries the FULL breakdown so a reader sees the four
43
+ * counts regardless of the reduced state. */
44
+ export function evaluate(spec, rows) {
45
+ const t = tally(spec, rows);
46
+ const applicable = t.conform + t.violate + t.cnj;
47
+ const breakdown = `[applicable=${applicable} conform=${t.conform} non-conforming=${t.violate} could-not-judge=${t.cnj}]`;
48
+ if (t.cnj > 0) {
49
+ const reason = `${spec.ruleId}: ${t.cnj} oracle row(s) could not be judged — ${t.unjudged
50
+ .slice(0, 3)
51
+ .map((u) => `row#${u.row.n} (${u.note})`)
52
+ .join("; ")}. This is NOT a clean bill. ${breakdown}`;
53
+ return {
54
+ id: spec.id,
55
+ anchor: anchorFor(t.unjudged, "oracle:vp-by-tool-day158.csv"),
56
+ state: "could-not-judge",
57
+ verdict: reason,
58
+ reason,
59
+ };
60
+ }
61
+ if (t.violate > 0) {
62
+ const examples = t.violations
63
+ .slice(0, 3)
64
+ .map((v) => `row#${v.row.n} "${v.row.by.outil || v.row.by.table}" — ${v.note}`)
65
+ .join("; ");
66
+ return {
67
+ id: spec.id,
68
+ anchor: anchorFor(t.violations, "oracle:vp-by-tool-day158.csv"),
69
+ state: "non-conforming",
70
+ verdict: `${spec.ruleId}: ${t.violate} of ${applicable} applicable oracle rows VIOLATE (${spec.check}). ${breakdown} Examples: ${examples}.`,
71
+ };
72
+ }
73
+ return {
74
+ id: spec.id,
75
+ anchor: "oracle:vp-by-tool-day158.csv",
76
+ state: "present",
77
+ verdict: `${spec.ruleId}: ${t.conform} applicable oracle rows conform (${spec.check}); 0 violations. ${breakdown}`,
78
+ };
79
+ }
80
+ /**
81
+ * Wrap a predicate into an engine Detector. `rows` is injectable for tests;
82
+ * production reads the memoized real oracle. The `run(tree)` ARGUMENT IS IGNORED
83
+ * — the tree is .ts-only and does not carry the CSV.
84
+ */
85
+ export function makeOracleDetector(spec, rowsProvider = readOracle) {
86
+ return {
87
+ id: spec.id,
88
+ run: () => evaluate(spec, rowsProvider()),
89
+ };
90
+ }
91
+ // ---- small shared classifiers over oracle vocabulary --------------------
92
+ /** verbs that write (used by writer-authority / verb reasoning). */
93
+ export const WRITE_VERBS = new Set(["CREATE", "UPDATE", "DELETE", "TRANSITION", "UPSERT", "BULK-WRITE"]);
94
+ /** verbs that list/search (retrieval + pagination rules apply). */
95
+ export const LIST_VERBS = new Set(["READ-LIST", "SEARCH"]);
96
+ export function isMcpPublic(row) {
97
+ return row.by.surface === "MCP-public";
98
+ }
99
+ /** placeholder tokens that are a declared-but-empty answer. */
100
+ export function isPlaceholder(v) {
101
+ const t = v.trim().toLowerCase();
102
+ return t === "" || t === "tbd" || t === "?" || t === "~" || t === "n/a";
103
+ }
@@ -0,0 +1,43 @@
1
+ import type { Detector, DetectorResult, Tree } from "../../engine-types.js";
2
+ /** The standard both wave-2 files 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
+ export type SrcFile = Tree["files"][number];
5
+ export type SrcLine = SrcFile["lines"][number];
6
+ /** A registrable entry — structurally the RegistryEntry the orchestrator spreads
7
+ * into REGISTRY. We do NOT import registry.ts; we mirror its shape so the
8
+ * orchestrator owns the integration. */
9
+ export interface SourceDetectorEntry {
10
+ detector: Detector;
11
+ ruleId: string;
12
+ standardPin: string;
13
+ }
14
+ export interface Site {
15
+ rel: string;
16
+ n: number;
17
+ note: string;
18
+ }
19
+ export interface Scan {
20
+ conform: Site[];
21
+ violate: Site[];
22
+ cnj: Site[];
23
+ }
24
+ export declare function emptyScan(): Scan;
25
+ /** basename of a `rel` path, lower-cased. */
26
+ export declare function base(rel: string): string;
27
+ /** a source file we should judge — never a test or generated file. */
28
+ export declare function isJudgeable(f: SrcFile): boolean;
29
+ /** an EXECUTING line (not a comment-only line). Predicates read code, not prose —
30
+ * this is what makes the neuter-proof land on an executing line. */
31
+ export declare function code(l: SrcLine): boolean;
32
+ /** convex/ or mcp-server/src source. */
33
+ export declare function isConvexOrMcp(f: SrcFile): boolean;
34
+ /**
35
+ * Reduce a scan to ONE engine DetectorResult. Precedence for the SOURCE kind:
36
+ * a concrete VIOLATION site is decisive (non-conforming) and outranks an
37
+ * abstain; else any could-not-judge → could-not-judge WITH a reason; else any
38
+ * conform → present; else (zero applicable sites) → could-not-judge naming the
39
+ * empty scan (an instrument that matched nothing has NOT certified a clean bill).
40
+ */
41
+ export declare function decide(ruleId: string, id: string, check: string, scan: Scan, emptyReason: string): DetectorResult;
42
+ /** wrap a files-scan into a Detector that reads `tree.files`. */
43
+ export declare function srcDetector(ruleId: string, id: string, check: string, scanFn: (files: SrcFile[]) => Scan, emptyReason: string): Detector;
@@ -0,0 +1,82 @@
1
+ /** The standard both wave-2 files are pinned against (short hash a9d7877b). */
2
+ export const SOURCE_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15";
3
+ export function emptyScan() {
4
+ return { conform: [], violate: [], cnj: [] };
5
+ }
6
+ /** basename of a `rel` path, lower-cased. */
7
+ export function base(rel) {
8
+ return (rel.split("/").pop() ?? rel).toLowerCase();
9
+ }
10
+ /** a source file we should judge — never a test or generated file. */
11
+ export function isJudgeable(f) {
12
+ const b = base(f.rel);
13
+ return (!b.endsWith(".test.ts") &&
14
+ !f.rel.includes("__tests__") &&
15
+ !f.rel.includes("_generated/"));
16
+ }
17
+ /** an EXECUTING line (not a comment-only line). Predicates read code, not prose —
18
+ * this is what makes the neuter-proof land on an executing line. */
19
+ export function code(l) {
20
+ return !l.comment;
21
+ }
22
+ /** convex/ or mcp-server/src source. */
23
+ export function isConvexOrMcp(f) {
24
+ return /(^|\/)convex\//.test(f.rel) || /(^|\/)mcp-server\/src\//.test(f.rel);
25
+ }
26
+ /**
27
+ * Reduce a scan to ONE engine DetectorResult. Precedence for the SOURCE kind:
28
+ * a concrete VIOLATION site is decisive (non-conforming) and outranks an
29
+ * abstain; else any could-not-judge → could-not-judge WITH a reason; else any
30
+ * conform → present; else (zero applicable sites) → could-not-judge naming the
31
+ * empty scan (an instrument that matched nothing has NOT certified a clean bill).
32
+ */
33
+ export function decide(ruleId, id, check, scan, emptyReason) {
34
+ const nC = scan.conform.length;
35
+ const nV = scan.violate.length;
36
+ const nJ = scan.cnj.length;
37
+ const breakdown = `[conform=${nC} non-conforming=${nV} could-not-judge=${nJ}]`;
38
+ const fmt = (s) => s
39
+ .slice(0, 3)
40
+ .map((x) => `${x.rel}:${x.n} (${x.note})`)
41
+ .join("; ");
42
+ if (nV > 0) {
43
+ return {
44
+ id,
45
+ anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
46
+ state: "non-conforming",
47
+ verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
48
+ };
49
+ }
50
+ if (nJ > 0) {
51
+ const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}`;
52
+ return {
53
+ id,
54
+ anchor: `${scan.cnj[0].rel}:${scan.cnj[0].n}`,
55
+ state: "could-not-judge",
56
+ verdict: reason,
57
+ reason,
58
+ };
59
+ }
60
+ if (nC > 0) {
61
+ return {
62
+ id,
63
+ anchor: `${scan.conform[0].rel}:${scan.conform[0].n}`,
64
+ state: "present",
65
+ verdict: `${ruleId}: ${nC} applicable site(s) conform (${check}); 0 violations. ${breakdown} Conform: ${fmt(scan.conform)}.`,
66
+ };
67
+ }
68
+ return {
69
+ id,
70
+ anchor: "source:no-applicable-site",
71
+ state: "could-not-judge",
72
+ verdict: emptyReason,
73
+ reason: emptyReason,
74
+ };
75
+ }
76
+ /** wrap a files-scan into a Detector that reads `tree.files`. */
77
+ export function srcDetector(ruleId, id, check, scanFn, emptyReason) {
78
+ return {
79
+ id,
80
+ run: (tree) => decide(ruleId, id, check, scanFn(tree.files), emptyReason),
81
+ };
82
+ }
@@ -0,0 +1,27 @@
1
+ import type { Detector } from "../../engine-types.js";
2
+ import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ /** A DERIVED STRUCTURE discovered in the schema: a thin junction / index-bearing
4
+ * side table whose rows are computed from another table. */
5
+ interface Derived {
6
+ /** the derived table name. */
7
+ table: string;
8
+ /** the source table its rows are computed from (the FK target). */
9
+ source: string;
10
+ rel: string;
11
+ n: number;
12
+ }
13
+ /**
14
+ * Parse schema.ts defineTable blocks and select the DERIVED STRUCTURES: a thin
15
+ * side/junction table (≤4 fields) that carries a foreign-key v.id("<other>")
16
+ * AND is index-bearing. This is the junction / index-bearing side-table class
17
+ * R-44 names — deliberately NOT a rich domain table (tasks, missions) which
18
+ * carries many fields, and NOT a table that is its own FK target.
19
+ */
20
+ export declare function derivedStructures(files: SrcFile[]): Derived[];
21
+ export declare function scanR44(files: SrcFile[]): Scan;
22
+ export declare const R44: Detector;
23
+ export declare function scanR45(files: SrcFile[]): Scan;
24
+ export declare const R45: Detector;
25
+ /** In the form the orchestrator spreads into REGISTRY. */
26
+ export declare const SOURCE_BACKFILL_DETECTORS: SourceDetectorEntry[];
27
+ export {};