@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.
- package/README.md +48 -4
- package/dist/args.d.ts +1 -1
- package/dist/args.js +19 -1
- package/dist/build-stamp.json +2 -2
- package/dist/caller-resolution.js +11 -8
- package/dist/cli.js +164 -15
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.js +5 -4
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +32 -16
- package/dist/detectors/predicates.js +12 -3
- package/dist/detectors/shared.js +3 -2
- package/dist/detectors/source/_ast.d.ts +92 -0
- package/dist/detectors/source/_ast.js +353 -0
- package/dist/detectors/source/_scan.js +4 -3
- package/dist/detectors/source/backfill.js +62 -17
- package/dist/detectors/source/contract.js +21 -13
- package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
- package/dist/detectors/source/cross-tenant-write.js +1408 -0
- package/dist/detectors/source/guard-helper-flow.d.ts +22 -0
- package/dist/detectors/source/guard-helper-flow.js +49 -6
- package/dist/detectors/source/identity-by-id.d.ts +80 -0
- package/dist/detectors/source/identity-by-id.js +1783 -0
- package/dist/detectors/source/identity.d.ts +8 -1
- package/dist/detectors/source/identity.js +364 -35
- package/dist/detectors/source/jobs.js +13 -2
- package/dist/detectors/source/lifecycle.js +14 -2
- package/dist/detectors/source/local-identity.d.ts +26 -0
- package/dist/detectors/source/local-identity.js +661 -0
- package/dist/detectors/source/package-principal.d.ts +9 -0
- package/dist/detectors/source/package-principal.js +177 -0
- package/dist/detectors/source/r6-shapes.d.ts +20 -0
- package/dist/detectors/source/r6-shapes.js +285 -0
- package/dist/detectors/source/readbudget.js +291 -0
- package/dist/detectors/source/recipient-by-id.d.ts +40 -0
- package/dist/detectors/source/recipient-by-id.js +1108 -0
- package/dist/detectors/source/refusal.d.ts +14 -0
- package/dist/detectors/source/refusal.js +75 -2
- package/dist/detectors/source/security.js +455 -26
- package/dist/detectors/source/writesafety.js +27 -14
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +5 -4
- package/dist/detectors/standard/r41-frontier.js +3 -2
- package/dist/detectors/standard/r46-returns-validator.js +3 -2
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +75 -43
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +11 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +27 -3
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +14 -6
- package/dist/violation-count.d.ts +48 -0
- package/dist/violation-count.js +94 -0
- package/fixtures/backend-standard-2825122.md +722 -0
- package/fixtures/backend-standard-4dcd3e0.md +722 -0
- package/fixtures/rule-classes-2825122.json +228 -0
- 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,
|
|
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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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,
|
|
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
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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,
|
|
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
|
|
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) —
|
|
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
|
+
}
|