@vantageos/backend-doctor 0.1.13 → 0.2.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 (58) hide show
  1. package/README.md +19 -2
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +54 -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 +1195 -0
  9. package/dist/cli.js +217 -10
  10. package/dist/coverage-cli.js +8 -0
  11. package/dist/crud-coverage.d.ts +81 -0
  12. package/dist/crud-coverage.js +375 -0
  13. package/dist/detectors/oracle-axis1.d.ts +5 -3
  14. package/dist/detectors/oracle-axis1.js +24 -15
  15. package/dist/detectors/oracle-axis2.js +5 -6
  16. package/dist/detectors/predicates.d.ts +3 -0
  17. package/dist/detectors/predicates.js +81 -16
  18. package/dist/detectors/shared.js +5 -5
  19. package/dist/detectors/source/_scan.js +6 -1
  20. package/dist/detectors/source/backfill.js +9 -1
  21. package/dist/detectors/source/guard-helper-flow.d.ts +112 -0
  22. package/dist/detectors/source/guard-helper-flow.js +1173 -0
  23. package/dist/detectors/source/identity.d.ts +51 -2
  24. package/dist/detectors/source/identity.js +815 -14
  25. package/dist/detectors/source/isolation.d.ts +11 -1
  26. package/dist/detectors/source/isolation.js +106 -25
  27. package/dist/detectors/source/jobs.js +165 -2
  28. package/dist/detectors/source/readbudget.d.ts +1 -0
  29. package/dist/detectors/source/readbudget.js +67 -4
  30. package/dist/detectors/source/security.d.ts +16 -0
  31. package/dist/detectors/source/security.js +225 -7
  32. package/dist/detectors/source/write-isolation.d.ts +8 -1
  33. package/dist/detectors/source/write-isolation.js +64 -30
  34. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  35. package/dist/detectors/standard/r46-returns-validator.js +45 -14
  36. package/dist/detectors/structural.js +14 -10
  37. package/dist/oracle.d.ts +92 -3
  38. package/dist/oracle.js +229 -12
  39. package/dist/registry.d.ts +7 -0
  40. package/dist/registry.js +48 -6
  41. package/dist/report.d.ts +11 -1
  42. package/dist/report.js +15 -2
  43. package/dist/state-rank.d.ts +5 -0
  44. package/dist/state-rank.js +9 -0
  45. package/dist/tally.d.ts +35 -0
  46. package/dist/tally.js +76 -8
  47. package/dist/target.d.ts +32 -0
  48. package/dist/target.js +173 -0
  49. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  50. package/fixtures/cr1-classes/surface.ts +206 -0
  51. package/fixtures/cr1-empty/nothing.ts +1 -0
  52. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  53. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  54. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  55. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  56. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  57. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  58. package/package.json +2 -2
@@ -22,7 +22,7 @@
22
22
  // abstains (never a silent pass). Every zero-count branch names the real site it
23
23
  // matched so the branch is provably reachable.
24
24
  import { existsSync, readFileSync } from "node:fs";
25
- import { join } from "node:path";
25
+ import { dirname, join, relative, resolve } from "node:path";
26
26
  /** The standard all six are pinned against (short hash a9d7877b). */
27
27
  export const SOURCE_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15";
28
28
  function emptyScan() {
@@ -251,6 +251,181 @@ export function scanR33(files) {
251
251
  const INBOUND_JWT = /claims\.(aud|iss)\b|\.iss\b.*mismatch|verify\w*jwt/i;
252
252
  const AUD_ASSERT = /VP_CLERK_EXPECTED_AUD|aud claim mismatch|audMatches|expectedAud/i;
253
253
  const OUTBOUND_PASSTHROUGH = /["'`]?authorization["'`]?\s*[:=][^,\n]*(headers\.get\(\s*["'`]authorization|req(uest)?\.headers|callerToken|incomingToken|passthrough)/i;
254
+ // An `audience` KEY in the options object of a JWT-verify call is an audience
255
+ // assertion, judged on its VALUE, never on a name list (PR #44 follow-up: VP
256
+ // 30e821b mcp-server/src/auth.ts:882-885 `jwtVerify(token, jwks, { issuer,
257
+ // audience: CLERK_JWT_AUDIENCE })` with `CLERK_JWT_AUDIENCE =
258
+ // process.env.CLERK_JWT_AUDIENCE ?? "convex"` was reported as unasserted).
259
+ // counts: a non-empty string literal; a non-empty array literal; an identifier
260
+ // bound by a module-level `const` to either of those, or to
261
+ // `process.env.X ?? "<non-empty>"` / `|| "<non-empty>"`
262
+ // never: `undefined`, `""`, a const whose fallback is empty, a bare env read
263
+ // with no fallback (undefined at runtime disables the check in jose)
264
+ const VERIFY_CALL = /\b(?:jwtVerify|jwt\.verify|verifyToken|verify)\s*\(/;
265
+ const NON_EMPTY_LITERAL = /^(?:"[^"]+"|'[^']+'|`[^`$]+`|\[\s*["'`][^\]]*\])$/;
266
+ const ENV_WITH_FALLBACK = /^process\.env\.[A-Za-z0-9_]+\s*(?:\?\?|\|\|)\s*("[^"]+"|'[^']+'|`[^`$]+`)$/;
267
+ function audienceValueCounts(value, f) {
268
+ const v = value.trim();
269
+ if (NON_EMPTY_LITERAL.test(v))
270
+ return true;
271
+ if (!/^[A-Za-z_$][\w$]*$/.test(v) || v === "undefined")
272
+ return false;
273
+ const decl = new RegExp(`^(?:export\\s+)?const\\s+${v}\\s*(?::[^=]+)?=\\s*(.+?);?\\s*$`);
274
+ for (const l of f.lines) {
275
+ if (!code(l))
276
+ continue;
277
+ const m = decl.exec(l.raw);
278
+ if (m) {
279
+ const init = m[1].trim();
280
+ return NON_EMPTY_LITERAL.test(init) || ENV_WITH_FALLBACK.test(init);
281
+ }
282
+ }
283
+ return false;
284
+ }
285
+ /**
286
+ * The source with comments blanked to spaces and string/template CONTENTS blanked
287
+ * to `_` (quotes kept, newlines kept), so offsets line up with the original. All
288
+ * structural matching runs on this mask; values are sliced from the original.
289
+ */
290
+ function maskSource(src) {
291
+ const out = src.split("");
292
+ let i = 0;
293
+ while (i < src.length) {
294
+ const c = src[i];
295
+ const n = src[i + 1];
296
+ if (c === "/" && n === "/") {
297
+ while (i < src.length && src[i] !== "\n")
298
+ out[i++] = " ";
299
+ }
300
+ else if (c === "/" && n === "*") {
301
+ out[i++] = " ";
302
+ out[i++] = " ";
303
+ while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) {
304
+ if (src[i] !== "\n")
305
+ out[i] = " ";
306
+ i++;
307
+ }
308
+ if (i < src.length) {
309
+ out[i++] = " ";
310
+ out[i++] = " ";
311
+ }
312
+ }
313
+ else if (c === '"' || c === "'" || c === "`") {
314
+ i++;
315
+ while (i < src.length && src[i] !== c) {
316
+ if (src[i] === "\\") {
317
+ out[i++] = "_";
318
+ if (i < src.length && src[i] !== "\n")
319
+ out[i] = "_";
320
+ i++;
321
+ continue;
322
+ }
323
+ if (src[i] !== "\n")
324
+ out[i] = "_";
325
+ i++;
326
+ }
327
+ i++;
328
+ }
329
+ else
330
+ i++;
331
+ }
332
+ return out.join("");
333
+ }
334
+ /** Index of the bracket closing the one opened at `open` in `masked`, or -1. */
335
+ function closeOf(masked, open) {
336
+ let depth = 0;
337
+ for (let i = open; i < masked.length; i++) {
338
+ const c = masked[i];
339
+ if (c === "(" || c === "{" || c === "[")
340
+ depth += 1;
341
+ else if (c === ")" || c === "}" || c === "]") {
342
+ depth -= 1;
343
+ if (depth === 0)
344
+ return i;
345
+ }
346
+ }
347
+ return -1;
348
+ }
349
+ /** Split [from, to) of `masked` on commas at nesting depth 0 → [start, end) spans. */
350
+ function topLevelParts(masked, from, to) {
351
+ const parts = [];
352
+ let depth = 0;
353
+ let start = from;
354
+ for (let i = from; i < to; i++) {
355
+ const c = masked[i];
356
+ if (c === "(" || c === "{" || c === "[")
357
+ depth += 1;
358
+ else if (c === ")" || c === "}" || c === "]")
359
+ depth -= 1;
360
+ else if (c === "," && depth === 0) {
361
+ parts.push([start, i]);
362
+ start = i + 1;
363
+ }
364
+ }
365
+ parts.push([start, to]);
366
+ return parts;
367
+ }
368
+ /** The `audience` property VALUE of the object literal whose `{` is at `open`
369
+ * (own properties only, never a nested object's), or undefined. A shorthand
370
+ * `{ audience }` yields the identifier `audience`. */
371
+ function audienceProp(src, masked, open) {
372
+ const close = closeOf(masked, open);
373
+ if (close === -1)
374
+ return undefined;
375
+ for (const [st, en] of topLevelParts(masked, open + 1, close)) {
376
+ const m = /^\s*audience\s*(?::|$)/.exec(masked.slice(st, en));
377
+ if (!m)
378
+ continue;
379
+ const colon = masked.indexOf(":", st);
380
+ const value = colon !== -1 && colon < en ? src.slice(colon + 1, en) : "audience";
381
+ return { value: value.trim(), at: st + m[0].indexOf("audience") };
382
+ }
383
+ return undefined;
384
+ }
385
+ /**
386
+ * Line of an `audience` option that belongs to a JWT-verify call's OWN argument
387
+ * list and whose value counts, or 0. Bound to the call: the scan runs from the
388
+ * call's `(` to its balanced `)` (strings and comments masked), and the key is
389
+ * read only (a) as an own property of an object-literal argument, or (b) through
390
+ * an identifier argument bound by a module-level `const NAME = { … }` holding the
391
+ * key. An `audience` in a nested call, an unrelated object below the call, a
392
+ * different call on the same line, a string or a comment is never read
393
+ * (Eta REVISE #44, mutant A2).
394
+ */
395
+ function audienceOptionLine(f) {
396
+ const src = f.lines.map((l) => l.raw).join("\n");
397
+ const masked = maskSource(src);
398
+ const lineAt = (idx) => src.slice(0, idx).split("\n").length;
399
+ const call = new RegExp(VERIFY_CALL.source, "g");
400
+ let m;
401
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
402
+ while ((m = call.exec(masked)) !== null) {
403
+ const open = m.index + m[0].length - 1;
404
+ const close = closeOf(masked, open);
405
+ if (close === -1)
406
+ continue;
407
+ for (const [st, en] of topLevelParts(masked, open + 1, close)) {
408
+ const arg = masked.slice(st, en);
409
+ const lead = st + (arg.length - arg.trimStart().length);
410
+ let prop;
411
+ if (masked[lead] === "{") {
412
+ prop = audienceProp(src, masked, lead);
413
+ }
414
+ else {
415
+ const id = /^([A-Za-z_$][\w$]*)\s*$/.exec(arg.trim());
416
+ if (!id)
417
+ continue;
418
+ const decl = new RegExp(`^(?:export\\s+)?const\\s+${id[1].replace(/\$/g, "\\$")}\\s*(?::[^=\\n]+)?=\\s*\\{`, "m");
419
+ const d = decl.exec(masked);
420
+ if (d)
421
+ prop = audienceProp(src, masked, d.index + d[0].length - 1);
422
+ }
423
+ if (prop && audienceValueCounts(prop.value, f))
424
+ return lineAt(prop.at);
425
+ }
426
+ }
427
+ return 0;
428
+ }
254
429
  export function scanR34(files) {
255
430
  const scan = emptyScan();
256
431
  for (const f of files) {
@@ -269,9 +444,11 @@ export function scanR34(files) {
269
444
  scan.violate.push({ rel: f.rel, n: l.n, note: "outbound Authorization header forwards a caller-supplied token verbatim (token passthrough)" });
270
445
  }
271
446
  }
447
+ if (!audAssert)
448
+ audAssert = audienceOptionLine(f);
272
449
  if (inboundLine) {
273
450
  if (audAssert) {
274
- scan.conform.push({ rel: f.rel, n: audAssert, note: "inbound JWT audience asserted (VP_CLERK_EXPECTED_AUD / aud claim mismatch)" });
451
+ scan.conform.push({ rel: f.rel, n: audAssert, note: "inbound JWT audience asserted (VP_CLERK_EXPECTED_AUD / aud claim mismatch / a verify call's audience option with a non-empty value)" });
275
452
  }
276
453
  else {
277
454
  scan.violate.push({ rel: f.rel, n: inboundLine, note: "inbound JWT verified with no audience assertion — accepts a token minted for another party" });
@@ -328,16 +505,57 @@ export function scanR35(files) {
328
505
  const LOCKFILES = ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
329
506
  const WORKFLOW_DIR = ".github/workflows";
330
507
  const PUBLISH_HINT = /npm\s+publish|release|publish/i;
508
+ /**
509
+ * The directories R-36 searches, nearest first: the scanned root, then each
510
+ * ancestor up to and INCLUDING the git root (the first ancestor holding `.git`).
511
+ *
512
+ * The misread this closes: the documented invocation points the doctor at
513
+ * `<repo>/convex`, while the lockfile and `.github/workflows` are REPOSITORY
514
+ * artifacts living at the repo root. Reading only `tree.root` reported "no lockfile
515
+ * — dependencies are not pinned" on a repository with a committed `bun.lock`.
516
+ *
517
+ * The walk stops at the git root and never goes above it: a lockfile in some
518
+ * unrelated parent directory (a home directory, a monorepo host that is not this
519
+ * repository) does not pin this repository's dependencies. With no `.git` ancestor
520
+ * at all, only the scanned root itself is read (the pre-fix behaviour) — the walk
521
+ * is never widened on a guess.
522
+ */
523
+ export function r36SearchDirs(root) {
524
+ const chain = [];
525
+ let dir = resolve(root);
526
+ for (;;) {
527
+ chain.push(dir);
528
+ if (existsSync(join(dir, ".git")))
529
+ return chain;
530
+ const up = dirname(dir);
531
+ if (up === dir)
532
+ return [resolve(root)];
533
+ dir = up;
534
+ }
535
+ }
331
536
  export function evalR36(root) {
332
537
  const scan = emptyScan();
333
- const lock = LOCKFILES.find((n) => existsSync(join(root, n)));
538
+ const dirs = r36SearchDirs(root);
539
+ const relTo = (dir, name) => {
540
+ const r = relative(resolve(root), join(dir, name));
541
+ return r === "" ? name : r;
542
+ };
543
+ let lock;
544
+ for (const dir of dirs) {
545
+ const name = LOCKFILES.find((n) => existsSync(join(dir, n)));
546
+ if (name) {
547
+ lock = { dir, name };
548
+ break;
549
+ }
550
+ }
334
551
  if (!lock) {
335
- scan.violate.push({ rel: "(root)", n: 0, note: "no lockfile present — dependencies are not pinned" });
552
+ scan.violate.push({ rel: "(root)", n: 0, note: `no lockfile present in the scanned root or any ancestor up to the git root (searched: ${dirs.join(", ")}) — dependencies are not pinned` });
336
553
  }
337
554
  else {
338
- scan.conform.push({ rel: lock, n: 1, note: "lockfile present — every dependency pinned by the lockfile" });
555
+ scan.conform.push({ rel: relTo(lock.dir, lock.name), n: 1, note: "lockfile present — every dependency pinned by the lockfile" });
339
556
  }
340
- const wfDir = join(root, WORKFLOW_DIR);
557
+ const wfBase = dirs.find((d) => existsSync(join(d, WORKFLOW_DIR))) ?? resolve(root);
558
+ const wfDir = join(wfBase, WORKFLOW_DIR);
341
559
  let publishFile = "";
342
560
  let provenance = false;
343
561
  let sawPublish = false;
@@ -348,7 +566,7 @@ export function evalR36(root) {
348
566
  const p = join(wfDir, name);
349
567
  if (!existsSync(p))
350
568
  continue;
351
- publishFile = `${WORKFLOW_DIR}/${name}`;
569
+ publishFile = relTo(wfBase, `${WORKFLOW_DIR}/${name}`);
352
570
  const body = readFileSync(p, "utf8");
353
571
  if (PUBLISH_HINT.test(body))
354
572
  sawPublish = true;
@@ -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 — no org-isolation handling is visible in convex/, and whether a client can reach it before an org exists is not statically decidable; a reviewer must confirm this write isolates by org (never a silent clean bill)`,
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
- // A bare ungated public write (no throw / typed-unauth / marker) is NEVER a silent
325
- // clean bill — whether the scan is otherwise empty (a fully-ungated public-write
326
- // surface) OR mixed (bare writes alongside gated/marker subjects). Surface EVERY
327
- // such write as could-not-judge naming its site: never conformed, never dropped.
328
- // (Eta mixed-tree follow-up: the prior "scan otherwise empty" guard silently
329
- // dropped bare ungated writes whenever any gated/marker subject also existed — a
330
- // bare write hidden behind a sibling that happened to produce a verdict. Removed:
331
- // the disposition is per-def, not per-tree.)
332
- for (const u of ungated)
333
- scan.cnj.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
  ];
@@ -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;
@@ -160,17 +160,35 @@ export function extractDocMatches(name, text) {
160
160
  const matches = [];
161
161
  const lineOf = (pos) => sf.getLineAndCharacterOfPosition(pos).line + 1;
162
162
  const constMap = new Map();
163
+ const constSpan = new Map();
163
164
  function collectConsts(node) {
164
165
  if (ts.isVariableStatement(node)) {
165
166
  for (const decl of node.declarationList.declarations) {
166
167
  if (ts.isIdentifier(decl.name) && decl.initializer) {
167
168
  constMap.set(decl.name.text, decl.initializer);
169
+ constSpan.set(decl.name.text, {
170
+ start: lineOf(node.getStart(sf)),
171
+ end: lineOf(node.getEnd()),
172
+ });
168
173
  }
169
174
  }
170
175
  }
171
176
  ts.forEachChild(node, collectConsts);
172
177
  }
173
178
  collectConsts(sf);
179
+ /** The span of every const an expression resolves THROUGH (the chain a
180
+ * `returns: X` takes to reach its validator). */
181
+ function constChain(expr) {
182
+ const out = [];
183
+ let cur = expr;
184
+ for (let d = 0; d <= 10 && ts.isIdentifier(cur) && constMap.has(cur.text); d++) {
185
+ const span = constSpan.get(cur.text);
186
+ if (span)
187
+ out.push(span);
188
+ cur = constMap.get(cur.text);
189
+ }
190
+ return out;
191
+ }
174
192
  function resolve(expr, depth = 0) {
175
193
  if (depth > 10)
176
194
  return expr;
@@ -179,10 +197,11 @@ export function extractDocMatches(name, text) {
179
197
  }
180
198
  return expr;
181
199
  }
182
- function walkForDocObjects(expr, out, exportName, alias, span, depth = 0) {
200
+ function walkForDocObjects(expr, out, exportName, alias, span, depth = 0, consts = []) {
183
201
  if (depth > 20)
184
202
  return;
185
203
  const nextAlias = ts.isIdentifier(expr) ? expr.text : alias;
204
+ const chain = [...consts, ...constChain(expr)];
186
205
  const resolved = resolve(expr);
187
206
  if (ts.isCallExpression(resolved) &&
188
207
  ts.isPropertyAccessExpression(resolved.expression)) {
@@ -207,11 +226,12 @@ export function extractDocMatches(name, text) {
207
226
  line: lineOf(objArg.getStart(sf)),
208
227
  exportStartLine: span.start,
209
228
  exportEndLine: span.end,
229
+ constSpans: chain,
210
230
  });
211
231
  }
212
232
  for (const prop of objArg.properties) {
213
233
  if (ts.isPropertyAssignment(prop)) {
214
- walkForDocObjects(prop.initializer, out, exportName, undefined, span, depth + 1);
234
+ walkForDocObjects(prop.initializer, out, exportName, undefined, span, depth + 1, chain);
215
235
  }
216
236
  }
217
237
  }
@@ -219,7 +239,7 @@ export function extractDocMatches(name, text) {
219
239
  }
220
240
  if (method === "union" || method === "array" || method === "optional") {
221
241
  for (const arg of resolved.arguments) {
222
- walkForDocObjects(arg, out, exportName, nextAlias, span, depth + 1);
242
+ walkForDocObjects(arg, out, exportName, nextAlias, span, depth + 1, chain);
223
243
  }
224
244
  return;
225
245
  }
@@ -348,18 +368,29 @@ export function scanR46(files) {
348
368
  }
349
369
  // include the contiguous leading-comment block above the export — the
350
370
  // 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;
371
+ // The same holds for every SHARED CONSTANT the export's `returns:` resolves
372
+ // through: a reason written on `const externalOpenReturns = v.object(…)`
373
+ // is the author's written reason for every export that returns it. A
374
+ // reason on an UNRELATED const (one this export does not resolve through)
375
+ // governs nothing here.
376
+ const withLead = (start) => {
377
+ let lead = start;
378
+ while (lead > 1) {
379
+ const prev = (rawLines[lead - 2] ?? "").trim();
380
+ if (prev.startsWith("//") ||
381
+ prev.startsWith("*") ||
382
+ prev.startsWith("/*")) {
383
+ lead -= 1;
384
+ }
385
+ else
386
+ break;
358
387
  }
359
- else
360
- break;
361
- }
362
- const reason = projectionReason(rawLines, leadStart, m.exportEndLine);
388
+ return lead;
389
+ };
390
+ const reason = projectionReason(rawLines, withLead(m.exportStartLine), m.exportEndLine) ??
391
+ m.constSpans
392
+ .map((c) => projectionReason(rawLines, withLead(c.start), c.end))
393
+ .find((r) => r !== undefined);
363
394
  if (reason) {
364
395
  inventory.push({
365
396
  rel: f.rel,
@@ -5,8 +5,8 @@
5
5
  // column. Agreeing with the oracle here would be certifying the defect the
6
6
  // instrument exists to find — so these are NOT tuned to pass.
7
7
  import { CONTRACT_FIELDS, READER_AUTHORITY_COLUMN, WRITER_AUTHORITY_COLUMN, authorityIsMerged } from "../contract.js";
8
- import { ORACLE_COLUMNS, readOracle } from "../oracle.js";
9
- const CSV_ANCHOR = "oracle:vp-by-tool-day158.csv col#9 rbac_qui";
8
+ import { ORACLE_COLUMNS, oracleLabel, readOracle, refuseWithoutOracle, requireOracle, } from "../oracle.js";
9
+ const csvAnchor = () => `${oracleLabel()} col#9 rbac_qui`;
10
10
  /**
11
11
  * R-14 — inventory-schema parity, contract → inventory, one direction.
12
12
  * A merged reader/writer authority column is "non-conforming by construction"
@@ -15,12 +15,15 @@ const CSV_ANCHOR = "oracle:vp-by-tool-day158.csv col#9 rbac_qui";
15
15
  export function makeR14(columns = ORACLE_COLUMNS) {
16
16
  return {
17
17
  id: "R-14 inventory-schema parity",
18
- run: () => {
18
+ // The verdict is about the oracle's schema, so with no oracle bound there is
19
+ // no subject: refuse (could-not-judge), never a verdict on the tool's own constant.
20
+ run: () => refuseWithoutOracle("R-14 inventory-schema parity", () => {
21
+ requireOracle();
19
22
  const authorityColumns = columns.filter((c) => c === "rbac_qui");
20
23
  if (authorityIsMerged() || authorityColumns.length < 2) {
21
24
  return {
22
25
  id: "R-14 inventory-schema parity",
23
- anchor: CSV_ANCHOR,
26
+ anchor: csvAnchor(),
24
27
  state: "non-conforming",
25
28
  verdict: "R-14: the inventory holds a SINGLE merged authority column `rbac_qui` (col#9), " +
26
29
  "but §2 splits authority into two distinct contract fields — reader authority (R-5) " +
@@ -30,11 +33,11 @@ export function makeR14(columns = ORACLE_COLUMNS) {
30
33
  }
31
34
  return {
32
35
  id: "R-14 inventory-schema parity",
33
- anchor: "oracle:vp-by-tool-day158.csv",
36
+ anchor: oracleLabel(),
34
37
  state: "present",
35
38
  verdict: "R-14: every §2 contract field present; reader and writer authority are two separate columns.",
36
39
  };
37
- },
40
+ }),
38
41
  };
39
42
  }
40
43
  /**
@@ -45,7 +48,8 @@ export function makeR14(columns = ORACLE_COLUMNS) {
45
48
  export function makeR38(columns = ORACLE_COLUMNS, rowsProvider = readOracle) {
46
49
  return {
47
50
  id: "R-38 thirteen-field completeness",
48
- run: () => {
51
+ run: () => refuseWithoutOracle("R-38 thirteen-field completeness", () => {
52
+ requireOracle();
49
53
  const colSet = new Set(columns);
50
54
  const missing = [];
51
55
  for (const f of CONTRACT_FIELDS) {
@@ -64,7 +68,7 @@ export function makeR38(columns = ORACLE_COLUMNS, rowsProvider = readOracle) {
64
68
  .join("; ");
65
69
  return {
66
70
  id: "R-38 thirteen-field completeness",
67
- anchor: CSV_ANCHOR,
71
+ anchor: csvAnchor(),
68
72
  state: "non-conforming",
69
73
  verdict: `R-38: the oracle cannot satisfy the thirteen-field §2 contract — reader authority (R-5) and ` +
70
74
  `writer authority (R-10) are not two separate fields; both map to the single column ` +
@@ -74,10 +78,10 @@ export function makeR38(columns = ORACLE_COLUMNS, rowsProvider = readOracle) {
74
78
  }
75
79
  return {
76
80
  id: "R-38 thirteen-field completeness",
77
- anchor: "oracle:vp-by-tool-day158.csv",
81
+ anchor: oracleLabel(),
78
82
  state: "present",
79
83
  verdict: "R-38: all thirteen §2 contract fields present per table, each within its vocabulary.",
80
84
  };
81
- },
85
+ }),
82
86
  };
83
87
  }