@vantageos/backend-doctor 0.1.14 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +66 -5
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +72 -0
  4. package/dist/build-stamp.d.ts +13 -0
  5. package/dist/build-stamp.js +76 -0
  6. package/dist/build-stamp.json +4 -0
  7. package/dist/caller-resolution.d.ts +222 -0
  8. package/dist/caller-resolution.js +1198 -0
  9. package/dist/cli.js +249 -17
  10. package/dist/contract.d.ts +25 -7
  11. package/dist/contract.js +52 -7
  12. package/dist/coverage-cli.js +8 -0
  13. package/dist/coverage.d.ts +1 -1
  14. package/dist/coverage.js +2 -2
  15. package/dist/detectors/oracle-axis1.d.ts +5 -3
  16. package/dist/detectors/oracle-axis1.js +28 -18
  17. package/dist/detectors/oracle-axis2.d.ts +6 -0
  18. package/dist/detectors/oracle-axis2.js +37 -22
  19. package/dist/detectors/predicates.d.ts +3 -0
  20. package/dist/detectors/predicates.js +93 -19
  21. package/dist/detectors/shared.js +8 -7
  22. package/dist/detectors/source/_ast.d.ts +92 -0
  23. package/dist/detectors/source/_ast.js +353 -0
  24. package/dist/detectors/source/_scan.js +4 -3
  25. package/dist/detectors/source/backfill.js +71 -18
  26. package/dist/detectors/source/contract.js +21 -13
  27. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  28. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  29. package/dist/detectors/source/guard-helper-flow.d.ts +134 -0
  30. package/dist/detectors/source/guard-helper-flow.js +1216 -0
  31. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  32. package/dist/detectors/source/identity-by-id.js +1783 -0
  33. package/dist/detectors/source/identity.d.ts +58 -2
  34. package/dist/detectors/source/identity.js +1158 -28
  35. package/dist/detectors/source/isolation.d.ts +11 -1
  36. package/dist/detectors/source/isolation.js +106 -25
  37. package/dist/detectors/source/jobs.js +178 -4
  38. package/dist/detectors/source/lifecycle.js +14 -2
  39. package/dist/detectors/source/local-identity.d.ts +26 -0
  40. package/dist/detectors/source/local-identity.js +661 -0
  41. package/dist/detectors/source/package-principal.d.ts +9 -0
  42. package/dist/detectors/source/package-principal.js +177 -0
  43. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  44. package/dist/detectors/source/r6-shapes.js +285 -0
  45. package/dist/detectors/source/readbudget.d.ts +1 -0
  46. package/dist/detectors/source/readbudget.js +358 -4
  47. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  48. package/dist/detectors/source/recipient-by-id.js +1108 -0
  49. package/dist/detectors/source/refusal.d.ts +14 -0
  50. package/dist/detectors/source/refusal.js +75 -2
  51. package/dist/detectors/source/security.d.ts +16 -0
  52. package/dist/detectors/source/security.js +680 -33
  53. package/dist/detectors/source/write-isolation.d.ts +8 -1
  54. package/dist/detectors/source/write-isolation.js +64 -30
  55. package/dist/detectors/source/writesafety.js +27 -14
  56. package/dist/detectors/standard/_doc.d.ts +1 -1
  57. package/dist/detectors/standard/_doc.js +5 -4
  58. package/dist/detectors/standard/r41-frontier.js +3 -2
  59. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  60. package/dist/detectors/standard/r46-returns-validator.js +48 -16
  61. package/dist/detectors/structural.d.ts +6 -7
  62. package/dist/detectors/structural.js +84 -48
  63. package/dist/not-checkable.d.ts +1 -1
  64. package/dist/not-checkable.js +11 -0
  65. package/dist/oracle.d.ts +92 -3
  66. package/dist/oracle.js +229 -12
  67. package/dist/registry.d.ts +8 -1
  68. package/dist/registry.js +74 -8
  69. package/dist/report.d.ts +11 -1
  70. package/dist/report.js +15 -2
  71. package/dist/rule-class.d.ts +85 -0
  72. package/dist/rule-class.js +218 -0
  73. package/dist/state-rank.d.ts +5 -0
  74. package/dist/state-rank.js +9 -0
  75. package/dist/tally.d.ts +13 -5
  76. package/dist/tally.js +15 -13
  77. package/dist/target.d.ts +32 -0
  78. package/dist/target.js +173 -0
  79. package/dist/violation-count.d.ts +48 -0
  80. package/dist/violation-count.js +94 -0
  81. package/fixtures/backend-standard-2825122.md +722 -0
  82. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  83. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  84. package/fixtures/cr1-classes/surface.ts +206 -0
  85. package/fixtures/cr1-empty/nothing.ts +1 -0
  86. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  87. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  88. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  89. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  90. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  91. package/fixtures/rule-classes-2825122.json +228 -0
  92. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  93. package/package.json +2 -2
@@ -1,5 +1,12 @@
1
1
  import type { Detector } from "../../engine-types.js";
2
- import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
2
+ import { type Scan, type Site, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ /** R-51's scan plus the public writes found OUTSIDE its scope (no no-org throw,
4
+ * no typed-unauthorised return, no marker). The list never reaches a verdict. */
5
+ export interface R51Scan {
6
+ scan: Scan;
7
+ outOfScope: Site[];
8
+ }
3
9
  export declare function scanR51(files: SrcFile[]): Scan;
10
+ export declare function scanR51Detailed(files: SrcFile[]): R51Scan;
4
11
  export declare const R51: Detector;
5
12
  export declare const SOURCE_WRITE_ISOLATION_DETECTORS: SourceDetectorEntry[];
@@ -1,4 +1,5 @@
1
- import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
1
+ import { SCOPE_REFUSED_RETURN } from "./isolation.js";
2
+ import { code, decide, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, } from "./_scan.js";
2
3
  const CUSTOM_MUTATION_OPEN = /(?:export\s+)?const\s+([A-Za-z0-9_]+)\s*=\s*customMutation\s*\(/;
3
4
  const CUSTOM_QUERY_DEF = /(?:export\s+)?const\s+([A-Za-z0-9_]+)\s*=\s*customQuery\s*\(/;
4
5
  const FIRST_IDENT = /([A-Za-z_][A-Za-z0-9_]*)/;
@@ -6,7 +7,26 @@ const BASE_PUBLIC_MUTATION = "mutation";
6
7
  const BASE_INTERNAL_MUTATION = "internalMutation";
7
8
  // A no-org throw a write inherits: an org-keyed ConvexError, or a write/org gate
8
9
  // helper that throws when no org is resolved.
9
- const ORG_THROW = /throw\b[^;]*\b(?:NO_ORG|ORG_REQUIRED|RBAC_DENIED|no org|missing org|organization is required|not in (?:an )?org)/i;
10
+ //
11
+ // `RBAC_DENIED` is NOT in this vocabulary, and that is the standard's own line, not
12
+ // a convenience. The pinned standard (backend-standard-4bcd605.md :652) says "a
13
+ // mutation that refuses an unauthorised write via `requireOrgAdmin` throwing
14
+ // `RBAC_DENIED` is **correct**", and R-51's own text (:655-656) keeps R-16's
15
+ // `{code,field/scope,message}` refusal of a chosen unauthorised action correct and
16
+ // names R-51's throw as NO_ORG (an org-keyed ConvexError or
17
+ // requireWriteAccess/requireWorkspaceId/getEffectiveWorkspaceId). Counting every
18
+ // RBAC_DENIED as a no-org throw contradicted both lines (isolation.ts header
19
+ // :16-19 said CORRECT while this regex said RED). An RBAC_DENIED raised BECAUSE the
20
+ // org is missing is still caught — by ORG_KEYED_GUARDED_THROW below, which reads
21
+ // the CONDITION, not the code word.
22
+ const ORG_THROW = /throw\b[^;]*\b(?:NO_ORG|ORG_REQUIRED|no org|missing org|organization is required|not in (?:an )?org)/i;
23
+ // A throw (any code) whose governing `if` is an ORG-keyed absence test — the
24
+ // no-org path itself, whatever the error string says:
25
+ // if (!scope.isMaster && scope.orgSlug === null) { throw new ConvexError("RBAC_DENIED…") }
26
+ // The condition must test an org/workspace field for absence; an identity-absence
27
+ // test (`if (!identity)`) is a caller with no credential at all, not the signed-in
28
+ // pre-org shell R-51 protects, and is R-16's refusal.
29
+ const ORG_KEYED_GUARDED_THROW = /if\s*\((?:[^()]|\([^()]*\))*?(?:!\s*(?:[A-Za-z_$][\w$]*\.)?(?:org|orgId|orgSlug|workspaceId|workspace)\b|\b(?:org|orgId|orgSlug|workspaceId)\s*(?:===|==)\s*(?:null|undefined))(?:[^()]|\([^()]*\))*\)\s*\{?\s*throw\b/i;
10
30
  const WRITE_GATE_HELPER = /\b(?:requireWriteAccess|requireWorkspaceId|requireOrgId|getEffectiveWorkspaceId|getOrgIdOrThrow|ensureCurrentOrg)\s*\(/;
11
31
  // a gate helper generally (any require*/ensure*/assert* access check in a wrapper).
12
32
  const GATE_HELPER = /\b(?:require|ensure|assert)[A-Z][A-Za-z0-9_]*\s*\(/;
@@ -59,12 +79,17 @@ function namedValueDomination(before) {
59
79
  function throwDomination(blockText) {
60
80
  const t1 = ORG_THROW.exec(blockText);
61
81
  const t2 = WRITE_GATE_HELPER.exec(blockText);
62
- const throwIdx = Math.min(t1 ? t1.index : Number.POSITIVE_INFINITY, t2 ? t2.index : Number.POSITIVE_INFINITY);
82
+ const t3 = ORG_KEYED_GUARDED_THROW.exec(blockText);
83
+ const throwIdx = Math.min(t1 ? t1.index : Number.POSITIVE_INFINITY, t2 ? t2.index : Number.POSITIVE_INFINITY, t3 ? t3.index : Number.POSITIVE_INFINITY);
63
84
  if (!Number.isFinite(throwIdx))
64
85
  return "none";
65
86
  const before = blockText.slice(0, throwIdx);
66
87
  if (namedValueDomination(before))
67
88
  return "dominated";
89
+ // Form C — the VP pre-org `if (scope.refused) return …` typed value (shared
90
+ // definition with R-50, isolation.ts SCOPE_REFUSED_RETURN).
91
+ if (SCOPE_REFUSED_RETURN.test(before))
92
+ return "dominated";
68
93
  if (!TYPED_EMPTY_RETURN.test(before))
69
94
  return "none";
70
95
  return NO_ORG_GUARD.test(before) ? "dominated" : "ambiguous";
@@ -169,6 +194,9 @@ function mutationBlock(lines, i) {
169
194
  return { lines: body, unterminated: seenOpen };
170
195
  }
171
196
  export function scanR51(files) {
197
+ return scanR51Detailed(files).scan;
198
+ }
199
+ export function scanR51Detailed(files) {
172
200
  const scan = emptyScan();
173
201
  const builders = discoverWriteBuilders(files);
174
202
  // Public writes that matched a definition opener but exhibit NO org-throw, NO
@@ -242,19 +270,28 @@ export function scanR51(files) {
242
270
  .map((l) => l.raw)
243
271
  .join("\n");
244
272
  const blockAll = block.lines.map((l) => l.raw).join("\n");
245
- const throwsOnNoOrg = ORG_THROW.test(blockText) || WRITE_GATE_HELPER.test(blockText);
273
+ const throwsOnNoOrg = ORG_THROW.test(blockText) ||
274
+ WRITE_GATE_HELPER.test(blockText) ||
275
+ ORG_KEYED_GUARDED_THROW.test(blockText);
246
276
  const typedUnauth = TYPED_UNAUTH_RETURN.test(blockText);
247
277
  const hasMarker = CONTRACT_MARKER.test(blockAll);
248
278
  if (!throwsOnNoOrg && !typedUnauth && !hasMarker) {
249
- // A bare public write with no visible org isolation. Not a gated
250
- // subject — but recorded so a tree whose ENTIRE public-write surface
251
- // is bare is judged (CNJ-with-sites) rather than falsely abstained.
279
+ // A public write that neither throws on the no-org path, nor returns a
280
+ // typed unauthorised result, nor carries a marker is NOT an R-51
281
+ // subject. The pinned standard's red (backend-standard-4bcd605.md :656,
282
+ // clause b) is "a bare public mutation whose no-org path THROWS" with
283
+ // the two readable absences; a write with no no-org throw cannot
284
+ // crash a pre-org shell, which is the whole of R-51. Whether such a
285
+ // write resolves its caller at all is CR-1's control
286
+ // (src/caller-resolution.ts), which drives the exit code — the
287
+ // instrument still goes red on an unguarded write, under the control
288
+ // whose question it is. Counted only to word the empty abstain.
252
289
  ungated.push({
253
290
  rel: f.rel,
254
291
  n: block.lines[0].n,
255
- note: `${fn}: public mutation with no org-throw, no typed-unauthorized return, and no write-contract marker — an UNMARKED public write. No org-isolation handling and no client contract is visible in convex/; an instrument that abstains here protects nothing (a fully-unmarked write is exactly the surface a pre-org client shell can issue). NON-CONFORMING until the author isolates org via a fail-closed path, returns a typed unauthorized result, or records a write-contract marker`,
292
+ note: `${fn}: public mutation with no no-org throw, no typed-unauthorized return, no write-contract marker — not an R-51 subject (standard 4bcd605 :656 red requires a no-org throw); caller resolution is CR-1's control`,
256
293
  });
257
- continue; // not a gated subject
294
+ continue; // not a subject
258
295
  }
259
296
  if (typedUnauth) {
260
297
  // The write returns a shape on the no-org path — a pre-org client can render
@@ -295,7 +332,7 @@ export function scanR51(files) {
295
332
  scan.cnj.push({
296
333
  rel: f.rel,
297
334
  n: block.lines[0].n,
298
- note: `${fn}: public mutation throws on the missing-org path AND carries a write-contract marker — the marker records a claim that a client cannot reach this write before an org exists, which convex/ cannot verify. R-51 refuses (a reviewer checks the marker's claim) — never a silent green`,
335
+ note: `${fn}: public mutation throws on the missing-org path AND carries a write-contract marker — the marker records a claim that a client cannot reach this write before an org exists, which convex/ cannot verify. R-51 refuses (a reviewer checks the marker's claim) — never a silent green. Why R-50 accepts its marker and R-51 does not: the pinned standard decides it per rule — R-50's green includes a query that "documents a client contract" (4bcd605 :653), while R-51 lists "a write … that carries a write-contract marker whose unreachability claim the detector cannot verify" under could-not-judge, never green (4bcd605 :656)`,
299
336
  });
300
337
  }
301
338
  else {
@@ -313,27 +350,24 @@ export function scanR51(files) {
313
350
  }
314
351
  }
315
352
  }
316
- // A tree whose ENTIRE public-write surface is ungated (no throw / typed-unauth /
317
- // marker on ANY write) leaves the scan otherwise empty. The legacy abstain then
318
- // claimed "no public mutation matched" — FALSE when bare public writes exist, and
319
- // a silent pass over a fully-ungated backend. Surface those writes as
320
- // could-not-judge sites so the verdict is CNJ-with-sites, not a misleading empty
321
- // abstain. Gated on "scan otherwise empty" so a tree that already produced
322
- // subjects (VP: 7 marker CNJ) is byte-for-byte unchanged — the bare-write
323
- // disposition in a mixed tree is out of this fix's scope.
324
- // An UNMARKED bare public write (no throw / typed-unauth / marker) is
325
- // NON-CONFORMING, never an abstention — whether the scan is otherwise empty (a
326
- // fully-unmarked public-write surface) OR mixed (bare writes alongside
327
- // gated/marker subjects). Laurent doctrine: "an instrument that cannot go red
328
- // protects nothing; CNJ is for UNREADABLE input only." A bare write is fully
329
- // READABLE — its two absences (no isolation, no contract) are the defect, not a
330
- // could-not-read. Push to violate; the author's exit is a fail-closed path, a
331
- // typed unauthorized return, or a recorded write-contract marker.
332
- for (const u of ungated)
333
- scan.violate.push(u);
334
- return scan;
353
+ // Unmarked public writes are NOT pushed anywhere (standard 4bcd605 :656 — the red
354
+ // requires a no-org throw). Until 0.1.16 they were pushed to `violate`; that
355
+ // obligation is CR-1's now (a write resolving no caller is a CR-1 violation).
356
+ return { scan, outOfScope: ungated };
335
357
  }
336
- export const R51 = srcDetector("R-51", "R-51 a public mutation returns a typed unauthorized result on the no-org path, never an uncaught throw a pre-org client shell cannot render", "a public write either returns a typed unauthorized result / documents its client contract, or a human confirms a pre-org client cannot reach it — it never throws NO_ORG on a path an ordinary render can hit", scanR51, "R-51: no public mutation matched in the scanned tree — no write to certify. NOT a clean bill.");
358
+ const R51_ID = "R-51 a public mutation returns a typed unauthorized result on the no-org path, never an uncaught throw a pre-org client shell cannot render";
359
+ const R51_CHECK = "a public write either returns a typed unauthorized result / documents its client contract, or a human confirms a pre-org client cannot reach it — it never throws NO_ORG on a path an ordinary render can hit";
360
+ export const R51 = {
361
+ id: R51_ID,
362
+ run: (tree) => {
363
+ const { scan, outOfScope } = scanR51Detailed(tree.files);
364
+ const n = outOfScope.length;
365
+ const empty = n > 0
366
+ ? `R-51: no R-51 subject in the scanned tree — ${n} public mutation(s) matched, none throws on the no-org path, returns a typed unauthorized result, or carries a write-contract marker (out of scope per standard 4bcd605 :656; caller resolution is CR-1's control). NOT a clean bill.`
367
+ : "R-51: no public mutation matched in the scanned tree — no write to certify. NOT a clean bill.";
368
+ return decide("R-51", R51_ID, R51_CHECK, scan, empty);
369
+ },
370
+ };
337
371
  export const SOURCE_WRITE_ISOLATION_DETECTORS = [
338
372
  { detector: R51, ruleId: "R-51", standardPin: SOURCE_STANDARD_PIN },
339
373
  ];
@@ -131,20 +131,33 @@ export function scanR18(files) {
131
131
  // them into one mutation "would game the regex without changing atomicity"
132
132
  // (okfBundleNode importOkfBundle shape).
133
133
  // An action that would be flagged non-conforming may carry an `atomicity-exception:`
134
- // marker naming EXACTLY ONE of those two shapes. Per Eta's three conditions:
135
- // COND 1 — the disposition is COULD-NOT-JUDGE, never conform. A marker records a
136
- // CLAIM that reaches a human reviewer; it never closes the rule green (same as
137
- // the R-51 marker, #141).
138
- // COND 2 — the marker MUST name which shape it claims, because the two are checked
139
- // differently and the reviewer must know which question to ask.
134
+ // marker naming EXACTLY ONE of those two shapes. Per Eta's three conditions
135
+ // (superseded on the disposition axis by task k179jnhx, §23 — judgement retired
136
+ // as an acceptance kind, 2026-09-02):
137
+ // COND 1 — originally "the disposition is COULD-NOT-JUDGE, never conform: a
138
+ // marker records a CLAIM that reaches a human reviewer" (Eta ruling
139
+ // 2026-08-31, mirroring the R-51 marker #141). The standard's §23 rewrite
140
+ // retires that disposition: a well-formed, COMPLETE claim (COND 2 + COND 3
141
+ // both satisfied — the shape named, and its checkable sub-conditions all
142
+ // present) IS the written justification the marker was always meant to
143
+ // record, exactly as JUSTIFIÉ:/JUSTIFIED: is elsewhere — so it now decides
144
+ // mechanically to PRESENT, never could-not-judge. An INCOMPLETE claim still
145
+ // buys nothing (COND 3, unchanged): the site stays non-conforming until the
146
+ // claim is well-formed.
147
+ // COND 2 — the marker MUST name which shape it claims, because the three are
148
+ // checked differently and the check must know which sub-conditions to ask for.
140
149
  // COND 3 — the dangerous shape is (2): "unbounded loop, each write independently
141
150
  // atomic" is a sentence anyone can write about a split invariant. So shape (2)
142
151
  // is only honoured when the region ALSO names the loop (for/for-of/.forEach/.map)
143
152
  // AND states the independence (no invariant spans two writes). A shape-(2) marker
144
153
  // that names neither the loop nor the independence is INCOMPLETE — it does not buy
145
- // could-not-judge; the site stays non-conforming until the claim is well-formed.
146
- // A well-formed marker → could-not-judge (the reviewer verifies the per-site claim).
147
- // No marker, an unknown shape, or an incomplete shape-(2) claim → non-conforming.
154
+ // present; the site stays non-conforming until the claim is well-formed.
155
+ // A well-formed, COMPLETE marker → present (the claim itself is the recorded
156
+ // justification; the control decides on the claim's structural completeness,
157
+ // never on a person's opinion of whether it is sound).
158
+ // No marker, an unknown shape, or an incomplete claim → non-conforming.
159
+ // could-not-judge is reserved for a site the instrument could not read (never
160
+ // reached here — every site below either parsed a marker or did not).
148
161
  // ---------------------------------------------------------------------------
149
162
  const ACTION_OPEN = /export\s+const\s+(\w+)\s*=\s*action\s*\(/;
150
163
  const NEXT_EXPORT = /export\s+const\s+\w+\s*=/;
@@ -232,16 +245,16 @@ export function scanR29(files) {
232
245
  ? shapeReingest
233
246
  : false;
234
247
  if (shape && shapeComplete) {
235
- // COND 1: a well-formed marker yields COULD-NOT-JUDGE, never conform —
236
- // the claim is deferred to the reviewer, who verifies it per site.
237
- scan.cnj.push({
248
+ // COND 1 (§23 rewrite, task k179jnhx): a well-formed, COMPLETE marker
249
+ // is itself the written justification — decided mechanically, present.
250
+ scan.conform.push({
238
251
  rel: f.rel,
239
252
  n: firstMut || openLine,
240
- note: `action ${inAction} issues ${count} ctx.runMutation calls AND carries an atomicity-exception marker claiming shape "${shape}" — ${shape === SHAPE_PER_ITEM_LOOP
253
+ note: `action ${inAction} issues ${count} ctx.runMutation calls AND carries a COMPLETE atomicity-exception marker claiming shape "${shape}" — ${shape === SHAPE_PER_ITEM_LOOP
241
254
  ? "the region names its loop and states the per-item writes are independently atomic (no invariant spans two)"
242
255
  : shape === SHAPE_GATE_REINGEST
243
256
  ? "a leading gate runMutation, a supersede/dedup keyed on a stable namespace, and a recovering re-insert loop, with the convergence stated explicitly — a re-ingest of the same stable key converges on one current set"
244
- : "exactly two runMutations, the first a gate (require*/assert*/bind*) preceding a single atomic write, which R-29's <=1 bar has no room for"}. R-29 refuses (a reviewer verifies the claim per site) — never a silent green (Eta ruling 2026-08-31)`,
257
+ : "exactly two runMutations, the first a gate (require*/assert*/bind*) preceding a single atomic write, which R-29's <=1 bar has no room for"}. The marker's structural completeness IS the written justification (§23) — decided by the claim's presence, never a person's opinion of it`,
245
258
  });
246
259
  }
247
260
  else if (shape === SHAPE_GATE_REINGEST) {
@@ -1,6 +1,6 @@
1
1
  import type { Detector, DetectorResult } from "../../engine-types.js";
2
2
  /** The vendored-fixture basename used as the anchor file for every doc site. */
3
- export declare const STANDARD_FILE = "backend-standard-4bcd605.md";
3
+ export declare const STANDARD_FILE = "backend-standard-2825122.md";
4
4
  export declare const VENDORED_STANDARD_PATH: string;
5
5
  /** The standard all four are pinned against (short hash a9d7877b). */
6
6
  export declare const STANDARD_DOC_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) \u00A714/\u00A711/\u00A710/\u00A73";
@@ -2,7 +2,7 @@
2
2
  // detectors that read THE STANDARD ITSELF as their material — R-42, R-15, R-32,
3
3
  // R-39. Unlike the source-tree detectors (which read convex/ via `tree.files`)
4
4
  // and the oracle detectors (which read the CSV), these read the VENDORED standard
5
- // fixture `fixtures/backend-standard-4bcd605.md` — the material at the pin. The
5
+ // fixture `fixtures/backend-standard-2825122.md` — the material at the pin. The
6
6
  // engine's contract types are DERIVED from engine-types.ts, never re-spelled.
7
7
  //
8
8
  // THE LAW (the one that caught R-5): every predicate is derived from the ACTUAL
@@ -12,8 +12,9 @@
12
12
  import { readFileSync } from "node:fs";
13
13
  import { dirname, join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
+ import { itemCount, withCount } from "../../violation-count.js";
15
16
  /** The vendored-fixture basename used as the anchor file for every doc site. */
16
- export const STANDARD_FILE = "backend-standard-4bcd605.md";
17
+ export const STANDARD_FILE = "backend-standard-2825122.md";
17
18
  /** The vendored standard at the pin — DATA on the same footing as the oracle CSV.
18
19
  * Resolved locally (not via coverage.ts) so this module carries no import cycle
19
20
  * with registry.ts. Same file coverage.ts's VENDORED_STANDARD_PATH points at. */
@@ -136,12 +137,12 @@ export function decideDoc(ruleId, id, check, scan, emptyReason) {
136
137
  .map((x) => `${STANDARD_FILE}:${x.n} (${x.note})`)
137
138
  .join("; ");
138
139
  if (nV > 0) {
139
- return {
140
+ return withCount({
140
141
  id,
141
142
  anchor: `${STANDARD_FILE}:${scan.violate[0].n}`,
142
143
  state: "non-conforming",
143
144
  verdict: `${ruleId}: ${nV} STATIC violation(s) in the standard document (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform: ${fmt(scan.conform)}.` : ""}`,
144
- };
145
+ }, itemCount(nV));
145
146
  }
146
147
  if (nJ > 0) {
147
148
  const reason = `${ruleId}: static half clean (${nC} conform); ${nJ} site(s) owe a human/gate verdict — ${fmt(scan.cnj)}. This is NOT a clean bill: GREEN is the gate half surfaced per site, not the detection running. ${breakdown}`;
@@ -24,6 +24,7 @@
24
24
  import { readFileSync } from "node:fs";
25
25
  import { dirname, join } from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
+ import { itemCount, withCount } from "../../violation-count.js";
27
28
  import { readStandard, STANDARD_FILE, toLines } from "./_doc.js";
28
29
  /** prefix → { the sibling fixture basename, its recorded upstream blob hash }.
29
30
  * The blob hash is the PINS.txt record — a vendored document that cannot be
@@ -154,12 +155,12 @@ export function decideR41(r) {
154
155
  const deadList = r.dead
155
156
  .map((c) => `${c.id} (cited ${site(c)}) not defined in ${SIBLING[c.prefix].file}`)
156
157
  .join("; ");
157
- return {
158
+ return withCount({
158
159
  id: R41_ID,
159
160
  anchor: site(r.dead[0]),
160
161
  state: "non-conforming",
161
162
  verdict: `R-41: ${r.dead.length} DEAD POINTER(S) — a cited frontier id resolves to no definition in its sibling (a dead pointer is a red, not a note). ${counts} Dead: ${deadList}. Cited: ${citedList}.`,
162
- };
163
+ }, itemCount(r.dead.length));
163
164
  }
164
165
  return {
165
166
  id: R41_ID,
@@ -23,6 +23,14 @@ export interface DocValidatorMatch {
23
23
  /** 1-based line span of the enclosing export — where a projection reason lives. */
24
24
  exportStartLine: number;
25
25
  exportEndLine: number;
26
+ /** 1-based line spans of every SHARED CONSTANT the export's `returns:` resolved
27
+ * through to reach this object (`returns: externalOpenReturns` →
28
+ * `const externalOpenReturns = v.object({…})`). A `returns-projection:` reason
29
+ * written on that constant governs every export that uses it. */
30
+ constSpans: {
31
+ start: number;
32
+ end: number;
33
+ }[];
26
34
  }
27
35
  export declare function extractDocMatches(name: string, text: string): DocValidatorMatch[];
28
36
  export declare function projectionReason(rawLines: string[], startLine: number, endLine: number): string | undefined;
@@ -37,6 +37,7 @@
37
37
  // (non-conforming / could-not-judge / present / absent) — no fifth state,
38
38
  // no second mechanism: the detector reads the tree the engine hands it.
39
39
  import * as ts from "typescript";
40
+ import { siteCount, withCount } from "../../violation-count.js";
40
41
  /** The standard this detector is pinned against (the re-pinned instrument pin). */
41
42
  export const R46_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §16 R-46";
42
43
  // ---------------------------------------------------------------------------
@@ -160,17 +161,35 @@ export function extractDocMatches(name, text) {
160
161
  const matches = [];
161
162
  const lineOf = (pos) => sf.getLineAndCharacterOfPosition(pos).line + 1;
162
163
  const constMap = new Map();
164
+ const constSpan = new Map();
163
165
  function collectConsts(node) {
164
166
  if (ts.isVariableStatement(node)) {
165
167
  for (const decl of node.declarationList.declarations) {
166
168
  if (ts.isIdentifier(decl.name) && decl.initializer) {
167
169
  constMap.set(decl.name.text, decl.initializer);
170
+ constSpan.set(decl.name.text, {
171
+ start: lineOf(node.getStart(sf)),
172
+ end: lineOf(node.getEnd()),
173
+ });
168
174
  }
169
175
  }
170
176
  }
171
177
  ts.forEachChild(node, collectConsts);
172
178
  }
173
179
  collectConsts(sf);
180
+ /** The span of every const an expression resolves THROUGH (the chain a
181
+ * `returns: X` takes to reach its validator). */
182
+ function constChain(expr) {
183
+ const out = [];
184
+ let cur = expr;
185
+ for (let d = 0; d <= 10 && ts.isIdentifier(cur) && constMap.has(cur.text); d++) {
186
+ const span = constSpan.get(cur.text);
187
+ if (span)
188
+ out.push(span);
189
+ cur = constMap.get(cur.text);
190
+ }
191
+ return out;
192
+ }
174
193
  function resolve(expr, depth = 0) {
175
194
  if (depth > 10)
176
195
  return expr;
@@ -179,10 +198,11 @@ export function extractDocMatches(name, text) {
179
198
  }
180
199
  return expr;
181
200
  }
182
- function walkForDocObjects(expr, out, exportName, alias, span, depth = 0) {
201
+ function walkForDocObjects(expr, out, exportName, alias, span, depth = 0, consts = []) {
183
202
  if (depth > 20)
184
203
  return;
185
204
  const nextAlias = ts.isIdentifier(expr) ? expr.text : alias;
205
+ const chain = [...consts, ...constChain(expr)];
186
206
  const resolved = resolve(expr);
187
207
  if (ts.isCallExpression(resolved) &&
188
208
  ts.isPropertyAccessExpression(resolved.expression)) {
@@ -207,11 +227,12 @@ export function extractDocMatches(name, text) {
207
227
  line: lineOf(objArg.getStart(sf)),
208
228
  exportStartLine: span.start,
209
229
  exportEndLine: span.end,
230
+ constSpans: chain,
210
231
  });
211
232
  }
212
233
  for (const prop of objArg.properties) {
213
234
  if (ts.isPropertyAssignment(prop)) {
214
- walkForDocObjects(prop.initializer, out, exportName, undefined, span, depth + 1);
235
+ walkForDocObjects(prop.initializer, out, exportName, undefined, span, depth + 1, chain);
215
236
  }
216
237
  }
217
238
  }
@@ -219,7 +240,7 @@ export function extractDocMatches(name, text) {
219
240
  }
220
241
  if (method === "union" || method === "array" || method === "optional") {
221
242
  for (const arg of resolved.arguments) {
222
- walkForDocObjects(arg, out, exportName, nextAlias, span, depth + 1);
243
+ walkForDocObjects(arg, out, exportName, nextAlias, span, depth + 1, chain);
223
244
  }
224
245
  return;
225
246
  }
@@ -348,18 +369,29 @@ export function scanR46(files) {
348
369
  }
349
370
  // include the contiguous leading-comment block above the export — the
350
371
  // natural place a `returns-projection:` reason is written for the read path.
351
- let leadStart = m.exportStartLine;
352
- while (leadStart > 1) {
353
- const prev = (rawLines[leadStart - 2] ?? "").trim();
354
- if (prev.startsWith("//") ||
355
- prev.startsWith("*") ||
356
- prev.startsWith("/*")) {
357
- leadStart -= 1;
372
+ // The same holds for every SHARED CONSTANT the export's `returns:` resolves
373
+ // through: a reason written on `const externalOpenReturns = v.object(…)`
374
+ // is the author's written reason for every export that returns it. A
375
+ // reason on an UNRELATED const (one this export does not resolve through)
376
+ // governs nothing here.
377
+ const withLead = (start) => {
378
+ let lead = start;
379
+ while (lead > 1) {
380
+ const prev = (rawLines[lead - 2] ?? "").trim();
381
+ if (prev.startsWith("//") ||
382
+ prev.startsWith("*") ||
383
+ prev.startsWith("/*")) {
384
+ lead -= 1;
385
+ }
386
+ else
387
+ break;
358
388
  }
359
- else
360
- break;
361
- }
362
- const reason = projectionReason(rawLines, leadStart, m.exportEndLine);
389
+ return lead;
390
+ };
391
+ const reason = projectionReason(rawLines, withLead(m.exportStartLine), m.exportEndLine) ??
392
+ m.constSpans
393
+ .map((c) => projectionReason(rawLines, withLead(c.start), c.end))
394
+ .find((r) => r !== undefined);
363
395
  if (reason) {
364
396
  inventory.push({
365
397
  rel: f.rel,
@@ -425,12 +457,12 @@ export function decideR46(cov) {
425
457
  }
426
458
  if (missing.length > 0) {
427
459
  const first = missing[0];
428
- return {
460
+ return withCount({
429
461
  id: R46_ID,
430
462
  anchor: `${first.rel}:${first.line}`,
431
463
  state: "non-conforming",
432
464
  verdict: `R-46: ${missing.length} returns validator(s) enumerate a stored document but OMIT a schema-declared field with NO written projection reason (${R46_CHECK}). ${breakdown}\n=== coverage inventory ===\n${inventory}\n=== end inventory ===`,
433
- };
465
+ }, siteCount(missing));
434
466
  }
435
467
  if (cov.findings.length > 0) {
436
468
  const first = cov.findings[0];
@@ -1,14 +1,13 @@
1
1
  import type { Detector } from "../engine-types.js";
2
- import { type OracleRow } from "../oracle.js";
2
+ import { type OracleRow, type WriterTierTable } from "../oracle.js";
3
3
  /**
4
4
  * R-14 — inventory-schema parity, contract → inventory, one direction.
5
5
  * A merged reader/writer authority column is "non-conforming by construction"
6
- * (§12). The oracle's column 9 `rbac_qui` is that single merged column.
6
+ * (§12). Writer authority is a separate column only when the side-car declares it.
7
7
  */
8
- export declare function makeR14(columns?: readonly string[]): Detector;
8
+ export declare function makeR14(columns?: readonly string[], rowsProvider?: () => OracleRow[], sideCar?: () => WriterTierTable): Detector;
9
9
  /**
10
- * R-38 — the completeness gate over the thirteen §2 fields. The oracle cannot
11
- * satisfy the contract because reader and writer authority are not two separate
12
- * fields (both map to `rbac_qui`).
10
+ * R-38 — the completeness gate over the thirteen §2 fields. Writer authority is
11
+ * complete only when the side-car records a tier for every write tool.
13
12
  */
14
- export declare function makeR38(columns?: readonly string[], rowsProvider?: () => OracleRow[]): Detector;
13
+ export declare function makeR38(columns?: readonly string[], rowsProvider?: () => OracleRow[], sideCar?: () => WriterTierTable): Detector;