@vantageos/backend-doctor 0.2.0 → 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 (63) hide show
  1. package/README.md +48 -4
  2. package/dist/args.d.ts +1 -1
  3. package/dist/args.js +19 -1
  4. package/dist/build-stamp.json +2 -2
  5. package/dist/caller-resolution.js +11 -8
  6. package/dist/cli.js +164 -15
  7. package/dist/contract.d.ts +25 -7
  8. package/dist/contract.js +52 -7
  9. package/dist/coverage.d.ts +1 -1
  10. package/dist/coverage.js +2 -2
  11. package/dist/detectors/oracle-axis1.js +5 -4
  12. package/dist/detectors/oracle-axis2.d.ts +6 -0
  13. package/dist/detectors/oracle-axis2.js +32 -16
  14. package/dist/detectors/predicates.js +12 -3
  15. package/dist/detectors/shared.js +3 -2
  16. package/dist/detectors/source/_ast.d.ts +92 -0
  17. package/dist/detectors/source/_ast.js +353 -0
  18. package/dist/detectors/source/_scan.js +4 -3
  19. package/dist/detectors/source/backfill.js +62 -17
  20. package/dist/detectors/source/contract.js +21 -13
  21. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  22. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  23. package/dist/detectors/source/guard-helper-flow.d.ts +22 -0
  24. package/dist/detectors/source/guard-helper-flow.js +49 -6
  25. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  26. package/dist/detectors/source/identity-by-id.js +1783 -0
  27. package/dist/detectors/source/identity.d.ts +8 -1
  28. package/dist/detectors/source/identity.js +364 -35
  29. package/dist/detectors/source/jobs.js +13 -2
  30. package/dist/detectors/source/lifecycle.js +14 -2
  31. package/dist/detectors/source/local-identity.d.ts +26 -0
  32. package/dist/detectors/source/local-identity.js +661 -0
  33. package/dist/detectors/source/package-principal.d.ts +9 -0
  34. package/dist/detectors/source/package-principal.js +177 -0
  35. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  36. package/dist/detectors/source/r6-shapes.js +285 -0
  37. package/dist/detectors/source/readbudget.js +291 -0
  38. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  39. package/dist/detectors/source/recipient-by-id.js +1108 -0
  40. package/dist/detectors/source/refusal.d.ts +14 -0
  41. package/dist/detectors/source/refusal.js +75 -2
  42. package/dist/detectors/source/security.js +455 -26
  43. package/dist/detectors/source/writesafety.js +27 -14
  44. package/dist/detectors/standard/_doc.d.ts +1 -1
  45. package/dist/detectors/standard/_doc.js +5 -4
  46. package/dist/detectors/standard/r41-frontier.js +3 -2
  47. package/dist/detectors/standard/r46-returns-validator.js +3 -2
  48. package/dist/detectors/structural.d.ts +6 -7
  49. package/dist/detectors/structural.js +75 -43
  50. package/dist/not-checkable.d.ts +1 -1
  51. package/dist/not-checkable.js +11 -0
  52. package/dist/registry.d.ts +1 -1
  53. package/dist/registry.js +27 -3
  54. package/dist/rule-class.d.ts +85 -0
  55. package/dist/rule-class.js +218 -0
  56. package/dist/tally.d.ts +13 -5
  57. package/dist/tally.js +14 -6
  58. package/dist/violation-count.d.ts +48 -0
  59. package/dist/violation-count.js +94 -0
  60. package/fixtures/backend-standard-2825122.md +722 -0
  61. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  62. package/fixtures/rule-classes-2825122.json +228 -0
  63. package/package.json +1 -1
@@ -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
+ }
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
@@ -71,14 +71,22 @@ function emptyBySeverity() {
71
71
  * 0 critical + 1..5 major violations -> revise recommended
72
72
  * 0 critical + >5 major violations -> revise mandatory
73
73
  * >=1 critical violation -> revise mandatory (NO bypass)
74
- * ABSTENTIONS: a could-not-judge is a human verdict owed. Any abstention blocks
75
- * "approve" (the outcome becomes at least revise-recommended); an abstention on a
76
- * 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.
77
85
  *
78
86
  * EXIT CODE preserves the engine's honesty:
79
87
  * >=1 critical OR revise-mandatory -> 1 (a violation IS wrong)
80
88
  * 1..5 major violations (revise-recommended) -> 1 (hard violations exist)
81
- * 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)
82
90
  * approve -> 0
83
91
  * We cross-check against the engine's computeExitCode and never print "approve"
84
92
  * on an exit that means abstention.
@@ -113,7 +121,7 @@ export function verdict(t, results, severity = deriveSeverityMap()) {
113
121
  exitCode = 1; // hard violations present — a caller must not read a clean 0
114
122
  }
115
123
  else if (abstentionTotal > 0) {
116
- // 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)
117
125
  outcome = "revise-recommended";
118
126
  exitCode = 2;
119
127
  }
@@ -125,7 +133,7 @@ export function verdict(t, results, severity = deriveSeverityMap()) {
125
133
  const criticalNote = criticalViolations > 0
126
134
  ? `${criticalViolations} CRITICAL violation(s) — revise mandatory, NO bypass.`
127
135
  : criticalAbstentions > 0
128
- ? `${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.`
129
137
  : "no critical violation or abstention.";
130
138
  const summary = `VERDICT: ${outcome} (exit ${exitCode}) — ${criticalNote} ` +
131
139
  `major violations=${majorViolations}, abstentions=${abstentionTotal} ` +
@@ -0,0 +1,48 @@
1
+ import type { DetectorResult } from "./engine-types.js";
2
+ import type { MechanicalCount } from "./rule-class.js";
3
+ export interface ViolationCount {
4
+ /** the non-conforming count the verdict line prints. */
5
+ count: number;
6
+ /**
7
+ * per module (see moduleOf) — present for a site-based count, absent for a
8
+ * row- or finding-based one; the values always sum to `count`.
9
+ */
10
+ modules?: Record<string, number>;
11
+ }
12
+ /** Attach `c` to `r` and return `r` (so a producer can `return withCount({...}, c)`). */
13
+ export declare function withCount<R extends DetectorResult>(r: R, c: ViolationCount): R;
14
+ export declare function countOf(r: DetectorResult): ViolationCount | undefined;
15
+ /**
16
+ * The module a site's file belongs to. A convex/ file is its basename without
17
+ * extension (`convex/tasks.ts` -> `tasks`, `convex/lib/auth.ts` -> `auth`). A file
18
+ * outside convex/ (R-53 also reads `mcp-server/src`) keeps its path without
19
+ * extension (`mcp-server/src/tools.ts` -> `mcp-server/src/tools`): it can never
20
+ * collide with a convex basename, and every site has a module, so the split
21
+ * always sums to the count.
22
+ */
23
+ export declare function moduleOf(rel: string): string;
24
+ /** A site-based count: one item per site, split by module. */
25
+ export declare function siteCount(sites: readonly {
26
+ rel: string;
27
+ }[]): ViolationCount;
28
+ /** A row- or finding-based count: no file, so no module split. */
29
+ export declare function itemCount(n: number): ViolationCount;
30
+ /** Sum the counts of several non-conforming results of one rule (merged halves). */
31
+ export declare function sumCounts(parts: readonly (ViolationCount | undefined)[]): ViolationCount | undefined;
32
+ export interface MechanicalRuleCounts {
33
+ /** red mechanical rule id -> its non-conforming count; null when not certifiable. */
34
+ counts: Record<string, number> | null;
35
+ /** red mechanical rule id -> module -> count, for site-based rules. */
36
+ moduleCounts: Record<string, Record<string, number>> | null;
37
+ /** red mechanical rules with no attached count (the reason `counts` is null). */
38
+ uncounted: string[];
39
+ }
40
+ /**
41
+ * One entry per rule in `mech.mechanicalViolations`: the sum of the attached
42
+ * counts of that rule's non-conforming results (the tally reduces a rule to its
43
+ * worst result; when several results of a rule are red, each one's items are
44
+ * printed and counted). Null when `mech.count` is null or any red rule is uncounted.
45
+ */
46
+ export declare function mechanicalRuleCounts(results: readonly DetectorResult[], mech: MechanicalCount): MechanicalRuleCounts;
47
+ /** The human line printed under MECHANICAL VIOLATIONS. */
48
+ export declare function mechanicalRuleCountsLine(m: MechanicalRuleCounts): string;
@@ -0,0 +1,94 @@
1
+ const COUNTS = new WeakMap();
2
+ /** Attach `c` to `r` and return `r` (so a producer can `return withCount({...}, c)`). */
3
+ export function withCount(r, c) {
4
+ COUNTS.set(r, c);
5
+ return r;
6
+ }
7
+ export function countOf(r) {
8
+ return COUNTS.get(r);
9
+ }
10
+ /**
11
+ * The module a site's file belongs to. A convex/ file is its basename without
12
+ * extension (`convex/tasks.ts` -> `tasks`, `convex/lib/auth.ts` -> `auth`). A file
13
+ * outside convex/ (R-53 also reads `mcp-server/src`) keeps its path without
14
+ * extension (`mcp-server/src/tools.ts` -> `mcp-server/src/tools`): it can never
15
+ * collide with a convex basename, and every site has a module, so the split
16
+ * always sums to the count.
17
+ */
18
+ export function moduleOf(rel) {
19
+ const noExt = (p) => p.replace(/\.[^./]+$/, "");
20
+ if (/(^|\/)convex\//.test(rel))
21
+ return noExt(rel.split("/").pop() ?? rel);
22
+ return noExt(rel.replace(/^\.\//, ""));
23
+ }
24
+ /** A site-based count: one item per site, split by module. */
25
+ export function siteCount(sites) {
26
+ const modules = {};
27
+ for (const s of sites) {
28
+ const m = moduleOf(s.rel);
29
+ modules[m] = (modules[m] ?? 0) + 1;
30
+ }
31
+ return { count: sites.length, modules: sortKeys(modules) };
32
+ }
33
+ /** A row- or finding-based count: no file, so no module split. */
34
+ export function itemCount(n) {
35
+ return { count: n };
36
+ }
37
+ /** Sum the counts of several non-conforming results of one rule (merged halves). */
38
+ export function sumCounts(parts) {
39
+ if (parts.length === 0 || parts.some((p) => p === undefined))
40
+ return undefined;
41
+ let count = 0;
42
+ let modules = {};
43
+ for (const p of parts) {
44
+ count += p.count;
45
+ if (p.modules === undefined || modules === undefined)
46
+ modules = undefined;
47
+ else
48
+ for (const [k, v] of Object.entries(p.modules))
49
+ modules[k] = (modules[k] ?? 0) + v;
50
+ }
51
+ return modules === undefined
52
+ ? { count }
53
+ : { count, modules: sortKeys(modules) };
54
+ }
55
+ function sortKeys(o) {
56
+ return Object.fromEntries(Object.entries(o).sort(([a], [b]) => a.localeCompare(b)));
57
+ }
58
+ const ruleOf = (r) => /^((?:R|CR)-\d+)\b/.exec(r.id)?.[1];
59
+ /**
60
+ * One entry per rule in `mech.mechanicalViolations`: the sum of the attached
61
+ * counts of that rule's non-conforming results (the tally reduces a rule to its
62
+ * worst result; when several results of a rule are red, each one's items are
63
+ * printed and counted). Null when `mech.count` is null or any red rule is uncounted.
64
+ */
65
+ export function mechanicalRuleCounts(results, mech) {
66
+ if (mech.count === null)
67
+ return { counts: null, moduleCounts: null, uncounted: [] };
68
+ const counts = {};
69
+ const moduleCounts = {};
70
+ const uncounted = [];
71
+ for (const id of mech.mechanicalViolations) {
72
+ const red = results.filter((r) => ruleOf(r) === id && r.state === "non-conforming");
73
+ const c = sumCounts(red.map(countOf));
74
+ if (c === undefined) {
75
+ uncounted.push(id);
76
+ continue;
77
+ }
78
+ counts[id] = c.count;
79
+ if (c.modules !== undefined)
80
+ moduleCounts[id] = c.modules;
81
+ }
82
+ if (uncounted.length > 0)
83
+ return { counts: null, moduleCounts: null, uncounted };
84
+ return { counts, moduleCounts, uncounted };
85
+ }
86
+ /** The human line printed under MECHANICAL VIOLATIONS. */
87
+ export function mechanicalRuleCountsLine(m) {
88
+ if (m.counts === null)
89
+ return m.uncounted.length > 0
90
+ ? `MECHANICAL RULE COUNTS: COULD-NOT-JUDGE — no count attached for ${m.uncounted.join(", ")} (the evidence file records null)`
91
+ : "MECHANICAL RULE COUNTS: COULD-NOT-JUDGE (the mechanical count is not certified; the evidence file records null)";
92
+ const entries = Object.entries(m.counts);
93
+ return `MECHANICAL RULE COUNTS: ${entries.length ? entries.map(([id, n]) => `${id} ${n}`).join(", ") : "none"}`;
94
+ }