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