@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
package/dist/registry.js CHANGED
@@ -7,7 +7,9 @@ import { PER_ROW_PREDICATES } from "./detectors/predicates.js";
7
7
  import { makeOracleDetector } from "./detectors/shared.js";
8
8
  import { SOURCE_BACKFILL_DETECTORS } from "./detectors/source/backfill.js";
9
9
  import { SOURCE_CONTRACT_DETECTORS } from "./detectors/source/contract.js";
10
+ import { SOURCE_CROSS_TENANT_WRITE_DETECTORS } from "./detectors/source/cross-tenant-write.js";
10
11
  import { IDENTITY_DETECTORS } from "./detectors/source/identity.js";
12
+ import { SOURCE_IDENTITY_BY_ID_DETECTORS } from "./detectors/source/identity-by-id.js";
11
13
  import { SOURCE_ISOLATION_DETECTORS } from "./detectors/source/isolation.js";
12
14
  import { SOURCE_JOBS_DETECTORS } from "./detectors/source/jobs.js";
13
15
  import { SOURCE_LIFECYCLE_DETECTORS } from "./detectors/source/lifecycle.js";
@@ -20,8 +22,46 @@ import { R41, SIBLING_PIN } from "./detectors/standard/r41-frontier.js";
20
22
  import { R46_STANDARD_PIN, STANDARD_SOURCE_DETECTORS, } from "./detectors/standard/r46-returns-validator.js";
21
23
  import { STANDARD_DOC_DETECTORS } from "./detectors/standard/standard-doc.js";
22
24
  import { makeR14, makeR38 } from "./detectors/structural.js";
25
+ import { STATE_RANK } from "./state-rank.js";
26
+ import { countOf, sumCounts, withCount } from "./violation-count.js";
23
27
  /** The standard both pins the whole wave against. */
24
- export const STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ 4bcd605656ceff52d4f1593a9176cd6b988471be (elpiarthera/ElPi-Corp)";
28
+ export const STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ 2825122a3ab1e4ddb4b955cfb136b7e8ab04f8f3 (elpiarthera/ElPi-Corp, merge of PR #228 on origin/main, after #226 at 9f7d789)";
29
+ /**
30
+ * Merge two detectors that judge halves of the SAME rule into one result: the
31
+ * worst state wins (non-conforming > could-not-judge > absent > present, the
32
+ * tally's own ranking), the anchor is the deciding half's, the title is the LATER
33
+ * (refined) half's, and both halves' verdicts are kept — nothing is dropped.
34
+ */
35
+ export function mergeHalves(ruleId, a, b) {
36
+ return {
37
+ id: b.id,
38
+ run: (tree) => {
39
+ const ra = a.run(tree);
40
+ const rb = b.run(tree);
41
+ const decider = STATE_RANK[ra.state] > STATE_RANK[rb.state] ? ra : rb;
42
+ const verdict = `${ruleId} (two halves, worst wins): [${ra.id}] ${ra.state} — ${ra.verdict} || [${rb.id}] ${rb.state} — ${rb.verdict}`;
43
+ const reasons = [ra, rb]
44
+ .filter((r) => r.state === "could-not-judge")
45
+ .map((r) => r.reason ?? r.verdict);
46
+ const merged = {
47
+ id: b.id,
48
+ anchor: decider.anchor,
49
+ state: decider.state,
50
+ verdict,
51
+ ...(decider.state === "could-not-judge"
52
+ ? { reason: reasons.join(" || ") }
53
+ : {}),
54
+ };
55
+ // The per-rule count of a red merge is the sum over its RED halves — the
56
+ // halves whose `N site(s) VIOLATE` the merged verdict prints. An abstaining
57
+ // half contributes nothing; a red half with no count leaves it uncounted.
58
+ if (merged.state !== "non-conforming")
59
+ return merged;
60
+ const c = sumCounts([ra, rb].filter((r) => r.state === "non-conforming").map(countOf));
61
+ return c === undefined ? merged : withCount(merged, c);
62
+ },
63
+ };
64
+ }
25
65
  export const REGISTRY = [
26
66
  ...PER_ROW_PREDICATES.map((spec) => ({
27
67
  detector: makeOracleDetector(spec),
@@ -31,7 +71,9 @@ export const REGISTRY = [
31
71
  { detector: makeR14(), ruleId: "R-14", standardPin: STANDARD_PIN },
32
72
  { detector: makeR38(), ruleId: "R-38", standardPin: STANDARD_PIN },
33
73
  // Axis-2 coherence detectors (R-7/R-8/R-37): each performs its mechanical half
34
- // IN FULL and returns could-not-judge, naming the sites a human verdict is owed.
74
+ // IN FULL and decides on the written justification token's PRESENCE in the
75
+ // remediation cell (task k179jnhx, §23) — non-conforming without the token,
76
+ // present with it; could-not-judge only on a row the instrument cannot read.
35
77
  {
36
78
  detector: makeOracleDetector(r7),
37
79
  ruleId: "R-7",
@@ -51,7 +93,7 @@ export const REGISTRY = [
51
93
  {
52
94
  detector: makeR40(),
53
95
  ruleId: "R-40",
54
- standardPin: `${STANDARD_PIN} + oracle:vr-by-tool-day159.csv @ a74a042b`,
96
+ standardPin: `${STANDARD_PIN} + second-backend oracle derived from the target (vr-by-tool.csv / --vr-oracle)`,
55
97
  },
56
98
  // The twenty source-tree detectors (built all at once — the wave plan cancelled).
57
99
  // Each reads the REAL convex/ source; R-6/R-16 also carry a live half. Their
@@ -115,17 +157,41 @@ export const REGISTRY = [
115
157
  // documents a client contract; a gated customMutation wrapper (writeMutation) is
116
158
  // refused (could-not-judge) since client reachability is not visible from convex/.
117
159
  ...SOURCE_WRITE_ISOLATION_DETECTORS,
160
+ // R-52 — a write that reaches a row carrying no tenant value through a
161
+ // caller-choosable key (an argument, or a stored field the caller set
162
+ // through a public write) is dominated by a guard reading a server-stamped
163
+ // tenant. Reads schema.ts tenant columns and every public mutation/action
164
+ // handler on the AST (the vantage-peers 45e40c8 tasks.complete auto-link).
165
+ ...SOURCE_CROSS_TENANT_WRITE_DETECTORS,
166
+ // R-53 — an actor and a target are identified by ID through
167
+ // @vantageos/cloud-identity: per public write door, the principal's
168
+ // source (a), the admission predicate (b), the unresolved branch (c);
169
+ // per helper / MCP gate, a local identity layer (d). Reads convex/ and
170
+ // mcp-server/src (the PR #1464 ten doors + org-less bulk_complete path).
171
+ ...SOURCE_IDENTITY_BY_ID_DETECTORS,
118
172
  ...SOURCE_CONTRACT_DETECTORS,
119
173
  ...IDENTITY_DETECTORS,
120
174
  // The four STANDARD-DOCUMENT detectors (R-42/R-15/R-32/R-39): each reads the
121
175
  // VENDORED standard itself, performs its mechanical/static half in full, and
122
176
  // returns could-not-judge naming the sites a human/gate verdict is owed on.
123
177
  ...STANDARD_DOC_DETECTORS,
124
- ].map((e) => ({
125
- detector: e.detector,
126
- ruleId: e.ruleId,
127
- standardPin: STANDARD_PIN,
128
- })),
178
+ ].reduce((acc, e) => {
179
+ // ONE result per rule. Two modules carry an R-31 half (writesafety: the
180
+ // bulk-WRITE self-schedule; readbudget: the refined read+write budget), and
181
+ // both used to print as two R-31 rows under two different titles. They are
182
+ // merged into one row titled by the refined standard text (4bcd605 :266).
183
+ const prev = acc.find((x) => x.ruleId === e.ruleId);
184
+ if (prev) {
185
+ prev.detector = mergeHalves(e.ruleId, prev.detector, e.detector);
186
+ return acc;
187
+ }
188
+ acc.push({
189
+ detector: e.detector,
190
+ ruleId: e.ruleId,
191
+ standardPin: STANDARD_PIN,
192
+ });
193
+ return acc;
194
+ }, []),
129
195
  // R-41 — the ONE fully-mechanical rule of the nine: it reads the backend
130
196
  // standard's §9 frontier AND the four vendored MCP-family siblings, resolving
131
197
  // every cited S-/C-/E-/A- id against its sibling's defined-id set. present when
package/dist/report.d.ts CHANGED
@@ -17,6 +17,8 @@ export interface ReportContext {
17
17
  * bit a reviewer). Empty string means a clean tree.
18
18
  */
19
19
  treeStatus: string;
20
+ /** which oracle files the run used and where each came from (describeOracle). Optional so older callers still compile. */
21
+ oracle?: string;
20
22
  }
21
23
  /** Derive the committed report path from the target basename + the run date. */
22
24
  export declare function reportPath(target: string, dateISO: string, dir: string): string;
@@ -32,4 +34,12 @@ export declare function buildReport(ctx: ReportContext, results: DetectorResult[
32
34
  * <dir> if needed. Returns the absolute path written. The caller (CLI) supplies the
33
35
  * ReportContext (clock + git) and the computed tally/verdict.
34
36
  */
35
- export declare function writeReport(ctx: ReportContext, results: DetectorResult[], t: Tally, v: Verdict, dir: string, severity?: Map<string, Severity>): string;
37
+ export declare function writeReport(ctx: ReportContext, results: DetectorResult[], t: Tally, v: Verdict, dir: string, severity?: Map<string, Severity>,
38
+ /**
39
+ * Doctor-owned sections appended VERBATIM after the rule tables, each in its
40
+ * own fence. CR-1 renders through here: its contract is that every member of
41
+ * every class is NAMED, and the truncation that hid thirteen of fourteen
42
+ * violations is the defect that contract exists not to repeat — so this
43
+ * carries the section's own text rather than re-deriving or summarising it.
44
+ */
45
+ extraSections?: string[]): string;
package/dist/report.js CHANGED
@@ -94,6 +94,7 @@ export function buildReport(ctx, results, t, v, severity = deriveSeverityMap())
94
94
  - **target commit**: \`${ctx.targetCommit}\`
95
95
  - **standard commit** (pinned): \`${STANDARD_SHA}\`
96
96
  - **tool version**: \`${ctx.toolVersion}\`
97
+ - **oracle**: ${ctx.oracle ?? "(not recorded)"}
97
98
  - **run date**: ${ctx.dateISO}
98
99
  - **machine tally**: \`${tallyLine(t)}\`
99
100
  - **verdict**: ${v.summary}
@@ -130,9 +131,21 @@ ${present.length === 0 ? "_none._" : present.join(", ")}
130
131
  * <dir> if needed. Returns the absolute path written. The caller (CLI) supplies the
131
132
  * ReportContext (clock + git) and the computed tally/verdict.
132
133
  */
133
- export function writeReport(ctx, results, t, v, dir, severity = deriveSeverityMap()) {
134
+ export function writeReport(ctx, results, t, v, dir, severity = deriveSeverityMap(),
135
+ /**
136
+ * Doctor-owned sections appended VERBATIM after the rule tables, each in its
137
+ * own fence. CR-1 renders through here: its contract is that every member of
138
+ * every class is NAMED, and the truncation that hid thirteen of fourteen
139
+ * violations is the defect that contract exists not to repeat — so this
140
+ * carries the section's own text rather than re-deriving or summarising it.
141
+ */
142
+ extraSections = []) {
134
143
  mkdirSync(dir, { recursive: true });
135
144
  const path = reportPath(ctx.target, ctx.dateISO, dir);
136
- writeFileSync(path, buildReport(ctx, results, t, v, severity), "utf8");
145
+ const body = [
146
+ buildReport(ctx, results, t, v, severity),
147
+ ...extraSections.map((sec) => `\n\n\`\`\`\n${sec}\n\`\`\`\n`),
148
+ ].join("");
149
+ writeFileSync(path, body, "utf8");
137
150
  return path;
138
151
  }
@@ -0,0 +1,85 @@
1
+ import type { State } from "./engine-types.js";
2
+ export declare const RULE_CLASS_PATH: string;
3
+ export type GateClass = "mechanical" | "judgement";
4
+ export interface RuleClassFile {
5
+ standard_sha: string;
6
+ standard_kind_to_class: Record<string, string>;
7
+ rules: Record<string, {
8
+ class: string;
9
+ basis: string;
10
+ }>;
11
+ }
12
+ export declare function loadRuleClassFile(path?: string): RuleClassFile;
13
+ /**
14
+ * The build kind the PINNED STANDARD itself states per rule: every row of the
15
+ * section 15 table (`| R-N | mechanical|judgement|process | … |`), then, for a rule the
16
+ * table does not list, the `*kind:* <word>` token inside that rule's own text (up
17
+ * to the next rule definition). A rule the standard classifies nowhere is absent
18
+ * from the map — classifyRules reports it as unclassified.
19
+ */
20
+ export declare function parseStandardKinds(path?: string): Map<string, string>;
21
+ export interface Unclassified {
22
+ id: string;
23
+ reason: string;
24
+ }
25
+ export interface Classification {
26
+ classOf: Map<string, GateClass>;
27
+ basisOf: Map<string, string>;
28
+ /** could-not-judge: rules the classification cannot place. Never defaulted. */
29
+ unclassified: Unclassified[];
30
+ /** sidecar entries naming no rule of this run — reported, a stale sidecar. */
31
+ stale: string[];
32
+ }
33
+ /**
34
+ * Place every id in exactly one class, or name it unclassified with its reason.
35
+ * `ids` is the derived rule set PLUS the doctor-owned ids (CR-1). An R- id is
36
+ * cross-checked against the standard; a non-R id (CR-1) is not in the standard and
37
+ * stands on the sidecar alone.
38
+ */
39
+ export declare function classifyRules(ids: readonly string[], file?: RuleClassFile, standardKinds?: Map<string, string>, standardSha?: string): Classification;
40
+ export interface MechanicalCount {
41
+ /** the gate's count, or `null` when it cannot be certified (could-not-judge). */
42
+ count: number | null;
43
+ mechanicalViolations: string[];
44
+ judgementViolations: string[];
45
+ /** could-not-judge rule states — NOT violations; reported, never folded into 0. */
46
+ mechanicalAbstentions: string[];
47
+ judgementAbstentions: string[];
48
+ unclassified: Unclassified[];
49
+ /** every reason the count is null (empty when it is an integer). */
50
+ cannotCertify: string[];
51
+ }
52
+ /**
53
+ * Count the non-conforming rules whose class is mechanical. The count is `null`
54
+ * — never 0 — when any rule is unclassified (its violation, present or future,
55
+ * could be mechanical) or when the instrument names a self-defect that voids the
56
+ * run (`instrumentDefects`, e.g. incomplete coverage or a dirty judged tree).
57
+ */
58
+ export declare function countMechanical(ruleState: ReadonlyMap<string, State>, classification: Classification, instrumentDefects?: readonly string[]): MechanicalCount;
59
+ /** The human line printed next to the VERDICT line. */
60
+ export declare function mechanicalLine(m: MechanicalCount): string;
61
+ /** The deploy hook's evidence schema, key for key and nothing else. */
62
+ export interface Evidence {
63
+ sha: string;
64
+ cli_commit: string;
65
+ convex_path: string;
66
+ exit_code: number;
67
+ checked: number;
68
+ total: number;
69
+ mechanical_violations: number | null;
70
+ /**
71
+ * Each RED mechanical rule -> the non-conforming count its report line prints
72
+ * (sites for a source rule, rows for an oracle rule). Its key count equals
73
+ * `mechanical_violations`; null whenever that is null or a red rule carries no
74
+ * count (src/violation-count.ts). Additive: the seven keys above are unchanged.
75
+ */
76
+ mechanical_rule_counts: Record<string, number> | null;
77
+ /**
78
+ * Per site-based red mechanical rule: module -> count, where a convex/ file's
79
+ * module is its basename without extension and any other file's is its path
80
+ * without extension (`mcp-server/src/tools`). Values sum to that rule's
81
+ * `mechanical_rule_counts` entry. Row-based rules are absent. Null with the above.
82
+ */
83
+ mechanical_rule_module_counts: Record<string, Record<string, number>> | null;
84
+ }
85
+ export declare function buildEvidence(e: Evidence): Evidence;
@@ -0,0 +1,218 @@
1
+ // The GATE CLASS of every rule — mechanical or judgement — and the one count the
2
+ // deploy gate refuses on: `mechanical_violations`.
3
+ //
4
+ // The vantage-peers deploy hook (`enforce-backend-doctor-before-deploy.py`) reads
5
+ // `qa/backend-doctor-<sha>.json` and refuses ONLY on mechanical non-conformance,
6
+ // never on a judgement/process rule. This module is where that distinction is made,
7
+ // and it is made from DATA, never from TS logic:
8
+ //
9
+ // - the per-rule class lives in the sidecar `fixtures/rule-classes-<pin>.json`
10
+ // (one entry per numbered rule plus CR-1, each with a one-phrase basis);
11
+ // - every R- entry is CROSS-CHECKED against the pinned standard's own build kind
12
+ // (the section 15 "Build kind" column, and the `*kind:*` token of a Detector spec
13
+ // for a rule the table does not list — R-50/R-51), through the sidecar's
14
+ // `standard_kind_to_class` vocabulary (`process` -> judgement);
15
+ // - a rule with no entry, an entry outside the two classes, an entry with no
16
+ // basis, a rule the standard gives no build kind, or a sidecar class that
17
+ // DISAGREES with the standard is UNCLASSIFIED — a could-not-judge. It is never
18
+ // defaulted to either class; the count it would feed becomes `null`.
19
+ //
20
+ // COUNT GRANULARITY — by RULE, the same granularity as the TALLY and the VERDICT
21
+ // line: a rule whose reduced state is `non-conforming` is one violation, however
22
+ // many sites it names. CR-1 (doctor-owned, outside the pinned rule set) counts as
23
+ // one rule.
24
+ import { readFileSync } from "node:fs";
25
+ import { dirname, join } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { STANDARD_SHA, VENDORED_STANDARD_PATH } from "./coverage.js";
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ export const RULE_CLASS_PATH = join(here, "..", "fixtures", "rule-classes-2825122.json");
30
+ const GATE_CLASSES = new Set(["mechanical", "judgement"]);
31
+ export function loadRuleClassFile(path = RULE_CLASS_PATH) {
32
+ return JSON.parse(readFileSync(path, "utf8"));
33
+ }
34
+ /**
35
+ * The build kind the PINNED STANDARD itself states per rule: every row of the
36
+ * section 15 table (`| R-N | mechanical|judgement|process | … |`), then, for a rule the
37
+ * table does not list, the `*kind:* <word>` token inside that rule's own text (up
38
+ * to the next rule definition). A rule the standard classifies nowhere is absent
39
+ * from the map — classifyRules reports it as unclassified.
40
+ */
41
+ export function parseStandardKinds(path = VENDORED_STANDARD_PATH) {
42
+ const blob = readFileSync(path, "utf8");
43
+ const out = new Map();
44
+ const start = blob.indexOf("## 15. Build column");
45
+ if (start !== -1) {
46
+ const after = blob.indexOf("\n## ", start + 1);
47
+ const section = blob.slice(start, after === -1 ? undefined : after);
48
+ for (const line of section.split("\n")) {
49
+ const m = /^\|\s*(R-\d+)\s*\|\s*(mechanical|judgement|process)\s*\|/.exec(line);
50
+ if (m)
51
+ out.set(m[1], m[2]);
52
+ }
53
+ }
54
+ // Detector-spec `*kind:*` for rules outside the table (none at 2825122: the table lists all 52; kept for a future rule defined outside it).
55
+ const lines = blob.split("\n");
56
+ let current;
57
+ for (const line of lines) {
58
+ const def = /^\*\*(R-\d+)\b/.exec(line);
59
+ if (def)
60
+ current = def[1];
61
+ if (current === undefined || out.has(current))
62
+ continue;
63
+ const k = /\*kind:\*\s*(mechanical|judgement|process)\b/.exec(line);
64
+ if (k)
65
+ out.set(current, k[1]);
66
+ }
67
+ return out;
68
+ }
69
+ /**
70
+ * Place every id in exactly one class, or name it unclassified with its reason.
71
+ * `ids` is the derived rule set PLUS the doctor-owned ids (CR-1). An R- id is
72
+ * cross-checked against the standard; a non-R id (CR-1) is not in the standard and
73
+ * stands on the sidecar alone.
74
+ */
75
+ export function classifyRules(ids, file = loadRuleClassFile(), standardKinds = parseStandardKinds(), standardSha = STANDARD_SHA) {
76
+ const classOf = new Map();
77
+ const basisOf = new Map();
78
+ const unclassified = [];
79
+ const pinMismatch = file.standard_sha !== standardSha
80
+ ? `sidecar is pinned to standard ${String(file.standard_sha).slice(0, 12)}, the instrument to ${standardSha.slice(0, 12)}`
81
+ : undefined;
82
+ for (const id of ids) {
83
+ if (pinMismatch) {
84
+ unclassified.push({ id, reason: pinMismatch });
85
+ continue;
86
+ }
87
+ const entry = file.rules?.[id];
88
+ if (entry === undefined) {
89
+ unclassified.push({ id, reason: "no class in the sidecar" });
90
+ continue;
91
+ }
92
+ if (!GATE_CLASSES.has(entry.class)) {
93
+ unclassified.push({
94
+ id,
95
+ reason: `sidecar class ${JSON.stringify(entry.class)} is neither mechanical nor judgement`,
96
+ });
97
+ continue;
98
+ }
99
+ if (typeof entry.basis !== "string" || entry.basis.trim() === "") {
100
+ unclassified.push({
101
+ id,
102
+ reason: "sidecar class carries no basis phrase",
103
+ });
104
+ continue;
105
+ }
106
+ if (/^R-\d+$/.test(id)) {
107
+ const kind = standardKinds.get(id);
108
+ if (kind === undefined) {
109
+ unclassified.push({
110
+ id,
111
+ reason: "the pinned standard states no build kind for this rule",
112
+ });
113
+ continue;
114
+ }
115
+ const expected = file.standard_kind_to_class?.[kind];
116
+ if (expected !== entry.class) {
117
+ unclassified.push({
118
+ id,
119
+ reason: `sidecar says ${entry.class}, the pinned standard says ${kind} (-> ${expected ?? "no mapping"}) — CONFLICT`,
120
+ });
121
+ continue;
122
+ }
123
+ }
124
+ classOf.set(id, entry.class);
125
+ basisOf.set(id, entry.basis);
126
+ }
127
+ const wanted = new Set(ids);
128
+ const stale = Object.keys(file.rules ?? {}).filter((id) => !wanted.has(id));
129
+ return { classOf, basisOf, unclassified, stale };
130
+ }
131
+ const byRuleId = (a, b) => {
132
+ const prefix = (s) => s.replace(/\d+$/, "");
133
+ const n = (s) => Number(/\d+$/.exec(s)?.[0] ?? 0);
134
+ return prefix(a).localeCompare(prefix(b)) || n(a) - n(b);
135
+ };
136
+ /**
137
+ * Count the non-conforming rules whose class is mechanical. The count is `null`
138
+ * — never 0 — when any rule is unclassified (its violation, present or future,
139
+ * could be mechanical) or when the instrument names a self-defect that voids the
140
+ * run (`instrumentDefects`, e.g. incomplete coverage or a dirty judged tree).
141
+ */
142
+ export function countMechanical(ruleState, classification, instrumentDefects = []) {
143
+ const out = {
144
+ count: null,
145
+ mechanicalViolations: [],
146
+ judgementViolations: [],
147
+ mechanicalAbstentions: [],
148
+ judgementAbstentions: [],
149
+ unclassified: classification.unclassified,
150
+ cannotCertify: [...instrumentDefects],
151
+ };
152
+ const unclassifiedIds = new Set(classification.unclassified.map((u) => u.id));
153
+ for (const [id, state] of ruleState) {
154
+ const cls = classification.classOf.get(id);
155
+ if (cls === undefined) {
156
+ if (!unclassifiedIds.has(id)) {
157
+ out.unclassified = [
158
+ ...out.unclassified,
159
+ {
160
+ id,
161
+ reason: "a result exists for a rule the classification never saw",
162
+ },
163
+ ];
164
+ unclassifiedIds.add(id);
165
+ }
166
+ continue;
167
+ }
168
+ if (state === "non-conforming") {
169
+ (cls === "mechanical"
170
+ ? out.mechanicalViolations
171
+ : out.judgementViolations).push(id);
172
+ }
173
+ else if (state === "could-not-judge") {
174
+ (cls === "mechanical"
175
+ ? out.mechanicalAbstentions
176
+ : out.judgementAbstentions).push(id);
177
+ }
178
+ }
179
+ for (const list of [
180
+ out.mechanicalViolations,
181
+ out.judgementViolations,
182
+ out.mechanicalAbstentions,
183
+ out.judgementAbstentions,
184
+ ])
185
+ list.sort(byRuleId);
186
+ if (out.unclassified.length > 0) {
187
+ out.cannotCertify.push(`${out.unclassified.length} rule(s) unclassified: ${out.unclassified.map((u) => `${u.id} (${u.reason})`).join("; ")}`);
188
+ }
189
+ if (out.cannotCertify.length === 0)
190
+ out.count = out.mechanicalViolations.length;
191
+ return out;
192
+ }
193
+ /** The human line printed next to the VERDICT line. */
194
+ export function mechanicalLine(m) {
195
+ const head = m.count === null
196
+ ? "MECHANICAL VIOLATIONS: COULD-NOT-JUDGE (no count certified; the evidence file records null)"
197
+ : `MECHANICAL VIOLATIONS: ${m.count}`;
198
+ const list = (ids) => (ids.length ? ids.join(", ") : "none");
199
+ const lines = [
200
+ `${head} — mechanical [${list(m.mechanicalViolations)}]; judgement violations (printed, not counted) [${list(m.judgementViolations)}]; abstentions (not violations, not 0): mechanical [${list(m.mechanicalAbstentions)}], judgement [${list(m.judgementAbstentions)}]`,
201
+ ];
202
+ for (const why of m.cannotCertify)
203
+ lines.push(` CANNOT CERTIFY: ${why}`);
204
+ return lines.join("\n");
205
+ }
206
+ export function buildEvidence(e) {
207
+ return {
208
+ sha: e.sha,
209
+ cli_commit: e.cli_commit,
210
+ convex_path: e.convex_path,
211
+ exit_code: e.exit_code,
212
+ checked: e.checked,
213
+ total: e.total,
214
+ mechanical_violations: e.mechanical_violations,
215
+ mechanical_rule_counts: e.mechanical_rule_counts,
216
+ mechanical_rule_module_counts: e.mechanical_rule_module_counts,
217
+ };
218
+ }
@@ -0,0 +1,5 @@
1
+ import type { State } from "./engine-types.js";
2
+ /** The four states, worst highest — every reduction (tally, a merged rule) picks
3
+ * the worst. A module of its own so registry.ts can use it without a cycle
4
+ * through tally -> coverage -> registry. */
5
+ export declare const STATE_RANK: Record<State, number>;
@@ -0,0 +1,9 @@
1
+ /** The four states, worst highest — every reduction (tally, a merged rule) picks
2
+ * the worst. A module of its own so registry.ts can use it without a cycle
3
+ * through tally -> coverage -> registry. */
4
+ export const STATE_RANK = {
5
+ "non-conforming": 3,
6
+ "could-not-judge": 2,
7
+ absent: 1,
8
+ present: 0,
9
+ };
package/dist/tally.d.ts CHANGED
@@ -23,7 +23,7 @@ export interface Verdict {
23
23
  exitCode: number;
24
24
  /** non-conforming rules bucketed by severity. */
25
25
  violations: Record<Severity, string[]>;
26
- /** could-not-judge rules bucketed by severity — abstentions, a human owes a verdict. */
26
+ /** could-not-judge rules bucketed by severity — abstentions: the instrument did not decide, and each names why. A disposition-only abstention is retired (k179jnhx); see the ABSTENTIONS note below for what remains. */
27
27
  abstentions: Record<Severity, string[]>;
28
28
  /** derived counts the wording turns on. */
29
29
  criticalViolations: number;
@@ -40,14 +40,22 @@ export interface Verdict {
40
40
  * 0 critical + 1..5 major violations -> revise recommended
41
41
  * 0 critical + >5 major violations -> revise mandatory
42
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.
43
+ * ABSTENTIONS: a could-not-judge means the instrument did NOT decide, and says
44
+ * why. Task k179jnhx retired judgement as an acceptance kind: a rule whose
45
+ * DISPOSITION used to wait on a person now decides on a written justification
46
+ * token's PRESENCE. What still abstains is named per rule, never relabelled:
47
+ * an unreadable input (unparseable file, unreachable tree, absent oracle), a
48
+ * rule with no detector yet (pinned NOT_MECHANICALLY_CHECKABLE), a population
49
+ * the source cannot decide statically, or a recorded marker claim the standard
50
+ * itself sends to a reviewer (R-51's write-contract marker).
51
+ * Any abstention still blocks "approve" (the outcome becomes at least
52
+ * revise-recommended); an abstention on a CRITICAL rule can never be silently
53
+ * approved and is reported first.
46
54
  *
47
55
  * EXIT CODE preserves the engine's honesty:
48
56
  * >=1 critical OR revise-mandatory -> 1 (a violation IS wrong)
49
57
  * 1..5 major violations (revise-recommended) -> 1 (hard violations exist)
50
- * no hard violation, abstention owed -> 2 (not looked = never a clean 0)
58
+ * no hard violation, input unreadable somewhere -> 2 (not read = never a clean 0)
51
59
  * approve -> 0
52
60
  * We cross-check against the engine's computeExitCode and never print "approve"
53
61
  * on an exit that means abstention.
package/dist/tally.js CHANGED
@@ -12,13 +12,7 @@
12
12
  import { computeExitCode } from "@vantageos/mcp-doctor";
13
13
  import { deriveRuleIds } from "./coverage.js";
14
14
  import { deriveSeverityMap } from "./severity.js";
15
- /** The four states, in worst-first order — the reduction picks the worst. */
16
- const STATE_RANK = {
17
- "non-conforming": 3,
18
- "could-not-judge": 2,
19
- absent: 1,
20
- present: 0,
21
- };
15
+ import { STATE_RANK } from "./state-rank.js";
22
16
  const ALL_STATES = [
23
17
  "present",
24
18
  "non-conforming",
@@ -77,14 +71,22 @@ function emptyBySeverity() {
77
71
  * 0 critical + 1..5 major violations -> revise recommended
78
72
  * 0 critical + >5 major violations -> revise mandatory
79
73
  * >=1 critical violation -> revise mandatory (NO bypass)
80
- * ABSTENTIONS: a could-not-judge is a human verdict owed. Any abstention blocks
81
- * "approve" (the outcome becomes at least revise-recommended); an abstention on a
82
- * CRITICAL rule can never be silently approved and is reported first.
74
+ * ABSTENTIONS: a could-not-judge means the instrument did NOT decide, and says
75
+ * why. Task k179jnhx retired judgement as an acceptance kind: a rule whose
76
+ * DISPOSITION used to wait on a person now decides on a written justification
77
+ * token's PRESENCE. What still abstains is named per rule, never relabelled:
78
+ * an unreadable input (unparseable file, unreachable tree, absent oracle), a
79
+ * rule with no detector yet (pinned NOT_MECHANICALLY_CHECKABLE), a population
80
+ * the source cannot decide statically, or a recorded marker claim the standard
81
+ * itself sends to a reviewer (R-51's write-contract marker).
82
+ * Any abstention still blocks "approve" (the outcome becomes at least
83
+ * revise-recommended); an abstention on a CRITICAL rule can never be silently
84
+ * approved and is reported first.
83
85
  *
84
86
  * EXIT CODE preserves the engine's honesty:
85
87
  * >=1 critical OR revise-mandatory -> 1 (a violation IS wrong)
86
88
  * 1..5 major violations (revise-recommended) -> 1 (hard violations exist)
87
- * no hard violation, abstention owed -> 2 (not looked = never a clean 0)
89
+ * no hard violation, input unreadable somewhere -> 2 (not read = never a clean 0)
88
90
  * approve -> 0
89
91
  * We cross-check against the engine's computeExitCode and never print "approve"
90
92
  * on an exit that means abstention.
@@ -119,7 +121,7 @@ export function verdict(t, results, severity = deriveSeverityMap()) {
119
121
  exitCode = 1; // hard violations present — a caller must not read a clean 0
120
122
  }
121
123
  else if (abstentionTotal > 0) {
122
- // no hard violation, but a human verdict is owed (critical abstention first)
124
+ // no hard violation, but some input could not be READ (critical abstention first)
123
125
  outcome = "revise-recommended";
124
126
  exitCode = 2;
125
127
  }
@@ -131,7 +133,7 @@ export function verdict(t, results, severity = deriveSeverityMap()) {
131
133
  const criticalNote = criticalViolations > 0
132
134
  ? `${criticalViolations} CRITICAL violation(s) — revise mandatory, NO bypass.`
133
135
  : criticalAbstentions > 0
134
- ? `${criticalAbstentions} CRITICAL abstention(s) — a human verdict is owed; approve is blocked.`
136
+ ? `${criticalAbstentions} CRITICAL abstention(s) — not decided (each rule names why); approve is blocked.`
135
137
  : "no critical violation or abstention.";
136
138
  const summary = `VERDICT: ${outcome} (exit ${exitCode}) — ${criticalNote} ` +
137
139
  `major violations=${majorViolations}, abstentions=${abstentionTotal} ` +
@@ -0,0 +1,32 @@
1
+ import type { Detector, Tree } from "./engine-types.js";
2
+ export interface TargetScope {
3
+ /** the argument as given, resolved to an absolute path. */
4
+ input: string;
5
+ /** the repository root every `rel` is computed against. */
6
+ repoRoot: string;
7
+ /** which marker made `repoRoot` the root. */
8
+ repoRootBy: "package.json" | ".git" | "no marker (argument used as-is)";
9
+ /** the convex functions dir, or undefined when the repository has none. */
10
+ convexDir: string | undefined;
11
+ /** the directories actually walked, absolute, in order. */
12
+ scanRoots: string[];
13
+ }
14
+ /** Resolve a CLI argument to the backend it names. Pure over the filesystem. */
15
+ export declare function resolveTarget(arg: string): TargetScope;
16
+ /** The ONE tree every detector judges: the scan roots' files, `rel` relative to
17
+ * the repository root, `root` = the repository root. */
18
+ export declare function loadScopedTree(scope: TargetScope): Tree;
19
+ /** Files scanned per top-level directory (two segments under the repo root), so a
20
+ * reader sees WHAT was judged — an empty or surprising surface is visible. */
21
+ export declare function coverageInventory(tree: Tree): Map<string, number>;
22
+ export declare function describeScope(scope: TargetScope, tree: Tree): string;
23
+ /** Wrap detectors so each judges the scoped tree instead of the engine's
24
+ * per-argument tree. runDoctor still owns the loud-fail contract. */
25
+ export declare function onScopedTree(tree: Tree, detectors: Detector[]): Detector[];
26
+ /** Absolute convex dir for a scoped tree (tree.root = repo root) or for a tree
27
+ * rooted AT a convex dir (direct detector tests). `prefix` is what the tree's
28
+ * rels carry in front of a convex-relative path. */
29
+ export declare function convexRootOf(tree: Tree): {
30
+ dir: string;
31
+ prefix: string;
32
+ };