@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.
- package/README.md +66 -5
- package/dist/args.d.ts +16 -0
- package/dist/args.js +72 -0
- package/dist/build-stamp.d.ts +13 -0
- package/dist/build-stamp.js +76 -0
- package/dist/build-stamp.json +4 -0
- package/dist/caller-resolution.d.ts +222 -0
- package/dist/caller-resolution.js +1198 -0
- package/dist/cli.js +249 -17
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage-cli.js +8 -0
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.d.ts +5 -3
- package/dist/detectors/oracle-axis1.js +28 -18
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +37 -22
- package/dist/detectors/predicates.d.ts +3 -0
- package/dist/detectors/predicates.js +93 -19
- package/dist/detectors/shared.js +8 -7
- 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 +71 -18
- 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 +134 -0
- package/dist/detectors/source/guard-helper-flow.js +1216 -0
- 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 +58 -2
- package/dist/detectors/source/identity.js +1158 -28
- package/dist/detectors/source/isolation.d.ts +11 -1
- package/dist/detectors/source/isolation.js +106 -25
- package/dist/detectors/source/jobs.js +178 -4
- 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.d.ts +1 -0
- package/dist/detectors/source/readbudget.js +358 -4
- 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.d.ts +16 -0
- package/dist/detectors/source/security.js +680 -33
- package/dist/detectors/source/write-isolation.d.ts +8 -1
- package/dist/detectors/source/write-isolation.js +64 -30
- 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.d.ts +8 -0
- package/dist/detectors/standard/r46-returns-validator.js +48 -16
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +84 -48
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +11 -0
- package/dist/oracle.d.ts +92 -3
- package/dist/oracle.js +229 -12
- package/dist/registry.d.ts +8 -1
- package/dist/registry.js +74 -8
- package/dist/report.d.ts +11 -1
- package/dist/report.js +15 -2
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/state-rank.d.ts +5 -0
- package/dist/state-rank.js +9 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +15 -13
- package/dist/target.d.ts +32 -0
- package/dist/target.js +173 -0
- 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/cr1-classes/_generated/api.d.ts +24 -0
- package/fixtures/cr1-classes/surface.ts +206 -0
- package/fixtures/cr1-empty/nothing.ts +1 -0
- package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
- package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
- package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
- package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
- package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
- package/fixtures/rule-classes-2825122.json +228 -0
- package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
- 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 = "
|
|
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
|
|
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
|
|
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
|
-
].
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
@@ -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
|
-
|
|
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
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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,
|
|
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
|
|
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) —
|
|
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} ` +
|
package/dist/target.d.ts
ADDED
|
@@ -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
|
+
};
|