@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,140 @@
1
+ // backend-doctor's OWN registry. Each entry pairs a detector with the rule id it
2
+ // targets and the standard pin it was written against — machine-readably, so
3
+ // coverage is a COMMAND over this array, never a human reading names.
4
+ import { makeR40 } from "./detectors/oracle-axis1.js";
5
+ import { makeR37, r7, r8 } from "./detectors/oracle-axis2.js";
6
+ import { PER_ROW_PREDICATES } from "./detectors/predicates.js";
7
+ import { makeOracleDetector } from "./detectors/shared.js";
8
+ import { SOURCE_BACKFILL_DETECTORS } from "./detectors/source/backfill.js";
9
+ import { SOURCE_CONTRACT_DETECTORS } from "./detectors/source/contract.js";
10
+ import { IDENTITY_DETECTORS } from "./detectors/source/identity.js";
11
+ import { SOURCE_JOBS_DETECTORS } from "./detectors/source/jobs.js";
12
+ import { SOURCE_LIFECYCLE_DETECTORS } from "./detectors/source/lifecycle.js";
13
+ import { SOURCE_READBUDGET_DETECTORS } from "./detectors/source/readbudget.js";
14
+ import { SOURCE_REFUSAL_DETECTORS } from "./detectors/source/refusal.js";
15
+ import { SOURCE_SECURITY_DETECTORS } from "./detectors/source/security.js";
16
+ import { SOURCE_WRITESAFETY_DETECTORS } from "./detectors/source/writesafety.js";
17
+ import { R41, SIBLING_PIN } from "./detectors/standard/r41-frontier.js";
18
+ import { R46_STANDARD_PIN, STANDARD_SOURCE_DETECTORS, } from "./detectors/standard/r46-returns-validator.js";
19
+ import { STANDARD_DOC_DETECTORS } from "./detectors/standard/standard-doc.js";
20
+ import { makeR14, makeR38 } from "./detectors/structural.js";
21
+ /** The standard both pins the whole wave against. */
22
+ export const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp)";
23
+ export const REGISTRY = [
24
+ ...PER_ROW_PREDICATES.map((spec) => ({
25
+ detector: makeOracleDetector(spec),
26
+ ruleId: spec.ruleId,
27
+ standardPin: STANDARD_PIN,
28
+ })),
29
+ { detector: makeR14(), ruleId: "R-14", standardPin: STANDARD_PIN },
30
+ { detector: makeR38(), ruleId: "R-38", standardPin: STANDARD_PIN },
31
+ // Axis-2 coherence detectors (R-7/R-8/R-37): each performs its mechanical half
32
+ // IN FULL and returns could-not-judge, naming the sites a human verdict is owed.
33
+ {
34
+ detector: makeOracleDetector(r7),
35
+ ruleId: "R-7",
36
+ standardPin: STANDARD_PIN,
37
+ },
38
+ {
39
+ detector: makeOracleDetector(r8),
40
+ ruleId: "R-8",
41
+ standardPin: STANDARD_PIN,
42
+ },
43
+ { detector: makeR37(), ruleId: "R-37", standardPin: STANDARD_PIN },
44
+ // R-40 — the Axis-1 BETWEEN-backend divergence control. It fires the moment a
45
+ // second backend's inventory exists (VR, vendored @ a74a042b) and compares the
46
+ // two per-tool inventories on every SHARED field's closed vocabulary. Mechanical
47
+ // half runs in full (names each field + VP-vs-VR value + the statut_suppression
48
+ // schema divergence); human half (is each divergence justified?) → could-not-judge.
49
+ {
50
+ detector: makeR40(),
51
+ ruleId: "R-40",
52
+ standardPin: `${STANDARD_PIN} + oracle:vr-by-tool-day159.csv @ a74a042b`,
53
+ },
54
+ // The twenty source-tree detectors (built all at once — the wave plan cancelled).
55
+ // Each reads the REAL convex/ source; R-6/R-16 also carry a live half. Their
56
+ // standardPin is normalised to the registry's full pin string.
57
+ ...[
58
+ ...SOURCE_SECURITY_DETECTORS,
59
+ ...SOURCE_WRITESAFETY_DETECTORS,
60
+ ...SOURCE_JOBS_DETECTORS,
61
+ // R-43 — the admin-resource lifecycle-symmetry control. Reads the convex
62
+ // source: for every admin-provisionable table a create/upsert reaches
63
+ // (oauth_scope_profiles, oauth_clients, tenants, filter rules), it checks a
64
+ // delete/GC path exists (or a written out-of-scope reason). Its mechanical
65
+ // half runs in full and it returns could-not-judge naming the admin-create
66
+ // tables with NO delete — a work list, never present/absent while the create
67
+ // subject exists (Day-165 friction, task k172xe9re...).
68
+ ...SOURCE_LIFECYCLE_DETECTORS,
69
+ // R-44 / R-45 — the derived-structure backfill controls. R-44 reads the
70
+ // convex source: for every derived structure (a thin, foreign-keyed,
71
+ // index-bearing junction/side table like briefingNoteParticipants) it
72
+ // checks a same-tree backfill migration populates it from the existing
73
+ // source rows — present on a direct-insert loop, could-not-judge when the
74
+ // migration writes via a helper (idempotence/coverage undecidable),
75
+ // non-conforming when no backfill exists (the PR #1200 inert-fix defect).
76
+ // R-45 reads the tests: present when a fixture builds the source row via a
77
+ // direct ctx.db.insert (bypassing the write path), non-conforming when the
78
+ // subject is exercised only through create, could-not-judge otherwise.
79
+ ...SOURCE_BACKFILL_DETECTORS,
80
+ // R-31 — the runtime read-budget control. Reads the convex source: for
81
+ // every mutation/internalMutation over an unbounded set (a published
82
+ // bulk-write OR a hand-run one-shot migration) it flags a bare `.collect()`
83
+ // read — non-conforming because an unbounded read aborts on the
84
+ // per-execution read-byte cap INDEPENDENTLY of the write budget (an
85
+ // unbounded read that writes nothing still throws `Too many bytes read`).
86
+ // A read bounded by `.take(n)`/`.paginate` WITH a `ctx.scheduler.runAfter`
87
+ // self-reschedule is present; a bounded read with no reschedule is
88
+ // could-not-judge. This is the static instrument that could have caught the
89
+ // participants-backfill P0 that 1079 convex-test tests passed (convex-test
90
+ // does not enforce the runtime read-byte limit).
91
+ ...SOURCE_READBUDGET_DETECTORS,
92
+ // R-47 — the incompletable-read refusal control (judgement; static+dynamic).
93
+ // Reads the convex source: a wide, non-index-bounded `.collect()` in a QUERY
94
+ // whose handler carries no refusal path can be truncated by the read cap
95
+ // BEFORE its filter runs, returning a fraction indistinguishable from a full
96
+ // match (the 2026-08-19 tasks.list companion to R-46's get_task P0). Its
97
+ // mechanical half runs in full; it returns could-not-judge naming each such
98
+ // site (the human decides refusal vs a tighter index vs a declared bound),
99
+ // present on a wide scan that DOES refuse (the tasks.list SCAN_CAP shape).
100
+ ...SOURCE_REFUSAL_DETECTORS,
101
+ ...SOURCE_CONTRACT_DETECTORS,
102
+ ...IDENTITY_DETECTORS,
103
+ // The four STANDARD-DOCUMENT detectors (R-42/R-15/R-32/R-39): each reads the
104
+ // VENDORED standard itself, performs its mechanical/static half in full, and
105
+ // returns could-not-judge naming the sites a human/gate verdict is owed on.
106
+ ...STANDARD_DOC_DETECTORS,
107
+ ].map((e) => ({
108
+ detector: e.detector,
109
+ ruleId: e.ruleId,
110
+ standardPin: STANDARD_PIN,
111
+ })),
112
+ // R-41 — the ONE fully-mechanical rule of the nine: it reads the backend
113
+ // standard's §9 frontier AND the four vendored MCP-family siblings, resolving
114
+ // every cited S-/C-/E-/A- id against its sibling's defined-id set. present when
115
+ // all resolve; non-conforming on any dead pointer. Its pin cites the sibling
116
+ // documents (the second input), not only the backend standard.
117
+ {
118
+ detector: R41,
119
+ ruleId: "R-41",
120
+ standardPin: `${STANDARD_PIN} + ${SIBLING_PIN}`,
121
+ },
122
+ // R-46 — a read path's returns validator is DERIVED from its table's schema,
123
+ // never a hand-written restatement (the 2026-08-19 get_task ReturnsValidationError
124
+ // P0, origin e9a6a7e). UNLIKE every other detector, R-46 reads the TARGET
125
+ // backend's OWN schema.ts + returns validators from the tree the engine loads,
126
+ // derives the schema field set at analysis time, and classifies every
127
+ // document-returning validator as COVERS / written-PROJECTION / MISSING — so R-46
128
+ // is measured on EVERY backend the doctor is pointed at, not rediscovered per
129
+ // repository at production cost. It is the lift of vantage-peers'
130
+ // returnsValidatorSchemaCoverage.test.ts (#1206, d2c32ea) into the instrument.
131
+ ...STANDARD_SOURCE_DETECTORS.map((e) => ({
132
+ detector: e.detector,
133
+ ruleId: e.ruleId,
134
+ standardPin: R46_STANDARD_PIN,
135
+ })),
136
+ ];
137
+ /** The rule ids this wave mechanically CHECKS — derived from the registry. */
138
+ export function checkedRuleIds() {
139
+ return [...new Set(REGISTRY.map((e) => e.ruleId))].sort((a, b) => Number(a.slice(2)) - Number(b.slice(2)));
140
+ }
@@ -0,0 +1,35 @@
1
+ import type { DetectorResult } from "./engine-types.js";
2
+ import { type Tally, type Verdict } from "./tally.js";
3
+ import { type Severity } from "./severity.js";
4
+ /** The run-time facts the CLI gathers (clock + git) and hands to the renderer. */
5
+ export interface ReportContext {
6
+ /** the audited target directory (absolute). */
7
+ target: string;
8
+ /** `git -C <target> rev-parse HEAD`, or the literal "not a git repo". */
9
+ targetCommit: string;
10
+ /** the tool version, from package.json. */
11
+ toolVersion: string;
12
+ /** the run date, YYYY-MM-DD, from the OS clock at CLI runtime. */
13
+ dateISO: string;
14
+ /**
15
+ * `git status --porcelain --untracked-files=all` of the target — untracked
16
+ * INCLUDED (the short form is not a clean-tree check; a doubled untracked dir
17
+ * bit a reviewer). Empty string means a clean tree.
18
+ */
19
+ treeStatus: string;
20
+ }
21
+ /** Derive the committed report path from the target basename + the run date. */
22
+ export declare function reportPath(target: string, dateISO: string, dir: string): string;
23
+ /**
24
+ * Render the full report markdown. PURE — no clock, no git, no fs. The tally line
25
+ * printed in the header is the SAME `tallyLine(t)` the CLI prints to the terminal
26
+ * (one source of truth — the header can never silently diverge from the printed
27
+ * tally), and its sum invariant was already asserted by `tally()`.
28
+ */
29
+ export declare function buildReport(ctx: ReportContext, results: DetectorResult[], t: Tally, v: Verdict, severity?: Map<string, Severity>): string;
30
+ /**
31
+ * Write the report to `<dir>/backend-doctor-<target-basename>-<date>.md`, creating
32
+ * <dir> if needed. Returns the absolute path written. The caller (CLI) supplies the
33
+ * ReportContext (clock + git) and the computed tally/verdict.
34
+ */
35
+ export declare function writeReport(ctx: ReportContext, results: DetectorResult[], t: Tally, v: Verdict, dir: string, severity?: Map<string, Severity>): string;
package/dist/report.js ADDED
@@ -0,0 +1,138 @@
1
+ // The REPORT-WRITER — "a report he cannot see, he cannot believe."
2
+ //
3
+ // backend-doctor's verdict must survive the terminal: every run WRITES a report
4
+ // file, ALWAYS (not behind a flag), to a path the repo keeps. The header carries
5
+ // what makes the report checkable months later (target path + commit, the pinned
6
+ // standard commit, the tool version, the machine tally with its sum asserted, and
7
+ // a tree-cleanliness proof). Per rule the body carries the SITES — the row / table
8
+ // / tool / file:line — not a count, because a count tells a number while the sites
9
+ // tell the owner what to fix. The CRITICAL rows are separated at the TOP so a
10
+ // reader who stops after one screen has still seen them.
11
+ //
12
+ // PURITY / SANDBOX: this module is a PURE renderer + a filesystem writer. It does
13
+ // NOT read the clock and does NOT shell git — the CLI (a real process) gathers the
14
+ // date via `new Date()` and the git facts via child_process and hands them in as a
15
+ // ReportContext. Nothing here calls Date.now()/execSync, so it is safe to build the
16
+ // report string inside a workflow-script sandbox where those are unavailable.
17
+ import { mkdirSync, writeFileSync } from "node:fs";
18
+ import { basename, join } from "node:path";
19
+ import { ruleIdOf, tallyLine } from "./tally.js";
20
+ import { deriveSeverityMap } from "./severity.js";
21
+ import { STANDARD_SHA } from "./coverage.js";
22
+ /** Derive the committed report path from the target basename + the run date. */
23
+ export function reportPath(target, dateISO, dir) {
24
+ return join(dir, `backend-doctor-${basename(target)}-${dateISO}.md`);
25
+ }
26
+ /** All results for one ruleId, in registry order. */
27
+ function resultsForRule(ruleId, results) {
28
+ return results.filter((r) => ruleIdOf(r) === ruleId);
29
+ }
30
+ /** The SITES line for a rule — anchor + verdict per result, never a count. */
31
+ function sitesFor(ruleId, results) {
32
+ return resultsForRule(ruleId, results)
33
+ .map((r) => {
34
+ const anchor = r.anchor?.trim() ? r.anchor.trim() : "(no anchor)";
35
+ return `${anchor} — ${r.verdict.trim()}`;
36
+ })
37
+ .join("<br>");
38
+ }
39
+ /** One markdown table row for a rule in a non-clean state. */
40
+ function ruleRow(ruleId, state, sev, results) {
41
+ return `| ${ruleId} | ${state} | ${sev} | ${sitesFor(ruleId, results)} |`;
42
+ }
43
+ /**
44
+ * Render the full report markdown. PURE — no clock, no git, no fs. The tally line
45
+ * printed in the header is the SAME `tallyLine(t)` the CLI prints to the terminal
46
+ * (one source of truth — the header can never silently diverge from the printed
47
+ * tally), and its sum invariant was already asserted by `tally()`.
48
+ */
49
+ export function buildReport(ctx, results, t, v, severity = deriveSeverityMap()) {
50
+ const sevOf = (id) => {
51
+ const s = severity.get(id);
52
+ if (s === undefined)
53
+ throw new Error(`buildReport: no severity for ${id}`);
54
+ return s;
55
+ };
56
+ // The CRITICAL rows — non-conforming first, then abstentions — separated at the
57
+ // TOP. Ordered by rule number within each group.
58
+ const byNum = (a, b) => Number(a.slice(2)) - Number(b.slice(2));
59
+ const criticalViolations = [...v.violations.critical].sort(byNum);
60
+ const criticalAbstentions = [...v.abstentions.critical].sort(byNum);
61
+ const nonConforming = [];
62
+ const couldNotJudge = [];
63
+ const present = [];
64
+ const absent = [];
65
+ for (const [id, state] of [...t.ruleState].sort((a, b) => byNum(a[0], b[0]))) {
66
+ if (state === "non-conforming")
67
+ nonConforming.push(id);
68
+ else if (state === "could-not-judge")
69
+ couldNotJudge.push(id);
70
+ else if (state === "present")
71
+ present.push(id);
72
+ else
73
+ absent.push(id);
74
+ }
75
+ const tableHead = "| rule | state | severity | sites |\n| --- | --- | --- | --- |";
76
+ const rowsFor = (ids, state) => ids.length === 0 ? "_none._" : `${tableHead}\n${ids.map((id) => ruleRow(id, state, sevOf(id), results)).join("\n")}`;
77
+ const treeProof = ctx.treeStatus.trim() === "" ? "(clean — no tracked or untracked change)" : ctx.treeStatus;
78
+ const criticalCount = criticalViolations.length + criticalAbstentions.length;
79
+ const criticalRows = criticalCount === 0
80
+ ? "_no critical violation or abstention._"
81
+ : [
82
+ tableHead,
83
+ ...criticalViolations.map((id) => ruleRow(id, "non-conforming", "critical", results)),
84
+ ...criticalAbstentions.map((id) => ruleRow(id, "could-not-judge", "critical", results)),
85
+ ].join("\n");
86
+ return `# backend-doctor report — ${basename(ctx.target)} — ${ctx.dateISO}
87
+
88
+ > A report he cannot see, he cannot believe. This file is written on EVERY run,
89
+ > never behind a flag — an absent file is never a clean result.
90
+
91
+ ## Header — what makes this checkable months later
92
+
93
+ - **target**: \`${ctx.target}\`
94
+ - **target commit**: \`${ctx.targetCommit}\`
95
+ - **standard commit** (pinned): \`${STANDARD_SHA}\`
96
+ - **tool version**: \`${ctx.toolVersion}\`
97
+ - **run date**: ${ctx.dateISO}
98
+ - **machine tally**: \`${tallyLine(t)}\`
99
+ - **verdict**: ${v.summary}
100
+
101
+ Tree-cleanliness proof — \`git status --porcelain --untracked-files=all\` (untracked INCLUDED):
102
+
103
+ \`\`\`
104
+ ${treeProof}
105
+ \`\`\`
106
+
107
+ ## CRITICAL (${criticalCount}) — read these first
108
+
109
+ ${criticalRows}
110
+
111
+ ## Non-conforming (${nonConforming.length})
112
+
113
+ ${rowsFor(nonConforming, "non-conforming")}
114
+
115
+ ## Could-not-judge (${couldNotJudge.length})
116
+
117
+ ${rowsFor(couldNotJudge, "could-not-judge")}
118
+
119
+ ## Absent (${absent.length})
120
+
121
+ ${rowsFor(absent, "absent")}
122
+
123
+ ## Present (${present.length})
124
+
125
+ ${present.length === 0 ? "_none._" : present.join(", ")}
126
+ `;
127
+ }
128
+ /**
129
+ * Write the report to `<dir>/backend-doctor-<target-basename>-<date>.md`, creating
130
+ * <dir> if needed. Returns the absolute path written. The caller (CLI) supplies the
131
+ * ReportContext (clock + git) and the computed tally/verdict.
132
+ */
133
+ export function writeReport(ctx, results, t, v, dir, severity = deriveSeverityMap()) {
134
+ mkdirSync(dir, { recursive: true });
135
+ const path = reportPath(ctx.target, ctx.dateISO, dir);
136
+ writeFileSync(path, buildReport(ctx, results, t, v, severity), "utf8");
137
+ return path;
138
+ }
@@ -0,0 +1,30 @@
1
+ export type Severity = "critical" | "major-high" | "major" | "minor";
2
+ /** Ordering for "worst wins" reductions and threshold comparisons. */
3
+ export declare const SEVERITY_RANK: Record<Severity, number>;
4
+ /**
5
+ * Resolve a rule's severity from its objective tags, by precedence. Throws in
6
+ * two distinct ways, both a finding about the parse or the standard and never a
7
+ * silent default-to-minor:
8
+ * 1. UNMAPPABLE tag present — a tag outside the five known objectives. This is
9
+ * checked FIRST, before precedence, because the critical rows carry BOTH
10
+ * "highly secure" AND the tenant tag: if the tenant tag were mangled (a
11
+ * case/spacing variant that fails `includes(MULTI_TENANT_TAG)`) and we let
12
+ * precedence resolve on the surviving "highly secure" sibling, a CRITICAL
13
+ * rule would silently downgrade to major-high with no throw. Asserting the
14
+ * set is a SUBSET of the five and throwing on the unmappable tag closes that.
15
+ * 2. EMPTY / all-absent — no tag matches any of the five (the vacuous-scope
16
+ * hole): still a throw, never minor.
17
+ */
18
+ export declare function severityOf(tags: readonly string[], ruleId?: string): Severity;
19
+ /**
20
+ * Parse the objectives table (§13) of the VENDORED standard into ruleId -> tags.
21
+ * A table row is `| R-N <desc> | <tag> · <tag> · … |`. Returns an empty map only
22
+ * if the table is absent — which `deriveSeverityMap` treats as a hard failure.
23
+ */
24
+ export declare function parseObjectives(path?: string): Map<string, string[]>;
25
+ /**
26
+ * The ruleId -> severity map for every rule the standard defines. Throws (the
27
+ * neuter guard) if the objectives parse yields NOTHING — an empty scope must go
28
+ * RED, never make every rule minor by vacuity.
29
+ */
30
+ export declare function deriveSeverityMap(path?: string): Map<string, Severity>;
@@ -0,0 +1,121 @@
1
+ // Severity is DERIVED from the standard's objectives table (§13, the
2
+ // "every rule to the objective it serves" table), never hardcoded per rule —
3
+ // the R-5 law: a machine reads the standard, a human does not maintain a list.
4
+ //
5
+ // THE TAG -> SEVERITY MAP (the one place it is written):
6
+ //
7
+ // objective tag on a rule => severity
8
+ // ---------------------------------------------------------------
9
+ // "multi-organisation multi-tenant" => critical (the tenant-boundary rules)
10
+ // "highly secure" (no multi-tenant) => major-high
11
+ // "enterprise-grade" => major
12
+ // "production-ready" => major
13
+ // "token-efficient" (only) => minor
14
+ //
15
+ // Resolution is by PRECEDENCE (a rule carries several tags): the highest tag wins.
16
+ // A rule whose objectives cell resolves to NONE of the five is a FINDING (the
17
+ // standard promises "no blank cell"), and severityOf THROWS — it never silently
18
+ // collapses an unmapped rule to minor (the vacuous-scope hole).
19
+ import { readFileSync } from "node:fs";
20
+ import { VENDORED_STANDARD_PATH } from "./coverage.js";
21
+ /** Ordering for "worst wins" reductions and threshold comparisons. */
22
+ export const SEVERITY_RANK = {
23
+ critical: 3,
24
+ "major-high": 2,
25
+ major: 1,
26
+ minor: 0,
27
+ };
28
+ /** The tenant-boundary tag — its presence alone makes a rule CRITICAL. */
29
+ const MULTI_TENANT_TAG = "multi-organisation multi-tenant";
30
+ /**
31
+ * The five objectives the §13 table is allowed to carry. A parsed tag outside
32
+ * this set is an UNREADABLE cell (a mangled/variant spelling, e.g. wrong case),
33
+ * never an absent one — and the two must not be conflated (see severityOf).
34
+ */
35
+ const KNOWN_OBJECTIVES = new Set([
36
+ MULTI_TENANT_TAG,
37
+ "highly secure",
38
+ "enterprise-grade",
39
+ "production-ready",
40
+ "token-efficient",
41
+ ]);
42
+ /**
43
+ * Resolve a rule's severity from its objective tags, by precedence. Throws in
44
+ * two distinct ways, both a finding about the parse or the standard and never a
45
+ * silent default-to-minor:
46
+ * 1. UNMAPPABLE tag present — a tag outside the five known objectives. This is
47
+ * checked FIRST, before precedence, because the critical rows carry BOTH
48
+ * "highly secure" AND the tenant tag: if the tenant tag were mangled (a
49
+ * case/spacing variant that fails `includes(MULTI_TENANT_TAG)`) and we let
50
+ * precedence resolve on the surviving "highly secure" sibling, a CRITICAL
51
+ * rule would silently downgrade to major-high with no throw. Asserting the
52
+ * set is a SUBSET of the five and throwing on the unmappable tag closes that.
53
+ * 2. EMPTY / all-absent — no tag matches any of the five (the vacuous-scope
54
+ * hole): still a throw, never minor.
55
+ */
56
+ export function severityOf(tags, ruleId = "<unknown>") {
57
+ const unmappable = tags.filter((t) => !KNOWN_OBJECTIVES.has(t));
58
+ if (unmappable.length > 0) {
59
+ throw new Error(`severityOf(${ruleId}): objective tag(s) ${JSON.stringify(unmappable)} are not among the five known ` +
60
+ `objectives ${JSON.stringify([...KNOWN_OBJECTIVES])} — an unreadable objectives cell must NOT be ` +
61
+ `absorbed by a recognised sibling tag (a mangled tenant tag alongside "highly secure" would silently ` +
62
+ `downgrade a critical rule); refusing to resolve severity while any tag is unmappable.`);
63
+ }
64
+ if (tags.includes(MULTI_TENANT_TAG))
65
+ return "critical";
66
+ if (tags.includes("highly secure"))
67
+ return "major-high";
68
+ if (tags.includes("enterprise-grade") || tags.includes("production-ready"))
69
+ return "major";
70
+ if (tags.includes("token-efficient"))
71
+ return "minor";
72
+ throw new Error(`severityOf(${ruleId}): objectives ${JSON.stringify(tags)} match none of the five — ` +
73
+ `the standard promises no blank cell; refusing to default an unmapped rule to minor.`);
74
+ }
75
+ /**
76
+ * Parse the objectives table (§13) of the VENDORED standard into ruleId -> tags.
77
+ * A table row is `| R-N <desc> | <tag> · <tag> · … |`. Returns an empty map only
78
+ * if the table is absent — which `deriveSeverityMap` treats as a hard failure.
79
+ */
80
+ export function parseObjectives(path = VENDORED_STANDARD_PATH) {
81
+ const blob = readFileSync(path, "utf8");
82
+ const out = new Map();
83
+ // Scope to §13 "Objective traceability" ONLY — later sections (§14 traceability)
84
+ // also carry `| R-N … |` rows with a different vocabulary ("mechanical"), which
85
+ // must NOT be read as objectives.
86
+ const start = blob.indexOf("## 13. Objective traceability");
87
+ if (start === -1)
88
+ return out; // section absent -> empty -> deriveSeverityMap goes RED
89
+ const after = blob.indexOf("\n## ", start + 1);
90
+ const section = blob.slice(start, after === -1 ? undefined : after);
91
+ for (const line of section.split("\n")) {
92
+ // `| R-5 reader authority… | highly secure · multi-organisation multi-tenant |`
93
+ const m = /^\|\s*(R-\d+)\b[^|]*\|\s*([^|]+?)\s*\|/.exec(line);
94
+ if (!m)
95
+ continue;
96
+ const [, ruleId, cell] = m;
97
+ const tags = cell
98
+ .split("·")
99
+ .map((t) => t.trim())
100
+ .filter((t) => t.length > 0);
101
+ if (tags.length > 0)
102
+ out.set(ruleId, tags);
103
+ }
104
+ return out;
105
+ }
106
+ /**
107
+ * The ruleId -> severity map for every rule the standard defines. Throws (the
108
+ * neuter guard) if the objectives parse yields NOTHING — an empty scope must go
109
+ * RED, never make every rule minor by vacuity.
110
+ */
111
+ export function deriveSeverityMap(path = VENDORED_STANDARD_PATH) {
112
+ const objectives = parseObjectives(path);
113
+ if (objectives.size === 0) {
114
+ throw new Error(`deriveSeverityMap: objectives parse of ${path} returned EMPTY — ` +
115
+ `refusing to build a severity map that would make every rule minor by vacuity.`);
116
+ }
117
+ const map = new Map();
118
+ for (const [ruleId, tags] of objectives)
119
+ map.set(ruleId, severityOf(tags, ruleId));
120
+ return map;
121
+ }
@@ -0,0 +1,57 @@
1
+ import type { DetectorResult, State } from "./engine-types.js";
2
+ import { type Severity } from "./severity.js";
3
+ /** Extract the ruleId (`R-N`) a result belongs to, from its leading id token. */
4
+ export declare function ruleIdOf(result: DetectorResult): string;
5
+ export interface Tally {
6
+ /** rule-level state counts — the four sum to `ruleTotal`. */
7
+ byState: Record<State, number>;
8
+ ruleTotal: number;
9
+ /** the number of raw DetectorResults reduced (>= ruleTotal). */
10
+ resultTotal: number;
11
+ /** reduced rule-level state, per ruleId. */
12
+ ruleState: Map<string, State>;
13
+ }
14
+ /**
15
+ * Reduce the engine's DetectorResults to a rule-level tally. Asserts the four
16
+ * state counts sum to the rule total (the derive-never-type invariant) and that
17
+ * every rule the standard defines is present in the results.
18
+ */
19
+ export declare function tally(results: DetectorResult[], ruleIds?: string[]): Tally;
20
+ export type Outcome = "approve" | "revise-recommended" | "revise-mandatory";
21
+ export interface Verdict {
22
+ outcome: Outcome;
23
+ exitCode: number;
24
+ /** non-conforming rules bucketed by severity. */
25
+ violations: Record<Severity, string[]>;
26
+ /** could-not-judge rules bucketed by severity — abstentions, a human owes a verdict. */
27
+ abstentions: Record<Severity, string[]>;
28
+ /** derived counts the wording turns on. */
29
+ criticalViolations: number;
30
+ majorViolations: number;
31
+ criticalAbstentions: number;
32
+ /** the engine's honest three-way exit over the raw results. */
33
+ engineExitCode: number;
34
+ summary: string;
35
+ }
36
+ /**
37
+ * Map the tally to react-doctor's verdict shape, severity DERIVED from the
38
+ * standard. Verdict rules (major = major-high + major combined for the gate):
39
+ * 0 critical + 0 major violations -> approve
40
+ * 0 critical + 1..5 major violations -> revise recommended
41
+ * 0 critical + >5 major violations -> revise mandatory
42
+ * >=1 critical violation -> revise mandatory (NO bypass)
43
+ * ABSTENTIONS: a could-not-judge is a human verdict owed. Any abstention blocks
44
+ * "approve" (the outcome becomes at least revise-recommended); an abstention on a
45
+ * CRITICAL rule can never be silently approved and is reported first.
46
+ *
47
+ * EXIT CODE preserves the engine's honesty:
48
+ * >=1 critical OR revise-mandatory -> 1 (a violation IS wrong)
49
+ * 1..5 major violations (revise-recommended) -> 1 (hard violations exist)
50
+ * no hard violation, abstention owed -> 2 (not looked = never a clean 0)
51
+ * approve -> 0
52
+ * We cross-check against the engine's computeExitCode and never print "approve"
53
+ * on an exit that means abstention.
54
+ */
55
+ export declare function verdict(t: Tally, results: DetectorResult[], severity?: Map<string, Severity>): Verdict;
56
+ /** The one-line machine tally string the CLI prints. */
57
+ export declare function tallyLine(t: Tally): string;