mjolnir-qa 4.0.0 → 5.0.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/CHANGELOG.md +269 -1
- package/README.br.md +10 -10
- package/README.md +12 -12
- package/dist/cli.d.mts +181 -4
- package/dist/cli.mjs +1481 -120
- package/dist/mcp/stdio.mjs +390 -193
- package/dist/scan-pipeline-DNYbnWjE.mjs +2 -0
- package/dist/{scan-pipeline-CCJdEKNa.mjs → scan-pipeline-DT8DWAMl.mjs} +344 -179
- package/package.json +25 -8
- package/dist/scan-pipeline-Bg-46cLS.mjs +0 -2
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as BADGE_BAND, A as plainContext, At as
|
|
2
|
+
import { $ as BADGE_BAND, A as plainContext, At as ConfigValidationError, B as shouldUseAscii, Bt as MEASURED_FP, C as runForensics, Ct as parseTsFile, D as nextStep, Dt as createIgnoreMatcher, E as buildFooter, Et as LIMITS, F as padTo, Ft as isRecord, G as countOrNull, Gt as DEDUCTIONS, H as atomicTempPath, Ht as degradationsSince, I as palette, It as parseJsonFile, J as evidenceTag, Jt as SEVERITY_ORDER$1, K as deriveScoreState, Kt as QA_IMPACT_LABELS, L as sanitizeData, Lt as resolveGitPath, M as severityIcon, Mt as loadConfig, N as box, Nt as readFileBounded, O as okIcon, Ot as globToRegExp, P as measure, Pt as ENGINE_VERSION, Q as TRUST_RUNGS, Qt as isValidCategory, R as scoreGauge, Rt as runGit, S as renderSuppressions, St as computeCodeText, T as FLAKE_GLYPH, Tt as DEFAULT_IGNORE_MATCHER, U as sweepStaleTempFiles, Ut as recordDegradation, V as wrapText, Vt as beginDegradationWindow, W as writeFileAtomic, Wt as summarizeDegradations, X as testsAnalyzedCell, Xt as deriveEvidenceLevel, Y as headlineFor, Yt as TRUST_ORDER, Z as verdictFor, Zt as isAdvisoryFinding, _t as SEARCHED_FOR, at as correlateFindings, b as loadLocalRules, bt as parseWorkflow, ct as capForTier, dt as massCeiling, et as SCORE, ft as compareCodePoints, gt as SCAN_ADAPTERS, h as scan_pipeline_exports, ht as getRule, it as getReachableFiles, j as sectionHeader, jt as isSuppressionActive, k as panel, kt as isLintFixtureDir, lt as computeDimensions, m as runScan$1, mt as RULES, n as KNOWN_RULE_IDS$1, nt as TINT, ot as classifyProvenance, pt as RETIRED_RULE_IDS, q as evidenceLevelOf, qt as RULE_CATEGORIES, rt as buildDependencyGraph, st as computeAgenticProfile, tt as STATUS, ut as deductionFor, v as pluginsGateOpen, vt as isAzurePipelineFixture, w as sanitizeErrorText, wt as detectFrameworks, x as loadSuppressions, xt as parseYamlGuarded, y as renderGateNotice, yt as parseAzurePipeline, z as shouldColorize, zt as buildEvidenceGraph } from "./scan-pipeline-DT8DWAMl.mjs";
|
|
3
3
|
import { accessSync, appendFileSync, chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import process$1 from "node:process";
|
|
@@ -433,6 +433,16 @@ const HELP_ENTRIES = [
|
|
|
433
433
|
usage: "mjolnir explain <RULE-ID> [--fixtures-root <dir>]",
|
|
434
434
|
examples: ["mjolnir explain QA-TEST-001", "mjolnir rules --unmeasured"]
|
|
435
435
|
},
|
|
436
|
+
{
|
|
437
|
+
verb: "capability",
|
|
438
|
+
summary: "the capability registry: every capability at the maturity the machine can prove",
|
|
439
|
+
usage: "mjolnir capability [--json] [--kind <k>] [--maturity M0..M5] [--id <substring>]",
|
|
440
|
+
examples: [
|
|
441
|
+
"mjolnir capability",
|
|
442
|
+
"mjolnir capability --maturity M2",
|
|
443
|
+
"mjolnir capability --json > registry.json"
|
|
444
|
+
]
|
|
445
|
+
},
|
|
436
446
|
{
|
|
437
447
|
verb: "rules",
|
|
438
448
|
summary: "rule catalog + empirical-measurement stats/health (md/json/stats/health)",
|
|
@@ -789,6 +799,7 @@ const GROUPS = [
|
|
|
789
799
|
"trust-trend",
|
|
790
800
|
"evidence-graph",
|
|
791
801
|
"framework-maturity",
|
|
802
|
+
"capability",
|
|
792
803
|
"suppression-gate",
|
|
793
804
|
"business-case",
|
|
794
805
|
"release-report",
|
|
@@ -1466,6 +1477,49 @@ function ciInstall(root, gate, options = {}) {
|
|
|
1466
1477
|
diffSummary: []
|
|
1467
1478
|
};
|
|
1468
1479
|
}
|
|
1480
|
+
//#endregion
|
|
1481
|
+
//#region src/cli-io.ts
|
|
1482
|
+
const out = (...parts) => console.log(parts.map(String).join(" "));
|
|
1483
|
+
const err = (...parts) => console.error(parts.map(String).join(" "));
|
|
1484
|
+
function internalErrorMessage(err, emit, debug) {
|
|
1485
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1486
|
+
emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
|
|
1487
|
+
emit(` ${message}`);
|
|
1488
|
+
if (debug && err instanceof Error && err.stack) emit(err.stack);
|
|
1489
|
+
emit("Rerun with --debug for the stack trace. Please report this:");
|
|
1490
|
+
emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* The ONE error-to-message derivation in `src/`.
|
|
1494
|
+
*
|
|
1495
|
+
* This tree carried four near-copies: two `errorMessage` (byte-identical) and
|
|
1496
|
+
* two `errorText`. The two `errorText` copies were not identical, which is the
|
|
1497
|
+
* part that mattered — `String(someObject)` renders `[object Object]`, so the
|
|
1498
|
+
* copy that lost the payload turned a hostile non-Error throw into a message
|
|
1499
|
+
* that names nothing. The richer arms are kept here, so every call site gets
|
|
1500
|
+
* the informative one and the difference stops being a per-file accident.
|
|
1501
|
+
*
|
|
1502
|
+
* The arms, in order:
|
|
1503
|
+
* Error -> `.message`. The 99% case, and the only one that
|
|
1504
|
+
* carries a stack the caller may still want.
|
|
1505
|
+
* string -> itself. A thrown string is already the message.
|
|
1506
|
+
* plain object -> `JSON.stringify`. A non-Error throw of `{ code:
|
|
1507
|
+
* "EISDIR" }` is a REAL failure shape in Node, and
|
|
1508
|
+
* `[object Object]` hides the only field that says
|
|
1509
|
+
* what went wrong.
|
|
1510
|
+
* anything else -> `String(err)`. Symbols and undefined included, so
|
|
1511
|
+
* nothing vanishes.
|
|
1512
|
+
*
|
|
1513
|
+
* The call this replaces was `err instanceof Error ? err.message : String(err)`
|
|
1514
|
+
* in `src/mcp/server.ts` and `src/commands/doctor.ts`; `tests/cli/summary.spec.ts`
|
|
1515
|
+
* already pins the object arm.
|
|
1516
|
+
*/
|
|
1517
|
+
function errorMessage(err) {
|
|
1518
|
+
if (err instanceof Error) return err.message;
|
|
1519
|
+
if (typeof err === "string") return err;
|
|
1520
|
+
if (typeof err === "object" && err !== null) return JSON.stringify(err);
|
|
1521
|
+
return String(err);
|
|
1522
|
+
}
|
|
1469
1523
|
/**
|
|
1470
1524
|
* Fields that participate in the verification digest: identity (ruleId,
|
|
1471
1525
|
* detectorRevision, file, line, column) + evidence (severity,
|
|
@@ -1669,6 +1723,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
1669
1723
|
ok: false,
|
|
1670
1724
|
error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
|
|
1671
1725
|
};
|
|
1726
|
+
const window = beginDegradationWindow();
|
|
1727
|
+
const degraded = () => {
|
|
1728
|
+
const counts = summarizeDegradations(degradationsSince(window));
|
|
1729
|
+
return counts.length > 0 ? { exampleDegraded: counts } : {};
|
|
1730
|
+
};
|
|
1672
1731
|
const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
|
|
1673
1732
|
if (!fixturePath) return {
|
|
1674
1733
|
ok: true,
|
|
@@ -1678,9 +1737,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
1678
1737
|
try {
|
|
1679
1738
|
text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
|
|
1680
1739
|
} catch {
|
|
1740
|
+
recordDegradation("explain-fixture-unreadable");
|
|
1681
1741
|
return {
|
|
1682
1742
|
ok: true,
|
|
1683
|
-
rule
|
|
1743
|
+
rule,
|
|
1744
|
+
...degraded()
|
|
1684
1745
|
};
|
|
1685
1746
|
}
|
|
1686
1747
|
const normalizedPath = fixturePath.replaceAll("\\", "/");
|
|
@@ -1690,9 +1751,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
1690
1751
|
else try {
|
|
1691
1752
|
ast = isAzurePipelineFixture(normalizedPath) ? parseAzurePipeline(text) : parseWorkflow(text);
|
|
1692
1753
|
} catch {
|
|
1754
|
+
recordDegradation("explain-workflow-parse-failed");
|
|
1693
1755
|
return {
|
|
1694
1756
|
ok: true,
|
|
1695
|
-
rule
|
|
1757
|
+
rule,
|
|
1758
|
+
...degraded()
|
|
1696
1759
|
};
|
|
1697
1760
|
}
|
|
1698
1761
|
}
|
|
@@ -1709,9 +1772,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
1709
1772
|
codeText
|
|
1710
1773
|
});
|
|
1711
1774
|
} catch {
|
|
1775
|
+
recordDegradation("explain-rule-crash");
|
|
1712
1776
|
return {
|
|
1713
1777
|
ok: true,
|
|
1714
|
-
rule
|
|
1778
|
+
rule,
|
|
1779
|
+
...degraded()
|
|
1715
1780
|
};
|
|
1716
1781
|
}
|
|
1717
1782
|
const example = findings[0];
|
|
@@ -1773,7 +1838,13 @@ function renderExplain(result, width = DEFAULT_EXPLAIN_WIDTH) {
|
|
|
1773
1838
|
pushBody(f.fix);
|
|
1774
1839
|
lines.push("");
|
|
1775
1840
|
pushBody(`Example from this rule's own must-fire fixture: ${result.exampleFixtureRelPath ?? result.exampleFixturePath ?? "(unknown path)"}`);
|
|
1776
|
-
} else
|
|
1841
|
+
} else {
|
|
1842
|
+
for (const seg of wrapText("No example available — run this command from a mjolnir checkout (or pass --fixtures-root) so the fixture that proves this rule works can be shown as a real example.", width)) lines.push(seg);
|
|
1843
|
+
for (const entry of result.exampleDegraded ?? []) {
|
|
1844
|
+
lines.push("");
|
|
1845
|
+
for (const seg of wrapText(`NOT SHOWN — ${entry.reason} (${entry.count}): the example is missing because the check did not complete, not because the rule found nothing.`, width)) lines.push(seg);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1777
1848
|
lines.push("");
|
|
1778
1849
|
lines.push("WHAT WOULD CHANGE THE VERDICT");
|
|
1779
1850
|
for (const c of whatWouldChangeTheVerdict(r)) pushBody(`- ${c}`);
|
|
@@ -1852,7 +1923,7 @@ function whatWouldChangeTheVerdict(r) {
|
|
|
1852
1923
|
/** Structural validation of the loaded JSON (hostile-input safe). */
|
|
1853
1924
|
function parseScanJson(raw) {
|
|
1854
1925
|
try {
|
|
1855
|
-
return parseJsonFile(raw, "scan result", (v) => isRecord
|
|
1926
|
+
return parseJsonFile(raw, "scan result", (v) => isRecord(v) && v.schemaVersion === 1 && Array.isArray(v.findings) && Boolean(v.analysisStatus) && typeof v.partial === "boolean");
|
|
1856
1927
|
} catch {
|
|
1857
1928
|
return;
|
|
1858
1929
|
}
|
|
@@ -3064,15 +3135,11 @@ async function handleToolCall(call) {
|
|
|
3064
3135
|
id: call.id,
|
|
3065
3136
|
error: {
|
|
3066
3137
|
code: MCP_ERRORS.INTERNAL,
|
|
3067
|
-
message: errorMessage
|
|
3138
|
+
message: errorMessage(err)
|
|
3068
3139
|
}
|
|
3069
3140
|
};
|
|
3070
3141
|
}
|
|
3071
3142
|
}
|
|
3072
|
-
/** Normalize any thrown value to a string (unit-tested both arms). */
|
|
3073
|
-
function errorMessage$1(err) {
|
|
3074
|
-
return err instanceof Error ? err.message : String(err);
|
|
3075
|
-
}
|
|
3076
3143
|
/** JSON-RPC request shape validation (strict — §21). */
|
|
3077
3144
|
function validateRequest(msg) {
|
|
3078
3145
|
if (typeof msg !== "object" || msg === null) return {
|
|
@@ -3175,24 +3242,6 @@ async function handleMcpMessage(msg) {
|
|
|
3175
3242
|
};
|
|
3176
3243
|
}
|
|
3177
3244
|
//#endregion
|
|
3178
|
-
//#region src/cli-io.ts
|
|
3179
|
-
const out = (...parts) => console.log(parts.map(String).join(" "));
|
|
3180
|
-
const err = (...parts) => console.error(parts.map(String).join(" "));
|
|
3181
|
-
function internalErrorMessage(err, emit, debug) {
|
|
3182
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3183
|
-
emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
|
|
3184
|
-
emit(` ${message}`);
|
|
3185
|
-
if (debug && err instanceof Error && err.stack) emit(err.stack);
|
|
3186
|
-
emit("Rerun with --debug for the stack trace. Please report this:");
|
|
3187
|
-
emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
|
|
3188
|
-
}
|
|
3189
|
-
/**
|
|
3190
|
-
* The single error-to-message derivation shared by every catch site:
|
|
3191
|
-
* Error instances render their message; anything else is stringified
|
|
3192
|
-
* honestly (a hostile non-Error throw must surface, not vanish).
|
|
3193
|
-
*/
|
|
3194
|
-
const errorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
3195
|
-
//#endregion
|
|
3196
3245
|
//#region src/mcp/transport.ts
|
|
3197
3246
|
/**
|
|
3198
3247
|
* MCP stdio transport loop (blueprint §21 — the framing around the
|
|
@@ -4337,13 +4386,13 @@ function renderMermaid(result) {
|
|
|
4337
4386
|
lines.push(" class NOTESTS unknown;");
|
|
4338
4387
|
return lines.join("\n");
|
|
4339
4388
|
}
|
|
4340
|
-
const frameworks = [...result.frameworks].sort(
|
|
4389
|
+
const frameworks = [...result.frameworks].sort(compareCodePoints);
|
|
4341
4390
|
if (frameworks.length === 0) lines.push(` ${rootId} --> FW_unknown["Frameworks: UNKNOWN"]`);
|
|
4342
4391
|
else for (const fw of frameworks) {
|
|
4343
4392
|
const fwId = `FW_${sanitizeId(fw)}`;
|
|
4344
4393
|
lines.push(` ${rootId} --> ${fwId}["${escapeLabel(fw)}"]`);
|
|
4345
4394
|
}
|
|
4346
|
-
const dimensions = [...result.dimensions].sort((a, b) => a.category
|
|
4395
|
+
const dimensions = [...result.dimensions].sort((a, b) => compareCodePoints(a.category, b.category));
|
|
4347
4396
|
const styleAssignments = [];
|
|
4348
4397
|
for (const dim of dimensions) {
|
|
4349
4398
|
const catId = `CAT_${sanitizeId(dim.category)}`;
|
|
@@ -4549,21 +4598,11 @@ const EVIDENCE_VALUES = /* @__PURE__ */ new Set([
|
|
|
4549
4598
|
"E2"
|
|
4550
4599
|
]);
|
|
4551
4600
|
const TRUST_VALUES = new Set(TRUST_ORDER);
|
|
4552
|
-
/** Human message for any thrown value — never "undefined"/"[object Object]". */
|
|
4553
|
-
function errorText$1(err) {
|
|
4554
|
-
if (err instanceof Error) return err.message;
|
|
4555
|
-
if (typeof err === "string") return err;
|
|
4556
|
-
if (typeof err === "object" && err !== null) return JSON.stringify(err);
|
|
4557
|
-
return String(err);
|
|
4558
|
-
}
|
|
4559
|
-
function isRecord$2(value) {
|
|
4560
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4561
|
-
}
|
|
4562
4601
|
function fail(message) {
|
|
4563
4602
|
throw new Error(message);
|
|
4564
4603
|
}
|
|
4565
4604
|
function requireRecord(value, label) {
|
|
4566
|
-
if (!isRecord
|
|
4605
|
+
if (!isRecord(value)) fail(`${label} must be an object`);
|
|
4567
4606
|
return value;
|
|
4568
4607
|
}
|
|
4569
4608
|
function requireString(value, label) {
|
|
@@ -4661,7 +4700,7 @@ function validateReportJson(text) {
|
|
|
4661
4700
|
try {
|
|
4662
4701
|
parsed = JSON.parse(text);
|
|
4663
4702
|
} catch (err) {
|
|
4664
|
-
throw new Error(`not valid JSON (${
|
|
4703
|
+
throw new Error(`not valid JSON (${errorMessage(err)})`, { cause: err });
|
|
4665
4704
|
}
|
|
4666
4705
|
const doc = requireRecord(parsed, "the report is not an object");
|
|
4667
4706
|
if (doc.schemaVersion !== 1) fail(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected schemaVersion 1`);
|
|
@@ -4685,7 +4724,7 @@ function validateReportJson(text) {
|
|
|
4685
4724
|
const partialMarkers = status["discovery"] !== "complete" || status["rules"] !== "complete" || Number(status["skippedFiles"]) > 0 || Number(status["rulesCrashed"] ?? 0) > 0 || Array.isArray(status["reasons"]) && status["reasons"].length > 0;
|
|
4686
4725
|
if (doc.partial === false && partialMarkers) fail("partial=false conflicts with analysisStatus");
|
|
4687
4726
|
if (doc.partial === true && typeof doc.score === "number" && doc.score === 100) fail("partial reports cannot have a perfect score");
|
|
4688
|
-
if ((isRecord
|
|
4727
|
+
if ((isRecord(doc.scopeIntegrity) ? doc.scopeIntegrity : void 0)?.["scopeVerdict"] === "PARTIAL" && doc.partial === false) fail("partial=false conflicts with scopeIntegrity");
|
|
4689
4728
|
return parsed;
|
|
4690
4729
|
}
|
|
4691
4730
|
/** Load + validate a saved report from disk. Throws on any problem. */
|
|
@@ -4701,7 +4740,7 @@ function reportExists(reportPath) {
|
|
|
4701
4740
|
}
|
|
4702
4741
|
function identityOf(result) {
|
|
4703
4742
|
const identity = result.runIdentity;
|
|
4704
|
-
return isRecord
|
|
4743
|
+
return isRecord(identity) ? identity : void 0;
|
|
4705
4744
|
}
|
|
4706
4745
|
/**
|
|
4707
4746
|
* Classify a loaded report. Pure — it reads the report and the caller's
|
|
@@ -4723,7 +4762,7 @@ function classifySavedReport(result, expected) {
|
|
|
4723
4762
|
};
|
|
4724
4763
|
const commit = typeof identity["commit"] === "string" ? identity["commit"] : void 0;
|
|
4725
4764
|
const candidate = identity["candidate"];
|
|
4726
|
-
const candidateManifestId = isRecord
|
|
4765
|
+
const candidateManifestId = isRecord(candidate) && typeof candidate["manifestId"] === "string" ? candidate["manifestId"] : void 0;
|
|
4727
4766
|
if (expected !== void 0) {
|
|
4728
4767
|
if (expected.scanId !== void 0 && expected.scanId !== scanId) return {
|
|
4729
4768
|
state: "OPEN",
|
|
@@ -4893,7 +4932,7 @@ function buildArtifactIdentity(result, commit) {
|
|
|
4893
4932
|
detectorRevisions: [...revisions.entries()].map(([ruleId, detectorRevision]) => ({
|
|
4894
4933
|
ruleId,
|
|
4895
4934
|
detectorRevision
|
|
4896
|
-
})).sort((a, b) => a.ruleId
|
|
4935
|
+
})).sort((a, b) => compareCodePoints(a.ruleId, b.ruleId)),
|
|
4897
4936
|
evidenceInventory: {
|
|
4898
4937
|
totalFindings: result.findings.length,
|
|
4899
4938
|
corroborated,
|
|
@@ -5625,7 +5664,7 @@ async function runWhyCommand(argv, io = {
|
|
|
5625
5664
|
try {
|
|
5626
5665
|
result = loadSavedReport(reportPath);
|
|
5627
5666
|
} catch (err) {
|
|
5628
|
-
io.err(`mjolnir why: cannot read ${reportPath}: ${
|
|
5667
|
+
io.err(`mjolnir why: cannot read ${reportPath}: ${errorMessage(err)}`);
|
|
5629
5668
|
return 2;
|
|
5630
5669
|
}
|
|
5631
5670
|
} else {
|
|
@@ -5644,7 +5683,7 @@ async function runWhyCommand(argv, io = {
|
|
|
5644
5683
|
strict: argv.includes("--strict")
|
|
5645
5684
|
});
|
|
5646
5685
|
} catch (err) {
|
|
5647
|
-
io.err(`mjolnir why: scan failed: ${
|
|
5686
|
+
io.err(`mjolnir why: scan failed: ${errorMessage(err)}`);
|
|
5648
5687
|
return 20;
|
|
5649
5688
|
}
|
|
5650
5689
|
}
|
|
@@ -6007,7 +6046,7 @@ function computeDebt(result) {
|
|
|
6007
6046
|
label,
|
|
6008
6047
|
count: v.count,
|
|
6009
6048
|
estHoursPerQuarter: Math.round(v.hours * 10) / 10,
|
|
6010
|
-
ruleIds: [...v.ruleIds].sort(
|
|
6049
|
+
ruleIds: [...v.ruleIds].sort(compareCodePoints)
|
|
6011
6050
|
})).sort((a, b) => b.estHoursPerQuarter - a.estHoursPerQuarter);
|
|
6012
6051
|
return {
|
|
6013
6052
|
classes,
|
|
@@ -6542,8 +6581,8 @@ async function computeImpact(root, options) {
|
|
|
6542
6581
|
baseRef,
|
|
6543
6582
|
headRef,
|
|
6544
6583
|
...baseTreeTruncated ? { baseTreeTruncated } : {},
|
|
6545
|
-
resolved: resolved.sort((a, b) => a.file
|
|
6546
|
-
introduced: introduced.sort((a, b) => a.file
|
|
6584
|
+
resolved: resolved.sort((a, b) => compareCodePoints(a.file, b.file)),
|
|
6585
|
+
introduced: introduced.sort((a, b) => compareCodePoints(a.file, b.file)),
|
|
6547
6586
|
unknownFacts
|
|
6548
6587
|
};
|
|
6549
6588
|
}
|
|
@@ -6802,7 +6841,7 @@ function tryReadPackageJson(rootDir) {
|
|
|
6802
6841
|
const p = join(rootDir, "package.json");
|
|
6803
6842
|
if (!existsSync(p)) return null;
|
|
6804
6843
|
try {
|
|
6805
|
-
return parseJsonFile(readFileSync(p, "utf8"), p, isRecord
|
|
6844
|
+
return parseJsonFile(readFileSync(p, "utf8"), p, isRecord);
|
|
6806
6845
|
} catch {
|
|
6807
6846
|
return null;
|
|
6808
6847
|
}
|
|
@@ -7598,7 +7637,7 @@ function computeSelectorHealth(root, ignoreMatcher = DEFAULT_IGNORE_MATCHER) {
|
|
|
7598
7637
|
}
|
|
7599
7638
|
};
|
|
7600
7639
|
walk(root);
|
|
7601
|
-
return specs.sort((a, b) => a.score - b.score || a.file
|
|
7640
|
+
return specs.sort((a, b) => a.score - b.score || compareCodePoints(a.file, b.file));
|
|
7602
7641
|
}
|
|
7603
7642
|
//#endregion
|
|
7604
7643
|
//#region src/cli-handlers.ts
|
|
@@ -8074,7 +8113,7 @@ async function runPrCommentCommand(argv, io = {
|
|
|
8074
8113
|
}));
|
|
8075
8114
|
return 0;
|
|
8076
8115
|
} catch (err) {
|
|
8077
|
-
io.err(`error: cannot read ${fromPath}: ${
|
|
8116
|
+
io.err(`error: cannot read ${fromPath}: ${errorMessage(err)}`);
|
|
8078
8117
|
return 10;
|
|
8079
8118
|
}
|
|
8080
8119
|
}
|
|
@@ -8567,7 +8606,7 @@ function computeDetectorHashes(rules, rulesDir, retiredIds = []) {
|
|
|
8567
8606
|
return manifest;
|
|
8568
8607
|
}
|
|
8569
8608
|
function loadManifest(path) {
|
|
8570
|
-
return parseJsonFile(readFileSync(path, "utf8"), path, (v) => isRecord
|
|
8609
|
+
return parseJsonFile(readFileSync(path, "utf8"), path, (v) => isRecord(v));
|
|
8571
8610
|
}
|
|
8572
8611
|
//#endregion
|
|
8573
8612
|
//#region src/commands/doctor.ts
|
|
@@ -8599,10 +8638,6 @@ const VALID_REASON_CODES = /* @__PURE__ */ new Set([
|
|
|
8599
8638
|
"family-fallback-lockstep",
|
|
8600
8639
|
"migration-deferred-next-measurement"
|
|
8601
8640
|
]);
|
|
8602
|
-
/** Uniform error rendering for doctor details (Error or thrown-as-string). */
|
|
8603
|
-
function errorText(e) {
|
|
8604
|
-
return e instanceof Error ? e.message : String(e);
|
|
8605
|
-
}
|
|
8606
8641
|
const ui$8 = plainContext();
|
|
8607
8642
|
/** Builds the check record: `ok` is always the pass-mirror of `status`. */
|
|
8608
8643
|
function check(name, status, details) {
|
|
@@ -8744,7 +8779,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
|
|
|
8744
8779
|
const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n").filter((l) => l.trim().length > 0);
|
|
8745
8780
|
for (const line of lines) try {
|
|
8746
8781
|
const parsed = JSON.parse(line);
|
|
8747
|
-
if (!isRecord
|
|
8782
|
+
if (!isRecord(parsed)) continue;
|
|
8748
8783
|
const entry = parsed;
|
|
8749
8784
|
if (entry.verdict === "TP" || entry.verdict === "FP") {
|
|
8750
8785
|
if (typeof entry.ruleId === "string") live.set(entry.ruleId, (live.get(entry.ruleId) ?? 0) + 1);
|
|
@@ -8752,7 +8787,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
|
|
|
8752
8787
|
} catch {}
|
|
8753
8788
|
}
|
|
8754
8789
|
} catch (e) {
|
|
8755
|
-
return check("tier-enforcement", "inconclusive", [`INCONCLUSIVE: live verdicts unreadable — ${
|
|
8790
|
+
return check("tier-enforcement", "inconclusive", [`INCONCLUSIVE: live verdicts unreadable — ${errorMessage(e)} (owner ruling 2026-09-08, L4)`]);
|
|
8756
8791
|
}
|
|
8757
8792
|
if (live.size > 0) for (const [id, n] of live) classifiedPerRule.set(id, n);
|
|
8758
8793
|
const coreRules = rules.filter((r) => effectiveTier(r) === "core");
|
|
@@ -8852,7 +8887,7 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
|
|
|
8852
8887
|
const allowlistPath = join(fixturesRoot, "typecheck-allowlist.json");
|
|
8853
8888
|
if (existsSync(allowlistPath)) try {
|
|
8854
8889
|
const raw = JSON.parse(readFileSync(allowlistPath, "utf8"));
|
|
8855
|
-
if (!isRecord
|
|
8890
|
+
if (!isRecord(raw)) {
|
|
8856
8891
|
ok = false;
|
|
8857
8892
|
details.push("typecheck-allowlist.json is not an object");
|
|
8858
8893
|
} else if (Array.isArray(raw.entries)) details.push(`Layer A typecheck allowlist: ${raw.entries.length} justified entr(ies)`);
|
|
@@ -8902,7 +8937,7 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
8902
8937
|
try {
|
|
8903
8938
|
current = computeDetectorHashes(rules, rulesDir);
|
|
8904
8939
|
} catch (e) {
|
|
8905
|
-
return check("revision-integrity", "inconclusive", [`INCONCLUSIVE: detector hash computation failed — ${
|
|
8940
|
+
return check("revision-integrity", "inconclusive", [`INCONCLUSIVE: detector hash computation failed — ${errorMessage(e)}`]);
|
|
8906
8941
|
}
|
|
8907
8942
|
const failures = [];
|
|
8908
8943
|
for (const rule of rules) {
|
|
@@ -8966,7 +9001,7 @@ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, me
|
|
|
8966
9001
|
let row;
|
|
8967
9002
|
try {
|
|
8968
9003
|
const parsed = JSON.parse(trimmed);
|
|
8969
|
-
if (!isRecord
|
|
9004
|
+
if (!isRecord(parsed)) {
|
|
8970
9005
|
failures.push(`${f}: unparseable verdict row (corpus integrity)`);
|
|
8971
9006
|
continue;
|
|
8972
9007
|
}
|
|
@@ -8983,10 +9018,10 @@ function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, me
|
|
|
8983
9018
|
let sidecar = {};
|
|
8984
9019
|
if (existsSync(sidecarPath)) try {
|
|
8985
9020
|
const parsed = JSON.parse(readFileSync(sidecarPath, "utf8"));
|
|
8986
|
-
if (!isRecord
|
|
9021
|
+
if (!isRecord(parsed)) failures.push("sidecar is not a valid object — regenerate the measurement artifacts");
|
|
8987
9022
|
else sidecar = parsed;
|
|
8988
9023
|
} catch (e) {
|
|
8989
|
-
failures.push(`sidecar unreadable/malformed: ${
|
|
9024
|
+
failures.push(`sidecar unreadable/malformed: ${errorMessage(e)} — regenerate the measurement artifacts`);
|
|
8990
9025
|
}
|
|
8991
9026
|
else {
|
|
8992
9027
|
const needsSidecar = Object.keys(measuredFp).filter((id) => measuredFp[id]?.detectorRevision !== 1);
|
|
@@ -9383,7 +9418,7 @@ function runSummaryCommand(argv, io = {
|
|
|
9383
9418
|
try {
|
|
9384
9419
|
result = loadSavedReport(reportPath);
|
|
9385
9420
|
} catch (err) {
|
|
9386
|
-
io.err(`mjolnir summary: cannot read ${reportPath}: ${
|
|
9421
|
+
io.err(`mjolnir summary: cannot read ${reportPath}: ${errorMessage(err)}`);
|
|
9387
9422
|
return 2;
|
|
9388
9423
|
}
|
|
9389
9424
|
const env = process.env;
|
|
@@ -9402,7 +9437,7 @@ function runSummaryCommand(argv, io = {
|
|
|
9402
9437
|
if (stepSummaryPath) try {
|
|
9403
9438
|
appendFileSync(stepSummaryPath, `${summary}\n`);
|
|
9404
9439
|
} catch (err) {
|
|
9405
|
-
io.err(`mjolnir summary: could not write $GITHUB_STEP_SUMMARY (${
|
|
9440
|
+
io.err(`mjolnir summary: could not write $GITHUB_STEP_SUMMARY (${errorMessage(err)}); printing to stdout instead.`);
|
|
9406
9441
|
io.out(summary);
|
|
9407
9442
|
}
|
|
9408
9443
|
else io.out(summary);
|
|
@@ -9710,7 +9745,7 @@ function runHandoffCommand(argv, io = {
|
|
|
9710
9745
|
try {
|
|
9711
9746
|
result = loadSavedReport(reportPath);
|
|
9712
9747
|
} catch (err) {
|
|
9713
|
-
io.err(`mjolnir handoff: cannot read ${reportPath}: ${
|
|
9748
|
+
io.err(`mjolnir handoff: cannot read ${reportPath}: ${errorMessage(err)}`);
|
|
9714
9749
|
return 2;
|
|
9715
9750
|
}
|
|
9716
9751
|
io.out(renderHandoff(result, {
|
|
@@ -11116,13 +11151,22 @@ async function runReleaseReportCommand(argv, io) {
|
|
|
11116
11151
|
* So the execution block is empty and says so, and the findings live in the
|
|
11117
11152
|
* `mjolnir` extension block where they are honestly labelled as static
|
|
11118
11153
|
* analysis. The file is a findings report in a familiar shape, not a test run.
|
|
11154
|
+
*
|
|
11155
|
+
* The TOP-LEVEL status is the same claim and is now held to it. It used to be
|
|
11156
|
+
* computed (`partial ? "interrupted" : hasError ? "failed" : "passed"`), so
|
|
11157
|
+
* the clean case shipped `{status: "passed", totalTests: 0}` — a green test
|
|
11158
|
+
* run over zero tests, in the one field a Playwright consumer reads first. The
|
|
11159
|
+
* per-finding statuses were fixed; this one was not. It is now the literal
|
|
11160
|
+
* `interrupted`, and what the scan actually found is reported as
|
|
11161
|
+
* `mjolnir.scanOutcome` instead of being smuggled into the run status.
|
|
11119
11162
|
*/
|
|
11120
11163
|
const ui$6 = plainContext();
|
|
11121
11164
|
function buildPlaywrightReport(result) {
|
|
11122
11165
|
const now = /* @__PURE__ */ new Date();
|
|
11123
11166
|
const partial = result.partial === true;
|
|
11124
11167
|
const hasError = result.findings.some((f) => f.severity === "error");
|
|
11125
|
-
const status =
|
|
11168
|
+
const status = "interrupted";
|
|
11169
|
+
const scanOutcome = partial ? "partial" : hasError ? "blocked" : "clean";
|
|
11126
11170
|
return {
|
|
11127
11171
|
version: 1,
|
|
11128
11172
|
startTime: now.toISOString(),
|
|
@@ -11135,7 +11179,7 @@ function buildPlaywrightReport(result) {
|
|
|
11135
11179
|
suites: [],
|
|
11136
11180
|
mjolnir: {
|
|
11137
11181
|
execution: "STATIC_ANALYSIS",
|
|
11138
|
-
|
|
11182
|
+
scanOutcome,
|
|
11139
11183
|
partial,
|
|
11140
11184
|
score: result.score,
|
|
11141
11185
|
framework: result.frameworks[0] ?? "unknown",
|
|
@@ -11232,16 +11276,39 @@ function recordTrend(root, snapshot) {
|
|
|
11232
11276
|
return false;
|
|
11233
11277
|
}
|
|
11234
11278
|
}
|
|
11235
|
-
function
|
|
11279
|
+
function loadTrendDetail(root, limit = 20) {
|
|
11236
11280
|
const path = trendPath(root);
|
|
11237
|
-
if (!existsSync(path)) return
|
|
11238
|
-
|
|
11239
|
-
|
|
11240
|
-
|
|
11241
|
-
|
|
11242
|
-
|
|
11243
|
-
|
|
11244
|
-
|
|
11281
|
+
if (!existsSync(path)) return {
|
|
11282
|
+
snapshots: [],
|
|
11283
|
+
corruptLines: 0
|
|
11284
|
+
};
|
|
11285
|
+
const lines = readFileSync(path, "utf8").trim().split("\n").filter(Boolean);
|
|
11286
|
+
let corruptLines = 0;
|
|
11287
|
+
return {
|
|
11288
|
+
snapshots: lines.map((line) => {
|
|
11289
|
+
try {
|
|
11290
|
+
return JSON.parse(line);
|
|
11291
|
+
} catch {
|
|
11292
|
+
recordDegradation("trend-record-unparseable");
|
|
11293
|
+
corruptLines++;
|
|
11294
|
+
return null;
|
|
11295
|
+
}
|
|
11296
|
+
}).filter((s) => s !== null).slice(-limit),
|
|
11297
|
+
corruptLines
|
|
11298
|
+
};
|
|
11299
|
+
}
|
|
11300
|
+
/**
|
|
11301
|
+
* Say what was discarded, on stderr, and report whether the history is
|
|
11302
|
+
* readable at all. Returns the snapshots so the caller does not read the
|
|
11303
|
+
* file twice.
|
|
11304
|
+
*/
|
|
11305
|
+
function readTrendOrDisclose(root, limit, io) {
|
|
11306
|
+
const { snapshots, corruptLines } = loadTrendDetail(root, limit);
|
|
11307
|
+
if (corruptLines > 0) io.err(`mjolnir trend: ${corruptLines} unreadable line(s) in ${trendPath(root)} were discarded; the history below is not the whole file.`);
|
|
11308
|
+
return {
|
|
11309
|
+
snapshots,
|
|
11310
|
+
readable: corruptLines === 0 || snapshots.length > 0
|
|
11311
|
+
};
|
|
11245
11312
|
}
|
|
11246
11313
|
async function runTrendCommand(argv, io) {
|
|
11247
11314
|
const subcommand = argv[0] ?? "show";
|
|
@@ -11286,8 +11353,12 @@ async function runTrendCommand(argv, io) {
|
|
|
11286
11353
|
}
|
|
11287
11354
|
if (subcommand === "show") {
|
|
11288
11355
|
const limitStr = argv.find((a) => a.startsWith("--limit="))?.split("=")[1] ?? "20";
|
|
11289
|
-
const snapshots =
|
|
11356
|
+
const { snapshots, readable } = readTrendOrDisclose(target, parseInt(limitStr, 10), io);
|
|
11290
11357
|
if (snapshots.length === 0) {
|
|
11358
|
+
if (!readable) {
|
|
11359
|
+
io.err(`mjolnir trend show: ${trendPath(target)} exists but no line in it could be parsed. That is unreadable history, not an empty history.`);
|
|
11360
|
+
return 20;
|
|
11361
|
+
}
|
|
11291
11362
|
io.out("No trend data yet. Run 'mjolnir trend record' first.");
|
|
11292
11363
|
return 0;
|
|
11293
11364
|
}
|
|
@@ -11301,12 +11372,12 @@ async function runTrendCommand(argv, io) {
|
|
|
11301
11372
|
return 0;
|
|
11302
11373
|
}
|
|
11303
11374
|
if (subcommand === "diff") {
|
|
11304
|
-
const snapshots =
|
|
11305
|
-
if (snapshots.length < 2) {
|
|
11306
|
-
io.out("Need at least 2 snapshots for diff. Run 'mjolnir trend record' more than once.");
|
|
11307
|
-
return 0;
|
|
11308
|
-
}
|
|
11375
|
+
const { snapshots, readable } = readTrendOrDisclose(target, 2, io);
|
|
11309
11376
|
if (snapshots.length < 2) {
|
|
11377
|
+
if (!readable) {
|
|
11378
|
+
io.err(`mjolnir trend diff: ${trendPath(target)} exists but no line in it could be parsed, so there is no history to diff.`);
|
|
11379
|
+
return 20;
|
|
11380
|
+
}
|
|
11310
11381
|
io.out("Need at least 2 snapshots for diff. Run 'mjolnir trend record' more than once.");
|
|
11311
11382
|
return 0;
|
|
11312
11383
|
}
|
|
@@ -11618,7 +11689,7 @@ function runtimeFailuresFromReport(parsed) {
|
|
|
11618
11689
|
return evidence;
|
|
11619
11690
|
}
|
|
11620
11691
|
function buildQuarantineProposals(evidence) {
|
|
11621
|
-
return evidence.filter((item) => item.attempts >= 2 && item.everFailed).sort((a, b) => b.attempts - a.attempts || a.file
|
|
11692
|
+
return evidence.filter((item) => item.attempts >= 2 && item.everFailed).sort((a, b) => b.attempts - a.attempts || compareCodePoints(a.file, b.file) || a.line - b.line).map((item, index) => ({
|
|
11622
11693
|
id: `Q-${String(index + 1).padStart(3, "0")}`,
|
|
11623
11694
|
ruleId: item.ruleId,
|
|
11624
11695
|
file: item.file,
|
|
@@ -12020,7 +12091,7 @@ async function runDashboardCommand(argv, io) {
|
|
|
12020
12091
|
return 10;
|
|
12021
12092
|
}
|
|
12022
12093
|
try {
|
|
12023
|
-
const { runScan } = await import("./scan-pipeline-
|
|
12094
|
+
const { runScan } = await import("./scan-pipeline-DNYbnWjE.mjs");
|
|
12024
12095
|
const result = await runScan({
|
|
12025
12096
|
target,
|
|
12026
12097
|
json: true,
|
|
@@ -12143,6 +12214,1282 @@ function runEnterpriseCommand(argv, io) {
|
|
|
12143
12214
|
return 10;
|
|
12144
12215
|
}
|
|
12145
12216
|
//#endregion
|
|
12217
|
+
//#region src/frameworks/provider-capability-contract.ts
|
|
12218
|
+
const CI_PROVIDER_CAPABILITY_SCHEMA = "m30.ci-provider-capabilities@1";
|
|
12219
|
+
const CI_PROVIDER_IDS = [
|
|
12220
|
+
"github-actions",
|
|
12221
|
+
"azure-pipelines",
|
|
12222
|
+
"jenkins",
|
|
12223
|
+
"gitlab-ci"
|
|
12224
|
+
];
|
|
12225
|
+
function unsupported(reason) {
|
|
12226
|
+
return {
|
|
12227
|
+
state: "unsupported",
|
|
12228
|
+
reason
|
|
12229
|
+
};
|
|
12230
|
+
}
|
|
12231
|
+
const CI_PROVIDER_CAPABILITY_RECORDS = [
|
|
12232
|
+
{
|
|
12233
|
+
schema: CI_PROVIDER_CAPABILITY_SCHEMA,
|
|
12234
|
+
provider: "github-actions",
|
|
12235
|
+
assurance: {
|
|
12236
|
+
mode: "bounded",
|
|
12237
|
+
certification: "not-claimed"
|
|
12238
|
+
},
|
|
12239
|
+
permissions: {
|
|
12240
|
+
defaultToken: "read-only",
|
|
12241
|
+
forkPullRequestToken: "read-only",
|
|
12242
|
+
untrustedInputSecrets: "unavailable",
|
|
12243
|
+
writeAccess: "not-granted"
|
|
12244
|
+
},
|
|
12245
|
+
capabilities: {
|
|
12246
|
+
triggers: {
|
|
12247
|
+
state: "supported",
|
|
12248
|
+
semantics: {
|
|
12249
|
+
source: "github-events",
|
|
12250
|
+
pathFilters: "narrow-only",
|
|
12251
|
+
untrustedInput: "data-only"
|
|
12252
|
+
}
|
|
12253
|
+
},
|
|
12254
|
+
forks: {
|
|
12255
|
+
state: "supported",
|
|
12256
|
+
semantics: {
|
|
12257
|
+
token: "read-only",
|
|
12258
|
+
secrets: "unavailable",
|
|
12259
|
+
checkoutCredentials: "not-persisted"
|
|
12260
|
+
}
|
|
12261
|
+
},
|
|
12262
|
+
conditions: {
|
|
12263
|
+
state: "supported",
|
|
12264
|
+
semantics: {
|
|
12265
|
+
evaluator: "github-expressions",
|
|
12266
|
+
untrustedExpressions: "data-only",
|
|
12267
|
+
skippedGate: "invalid"
|
|
12268
|
+
}
|
|
12269
|
+
},
|
|
12270
|
+
matrix: {
|
|
12271
|
+
state: "supported",
|
|
12272
|
+
semantics: {
|
|
12273
|
+
execution: "strategy-matrix",
|
|
12274
|
+
aggregation: "all-jobs-must-pass",
|
|
12275
|
+
toleratedFailures: "non-gates-only"
|
|
12276
|
+
}
|
|
12277
|
+
},
|
|
12278
|
+
artifacts: {
|
|
12279
|
+
state: "supported",
|
|
12280
|
+
semantics: {
|
|
12281
|
+
store: "run-artifacts",
|
|
12282
|
+
binding: "run-and-candidate-bound",
|
|
12283
|
+
untrustedUpload: "denied"
|
|
12284
|
+
}
|
|
12285
|
+
},
|
|
12286
|
+
cache: {
|
|
12287
|
+
state: "supported",
|
|
12288
|
+
semantics: {
|
|
12289
|
+
scope: "action-cache",
|
|
12290
|
+
keying: "integrity-keyed",
|
|
12291
|
+
forkWrite: "denied"
|
|
12292
|
+
}
|
|
12293
|
+
},
|
|
12294
|
+
approvals: {
|
|
12295
|
+
state: "supported",
|
|
12296
|
+
semantics: {
|
|
12297
|
+
mechanism: "required-checks",
|
|
12298
|
+
defaultMode: "advisory",
|
|
12299
|
+
staleApproval: "invalid"
|
|
12300
|
+
}
|
|
12301
|
+
},
|
|
12302
|
+
failureHandling: {
|
|
12303
|
+
state: "supported",
|
|
12304
|
+
semantics: {
|
|
12305
|
+
retry: "bounded-advisory",
|
|
12306
|
+
continueOnError: "non-gates-only",
|
|
12307
|
+
maskedGate: "blocking"
|
|
12308
|
+
}
|
|
12309
|
+
}
|
|
12310
|
+
}
|
|
12311
|
+
},
|
|
12312
|
+
{
|
|
12313
|
+
schema: CI_PROVIDER_CAPABILITY_SCHEMA,
|
|
12314
|
+
provider: "azure-pipelines",
|
|
12315
|
+
assurance: {
|
|
12316
|
+
mode: "bounded",
|
|
12317
|
+
certification: "not-claimed"
|
|
12318
|
+
},
|
|
12319
|
+
permissions: {
|
|
12320
|
+
defaultToken: "not-applicable",
|
|
12321
|
+
forkPullRequestToken: "unsupported",
|
|
12322
|
+
untrustedInputSecrets: "unavailable",
|
|
12323
|
+
writeAccess: "not-granted"
|
|
12324
|
+
},
|
|
12325
|
+
capabilities: {
|
|
12326
|
+
triggers: {
|
|
12327
|
+
state: "supported",
|
|
12328
|
+
semantics: {
|
|
12329
|
+
source: "azure-resource-triggers",
|
|
12330
|
+
pathFilters: "narrow-only",
|
|
12331
|
+
untrustedInput: "data-only"
|
|
12332
|
+
}
|
|
12333
|
+
},
|
|
12334
|
+
forks: unsupported("Azure fork pull-request token isolation is not part of this bounded contract."),
|
|
12335
|
+
conditions: {
|
|
12336
|
+
state: "supported",
|
|
12337
|
+
semantics: {
|
|
12338
|
+
evaluator: "azure-expressions",
|
|
12339
|
+
untrustedExpressions: "data-only",
|
|
12340
|
+
skippedGate: "invalid"
|
|
12341
|
+
}
|
|
12342
|
+
},
|
|
12343
|
+
matrix: {
|
|
12344
|
+
state: "supported",
|
|
12345
|
+
semantics: {
|
|
12346
|
+
execution: "jobs-matrix",
|
|
12347
|
+
aggregation: "all-jobs-must-pass",
|
|
12348
|
+
toleratedFailures: "non-gates-only"
|
|
12349
|
+
}
|
|
12350
|
+
},
|
|
12351
|
+
artifacts: {
|
|
12352
|
+
state: "supported",
|
|
12353
|
+
semantics: {
|
|
12354
|
+
store: "pipeline-artifacts",
|
|
12355
|
+
binding: "run-and-candidate-bound",
|
|
12356
|
+
untrustedUpload: "denied"
|
|
12357
|
+
}
|
|
12358
|
+
},
|
|
12359
|
+
cache: unsupported("Azure cache trust and restoration semantics are not part of this bounded contract."),
|
|
12360
|
+
approvals: {
|
|
12361
|
+
state: "supported",
|
|
12362
|
+
semantics: {
|
|
12363
|
+
mechanism: "branch-policy",
|
|
12364
|
+
defaultMode: "advisory",
|
|
12365
|
+
staleApproval: "invalid"
|
|
12366
|
+
}
|
|
12367
|
+
},
|
|
12368
|
+
failureHandling: {
|
|
12369
|
+
state: "supported",
|
|
12370
|
+
semantics: {
|
|
12371
|
+
retry: "bounded-advisory",
|
|
12372
|
+
continueOnError: "non-gates-only",
|
|
12373
|
+
maskedGate: "blocking"
|
|
12374
|
+
}
|
|
12375
|
+
}
|
|
12376
|
+
}
|
|
12377
|
+
},
|
|
12378
|
+
{
|
|
12379
|
+
schema: CI_PROVIDER_CAPABILITY_SCHEMA,
|
|
12380
|
+
provider: "jenkins",
|
|
12381
|
+
assurance: {
|
|
12382
|
+
mode: "bounded",
|
|
12383
|
+
certification: "not-claimed"
|
|
12384
|
+
},
|
|
12385
|
+
permissions: {
|
|
12386
|
+
defaultToken: "not-applicable",
|
|
12387
|
+
forkPullRequestToken: "unsupported",
|
|
12388
|
+
untrustedInputSecrets: "unavailable",
|
|
12389
|
+
writeAccess: "not-granted"
|
|
12390
|
+
},
|
|
12391
|
+
capabilities: {
|
|
12392
|
+
triggers: {
|
|
12393
|
+
state: "supported",
|
|
12394
|
+
semantics: {
|
|
12395
|
+
source: "jenkins-external-triggers",
|
|
12396
|
+
pathFilters: "narrow-only",
|
|
12397
|
+
untrustedInput: "data-only"
|
|
12398
|
+
}
|
|
12399
|
+
},
|
|
12400
|
+
forks: unsupported("Jenkins fork identity and token isolation are controller-specific and unsupported here."),
|
|
12401
|
+
conditions: unsupported("Untrusted Groovy condition execution is not part of this bounded contract."),
|
|
12402
|
+
matrix: {
|
|
12403
|
+
state: "supported",
|
|
12404
|
+
semantics: {
|
|
12405
|
+
execution: "parallel-axis",
|
|
12406
|
+
aggregation: "all-jobs-must-pass",
|
|
12407
|
+
toleratedFailures: "non-gates-only"
|
|
12408
|
+
}
|
|
12409
|
+
},
|
|
12410
|
+
artifacts: {
|
|
12411
|
+
state: "supported",
|
|
12412
|
+
semantics: {
|
|
12413
|
+
store: "workspace-archive",
|
|
12414
|
+
binding: "run-and-candidate-bound",
|
|
12415
|
+
untrustedUpload: "denied"
|
|
12416
|
+
}
|
|
12417
|
+
},
|
|
12418
|
+
cache: unsupported("Jenkins cache plugins and restoration trust are not part of this bounded contract."),
|
|
12419
|
+
approvals: {
|
|
12420
|
+
state: "supported",
|
|
12421
|
+
semantics: {
|
|
12422
|
+
mechanism: "manual-input",
|
|
12423
|
+
defaultMode: "advisory",
|
|
12424
|
+
staleApproval: "invalid"
|
|
12425
|
+
}
|
|
12426
|
+
},
|
|
12427
|
+
failureHandling: {
|
|
12428
|
+
state: "supported",
|
|
12429
|
+
semantics: {
|
|
12430
|
+
retry: "bounded-advisory",
|
|
12431
|
+
continueOnError: "non-gates-only",
|
|
12432
|
+
maskedGate: "blocking"
|
|
12433
|
+
}
|
|
12434
|
+
}
|
|
12435
|
+
}
|
|
12436
|
+
},
|
|
12437
|
+
{
|
|
12438
|
+
schema: CI_PROVIDER_CAPABILITY_SCHEMA,
|
|
12439
|
+
provider: "gitlab-ci",
|
|
12440
|
+
assurance: {
|
|
12441
|
+
mode: "bounded",
|
|
12442
|
+
certification: "not-claimed"
|
|
12443
|
+
},
|
|
12444
|
+
permissions: {
|
|
12445
|
+
defaultToken: "not-applicable",
|
|
12446
|
+
forkPullRequestToken: "unsupported",
|
|
12447
|
+
untrustedInputSecrets: "unavailable",
|
|
12448
|
+
writeAccess: "not-granted"
|
|
12449
|
+
},
|
|
12450
|
+
capabilities: {
|
|
12451
|
+
triggers: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
|
|
12452
|
+
forks: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
|
|
12453
|
+
conditions: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
|
|
12454
|
+
matrix: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
|
|
12455
|
+
artifacts: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
|
|
12456
|
+
cache: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
|
|
12457
|
+
approvals: unsupported("GitLab provider execution is not claimed by the bounded M30 contract."),
|
|
12458
|
+
failureHandling: unsupported("GitLab provider execution is not claimed by the bounded M30 contract.")
|
|
12459
|
+
}
|
|
12460
|
+
}
|
|
12461
|
+
];
|
|
12462
|
+
new Map(CI_PROVIDER_CAPABILITY_RECORDS.map((record) => [record.provider, record]));
|
|
12463
|
+
//#endregion
|
|
12464
|
+
//#region src/v6/maturity.ts
|
|
12465
|
+
/**
|
|
12466
|
+
* v6 capability maturity ladder — `M0`–`M5` (ADR 0001).
|
|
12467
|
+
*
|
|
12468
|
+
* ONE implementation of the maturity axis. `L0`–`L5` is the *finding
|
|
12469
|
+
* trust level* (`src/types.ts:106-117`) and is never a maturity value;
|
|
12470
|
+
* the two axes are orthogonal (ADR 0011). The blueprint's rejected
|
|
12471
|
+
* `EXPERIMENTAL→…→CERTIFIED` ladder is not declared anywhere, so the
|
|
12472
|
+
* product has exactly two level vocabularies, each with one meaning.
|
|
12473
|
+
*
|
|
12474
|
+
* Rules this module encodes, because they are the whole point of the
|
|
12475
|
+
* ladder and are otherwise only prose:
|
|
12476
|
+
*
|
|
12477
|
+
* - Maturity is **granted by evidence, never by hand**. Promotion is a
|
|
12478
|
+
* machine transition; there is no `setMaturity` export.
|
|
12479
|
+
* - Every level below `M5` must expose a machine-readable
|
|
12480
|
+
* `nextLevelGap`. `nextLevelGap` is therefore required, not optional,
|
|
12481
|
+
* and a capability that cannot state its gap is a registry error.
|
|
12482
|
+
* - Maturity can be **demoted automatically** when a `revisitTrigger`
|
|
12483
|
+
* fires. That is the same rule the ecosystem census staleness trigger
|
|
12484
|
+
* uses (ADR 0010), which is why the trigger is a first-class field
|
|
12485
|
+
* here rather than a string on a doc.
|
|
12486
|
+
* - `M4` requires precision AND recall, never precision alone (Law 5:
|
|
12487
|
+
* precision must not hide poor recall, and recall must not hide poor
|
|
12488
|
+
* precision).
|
|
12489
|
+
* - `M5` is unreachable from inside the engine: it requires external
|
|
12490
|
+
* field evidence (`REMOTE_PROVEN`), which the zero-network default
|
|
12491
|
+
* never produces. A capability that claims `M5` without external
|
|
12492
|
+
* evidence is a false proof, not a fast promotion.
|
|
12493
|
+
*/
|
|
12494
|
+
const MATURITY_LEVELS$1 = [
|
|
12495
|
+
"M0_UNKNOWN",
|
|
12496
|
+
"M1_DECLARED",
|
|
12497
|
+
"M2_IMPLEMENTED",
|
|
12498
|
+
"M3_FIXTURE_VERIFIED",
|
|
12499
|
+
"M4_CORPUS_VERIFIED",
|
|
12500
|
+
"M5_FIELD_PROVEN"
|
|
12501
|
+
];
|
|
12502
|
+
/** The short `M0`–`M5` form. Two vocabularies, one meaning each. */
|
|
12503
|
+
const MATURITY_SHORT = {
|
|
12504
|
+
M0_UNKNOWN: "M0",
|
|
12505
|
+
M1_DECLARED: "M1",
|
|
12506
|
+
M2_IMPLEMENTED: "M2",
|
|
12507
|
+
M3_FIXTURE_VERIFIED: "M3",
|
|
12508
|
+
M4_CORPUS_VERIFIED: "M4",
|
|
12509
|
+
M5_FIELD_PROVEN: "M5"
|
|
12510
|
+
};
|
|
12511
|
+
/** The default for anything unclassified. Never a guess upward. */
|
|
12512
|
+
const DEFAULT_MATURITY = "M0_UNKNOWN";
|
|
12513
|
+
const RANK = {
|
|
12514
|
+
M0_UNKNOWN: 0,
|
|
12515
|
+
M1_DECLARED: 1,
|
|
12516
|
+
M2_IMPLEMENTED: 2,
|
|
12517
|
+
M3_FIXTURE_VERIFIED: 3,
|
|
12518
|
+
M4_CORPUS_VERIFIED: 4,
|
|
12519
|
+
M5_FIELD_PROVEN: 5
|
|
12520
|
+
};
|
|
12521
|
+
function isMaturity(value) {
|
|
12522
|
+
return typeof value === "string" && MATURITY_LEVELS$1.includes(value);
|
|
12523
|
+
}
|
|
12524
|
+
/** Unknown input ranks as `M0` — an unparseable claim is not a claim. */
|
|
12525
|
+
function maturityRank(value) {
|
|
12526
|
+
return isMaturity(value) ? RANK[value] : RANK[DEFAULT_MATURITY];
|
|
12527
|
+
}
|
|
12528
|
+
/** The next level up, or `null` at the ceiling. */
|
|
12529
|
+
function nextMaturity(value) {
|
|
12530
|
+
const rank = RANK[value];
|
|
12531
|
+
return rank >= MATURITY_LEVELS$1.length - 1 ? null : MATURITY_LEVELS$1[rank + 1] ?? null;
|
|
12532
|
+
}
|
|
12533
|
+
MATURITY_LEVELS$1.filter((level) => level !== "M5_FIELD_PROVEN");
|
|
12534
|
+
/**
|
|
12535
|
+
* Derive maturity from evidence. **The only way a capability gets a
|
|
12536
|
+
* level** — there is no `setMaturity` export, and a hand-written level in
|
|
12537
|
+
* a registry is a defect, not a default.
|
|
12538
|
+
*
|
|
12539
|
+
* The rule is monotone: each level requires its own criterion *and* every
|
|
12540
|
+
* criterion below it. A capability with corpus proof but no fixtures
|
|
12541
|
+
* lands at `M2`, because `M4`'s criterion is a chain, not a ceiling — a
|
|
12542
|
+
* real corpus run without a deterministic fixture quad is exactly the
|
|
12543
|
+
* state that must not be advertised as `M4`.
|
|
12544
|
+
*/
|
|
12545
|
+
function deriveMaturityFromEvidence(evidence) {
|
|
12546
|
+
if (evidence.fieldProven) return "M5_FIELD_PROVEN";
|
|
12547
|
+
if (evidence.corpusVerified && evidence.fixtureQuadVerified && evidence.unitTested && evidence.implemented && evidence.declared) return "M4_CORPUS_VERIFIED";
|
|
12548
|
+
if (evidence.fixtureQuadVerified && evidence.implemented && evidence.declared) return "M3_FIXTURE_VERIFIED";
|
|
12549
|
+
if (evidence.implemented && evidence.unitTested && evidence.declared) return "M2_IMPLEMENTED";
|
|
12550
|
+
if (evidence.declared) return "M1_DECLARED";
|
|
12551
|
+
return "M0_UNKNOWN";
|
|
12552
|
+
}
|
|
12553
|
+
/**
|
|
12554
|
+
* Build the `nextLevelGap` for whatever the evidence currently supports.
|
|
12555
|
+
* `missing` is derived from the *unsatisfied* criteria of the next level
|
|
12556
|
+
* up, so a gap can never quietly go stale against the ladder.
|
|
12557
|
+
*/
|
|
12558
|
+
function nextLevelGapFromEvidence(evidence, owner, revisitTrigger) {
|
|
12559
|
+
const maturity = deriveMaturityFromEvidence(evidence);
|
|
12560
|
+
const target = nextMaturity(maturity);
|
|
12561
|
+
if (target === null) return {
|
|
12562
|
+
maturity,
|
|
12563
|
+
nextLevelGap: null
|
|
12564
|
+
};
|
|
12565
|
+
const missing = [];
|
|
12566
|
+
if (target === "M1_DECLARED" && !evidence.declared) missing.push("registry entry with an owner and a claim");
|
|
12567
|
+
if (target === "M2_IMPLEMENTED") {
|
|
12568
|
+
if (!evidence.implemented) missing.push("implementation exists");
|
|
12569
|
+
if (!evidence.unitTested) missing.push("unit tests for the implementation");
|
|
12570
|
+
}
|
|
12571
|
+
if (target === "M3_FIXTURE_VERIFIED") {
|
|
12572
|
+
if (!evidence.fixtureQuadVerified) missing.push("positive + negative + boundary + adversarial fixtures pass deterministically in CI");
|
|
12573
|
+
}
|
|
12574
|
+
if (target === "M4_CORPUS_VERIFIED") {
|
|
12575
|
+
if (!evidence.corpusVerified) missing.push("real-repository corpus run, n >= 10 classified verdicts, Wilson CI recorded, precision AND recall measured, detectorRev locked");
|
|
12576
|
+
}
|
|
12577
|
+
if (target === "M5_FIELD_PROVEN") {
|
|
12578
|
+
if (!evidence.fieldProven) missing.push("independent real-world repositories, declared field support, stable confidence interval, candidate-bound evidence");
|
|
12579
|
+
}
|
|
12580
|
+
return {
|
|
12581
|
+
maturity,
|
|
12582
|
+
nextLevelGap: {
|
|
12583
|
+
target,
|
|
12584
|
+
missing,
|
|
12585
|
+
owner,
|
|
12586
|
+
revisitTrigger
|
|
12587
|
+
}
|
|
12588
|
+
};
|
|
12589
|
+
}
|
|
12590
|
+
[
|
|
12591
|
+
{
|
|
12592
|
+
id: "trustLevel",
|
|
12593
|
+
fields: ["trustLevel"],
|
|
12594
|
+
vocabulary: [...TRUST_ORDER],
|
|
12595
|
+
question: "How much do we trust this finding? (src/types.ts:106-117)"
|
|
12596
|
+
},
|
|
12597
|
+
{
|
|
12598
|
+
id: "maturity",
|
|
12599
|
+
fields: ["maturity"],
|
|
12600
|
+
vocabulary: MATURITY_LEVELS$1,
|
|
12601
|
+
question: "How much has this capability been proven? (ADR 0001)"
|
|
12602
|
+
},
|
|
12603
|
+
{
|
|
12604
|
+
id: "evidenceState",
|
|
12605
|
+
fields: ["evidenceState", "state"],
|
|
12606
|
+
vocabulary: [
|
|
12607
|
+
"FRESH",
|
|
12608
|
+
"STALE",
|
|
12609
|
+
"FOREIGN",
|
|
12610
|
+
"NOT_RUN",
|
|
12611
|
+
"PARTIAL",
|
|
12612
|
+
"CONTRADICTORY",
|
|
12613
|
+
"INVALID",
|
|
12614
|
+
"UNKNOWN"
|
|
12615
|
+
],
|
|
12616
|
+
question: "Is this evidence current and admissible?"
|
|
12617
|
+
},
|
|
12618
|
+
{
|
|
12619
|
+
id: "determination",
|
|
12620
|
+
fields: ["determination", "verdict"],
|
|
12621
|
+
vocabulary: [
|
|
12622
|
+
"PASS",
|
|
12623
|
+
"FAILED",
|
|
12624
|
+
"BLOCKED",
|
|
12625
|
+
"INCONCLUSIVE",
|
|
12626
|
+
"UNPROVEN",
|
|
12627
|
+
"PARTIAL"
|
|
12628
|
+
],
|
|
12629
|
+
question: "What is the verdict? (closed set, TRUST-CONSTITUTION §2)"
|
|
12630
|
+
},
|
|
12631
|
+
{
|
|
12632
|
+
id: "lifecycle",
|
|
12633
|
+
fields: ["lifecycle", "ruleStatus"],
|
|
12634
|
+
vocabulary: [
|
|
12635
|
+
"PROPOSED",
|
|
12636
|
+
"EXPERIMENTAL",
|
|
12637
|
+
"PROVISIONAL",
|
|
12638
|
+
"VERIFIED",
|
|
12639
|
+
"PROVEN",
|
|
12640
|
+
"DEPRECATED",
|
|
12641
|
+
"RETIRED"
|
|
12642
|
+
],
|
|
12643
|
+
question: "Where is this rule in its career?"
|
|
12644
|
+
},
|
|
12645
|
+
{
|
|
12646
|
+
id: "severity",
|
|
12647
|
+
fields: [
|
|
12648
|
+
"severity",
|
|
12649
|
+
"trustImpact",
|
|
12650
|
+
"policySeverity"
|
|
12651
|
+
],
|
|
12652
|
+
vocabulary: [
|
|
12653
|
+
"CRITICAL",
|
|
12654
|
+
"HIGH",
|
|
12655
|
+
"MEDIUM",
|
|
12656
|
+
"LOW",
|
|
12657
|
+
"INFO",
|
|
12658
|
+
"NONE"
|
|
12659
|
+
],
|
|
12660
|
+
question: "How much does it matter? (technical severity stays separate)"
|
|
12661
|
+
}
|
|
12662
|
+
].map((axis) => axis.id);
|
|
12663
|
+
//#endregion
|
|
12664
|
+
//#region src/v6/ecosystem-census.ts
|
|
12665
|
+
/**
|
|
12666
|
+
* The leading major of a version or range. `FRAMEWORK_INVENTORY`
|
|
12667
|
+
* records *validated versions* (`1.44`, `29`) while a manifest records a
|
|
12668
|
+
* *range* (`^29.7.0`); both sides normalise to a major so the staleness
|
|
12669
|
+
* trigger compares like with like. A trigger that fires on a
|
|
12670
|
+
* representation difference is a trigger people learn to ignore.
|
|
12671
|
+
*/
|
|
12672
|
+
function versionMajor(raw) {
|
|
12673
|
+
const match = /\d+(?:\.\d+)*/.exec(raw);
|
|
12674
|
+
if (!match) return null;
|
|
12675
|
+
return (match[0] ?? "").split(".")[0] ?? null;
|
|
12676
|
+
}
|
|
12677
|
+
//#endregion
|
|
12678
|
+
//#region src/v6/ecosystem-probe.ts
|
|
12679
|
+
/**
|
|
12680
|
+
* Filesystem-backed census evidence + the discovery probe (ADR 0010).
|
|
12681
|
+
*
|
|
12682
|
+
* This module is the executable half of the census. Two jobs, both of
|
|
12683
|
+
* which exist because a census nobody diffs against reality is just a
|
|
12684
|
+
* nicer-looking hand-maintained list:
|
|
12685
|
+
*
|
|
12686
|
+
* 1. **Evidence resolution** — a `CensusEvidenceResolver` that observes
|
|
12687
|
+
* the real checkout, so a census entry's maturity is *derived* from
|
|
12688
|
+
* artifacts that exist rather than typed by a person. This is what
|
|
12689
|
+
* makes D1 mechanical: a capability ships only at the maturity the
|
|
12690
|
+
* machine proves.
|
|
12691
|
+
*
|
|
12692
|
+
* 2. **The discovery probe** — a walk over the checked-out corpus
|
|
12693
|
+
* (`tests/corpus/.cache/`) that reports every QA tool it sees,
|
|
12694
|
+
* including tools the census has never heard of. Those land as
|
|
12695
|
+
* `UNRECOGNIZED`, which is a first-class signal: a new tool the
|
|
12696
|
+
* engine sees in the wild and silently ignores is a release-blocking
|
|
12697
|
+
* honesty failure, not a backlog item.
|
|
12698
|
+
*
|
|
12699
|
+
* The resolver is deliberately **conservative**. Where no machine gate
|
|
12700
|
+
* exists to prove a criterion, the resolver returns `false` and the
|
|
12701
|
+
* entry lands lower. That direction of error is the safe one: an
|
|
12702
|
+
* under-claim is honest, an over-claim is a false proof (Law 1). Every
|
|
12703
|
+
* `false` returned here is therefore a real, named Wave-4 gate that does
|
|
12704
|
+
* not exist yet — see the `nextLevelGap` each entry carries.
|
|
12705
|
+
*/
|
|
12706
|
+
const CORPUS_CACHE_DIR = join("tests", "corpus", ".cache");
|
|
12707
|
+
const FIXTURES_DIR = join("tests", "fixtures");
|
|
12708
|
+
const VERDICTS_DIR = join("tests", "corpus", "verdicts");
|
|
12709
|
+
const ADAPTERS_DIR = join("src", "adapters");
|
|
12710
|
+
const RULES_TESTS_DIR = join("tests", "rules");
|
|
12711
|
+
const ADAPTER_TESTS_DIR = join("tests", "adapters");
|
|
12712
|
+
/** `QA-PW-*` → `QA-PW`. The family prefix is the id's domain segment. */
|
|
12713
|
+
function ruleFamily$1(ruleId) {
|
|
12714
|
+
const parts = ruleId.split("-");
|
|
12715
|
+
return parts.length >= 2 ? `${parts[0]}-${parts[1]}` : ruleId;
|
|
12716
|
+
}
|
|
12717
|
+
/**
|
|
12718
|
+
* Census ids are `ec.<category>.<slug>`, so the rule family they map to
|
|
12719
|
+
* is derived from the slug. This mapping is the join key between the
|
|
12720
|
+
* census and the rule registry — ADR 0007 in practice: the census
|
|
12721
|
+
* projects the existing registry rather than restating it.
|
|
12722
|
+
*/
|
|
12723
|
+
const SLUG_TO_FAMILY = {
|
|
12724
|
+
playwright: "QA-PW",
|
|
12725
|
+
cypress: "QA-CYP",
|
|
12726
|
+
selenium: "QA-SEL",
|
|
12727
|
+
jest: "QA-JV",
|
|
12728
|
+
vitest: "QA-JV",
|
|
12729
|
+
mocha: "QA-JV",
|
|
12730
|
+
jasmine: "QA-JV",
|
|
12731
|
+
pytest: "QA-PY",
|
|
12732
|
+
unittest: "QA-PY",
|
|
12733
|
+
"github-actions": "QA-CI",
|
|
12734
|
+
"azure-devops": "QA-CI",
|
|
12735
|
+
jenkins: "QA-CI",
|
|
12736
|
+
"gitlab-ci": "QA-CI",
|
|
12737
|
+
typescript: "QA-TEST",
|
|
12738
|
+
python: "QA-PY",
|
|
12739
|
+
java: "QA-JV",
|
|
12740
|
+
csharp: "QA-CS"
|
|
12741
|
+
};
|
|
12742
|
+
function familyForEntry(entry) {
|
|
12743
|
+
const slug = entry.id.split(".").pop() ?? "";
|
|
12744
|
+
return SLUG_TO_FAMILY[slug] ?? null;
|
|
12745
|
+
}
|
|
12746
|
+
function groupBy(ids) {
|
|
12747
|
+
const map = /* @__PURE__ */ new Map();
|
|
12748
|
+
for (const id of ids) {
|
|
12749
|
+
const family = ruleFamily$1(id);
|
|
12750
|
+
const bucket = map.get(family) ?? [];
|
|
12751
|
+
bucket.push(id);
|
|
12752
|
+
map.set(family, bucket);
|
|
12753
|
+
}
|
|
12754
|
+
return map;
|
|
12755
|
+
}
|
|
12756
|
+
function listFilesRecursive(dir, depth = 0) {
|
|
12757
|
+
if (depth > 4 || !existsSync(dir)) return [];
|
|
12758
|
+
const out = [];
|
|
12759
|
+
let entries;
|
|
12760
|
+
try {
|
|
12761
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
12762
|
+
} catch {
|
|
12763
|
+
return [];
|
|
12764
|
+
}
|
|
12765
|
+
for (const entry of entries) {
|
|
12766
|
+
const full = join(dir, entry.name);
|
|
12767
|
+
if (entry.isDirectory()) out.push(...listFilesRecursive(full, depth + 1));
|
|
12768
|
+
else out.push(full);
|
|
12769
|
+
}
|
|
12770
|
+
return out;
|
|
12771
|
+
}
|
|
12772
|
+
/**
|
|
12773
|
+
* Build the evidence index from the real checkout. Pure with respect to
|
|
12774
|
+
* the census (it takes a root), so the same function serves the
|
|
12775
|
+
* generator, the gate and the test fixture.
|
|
12776
|
+
*/
|
|
12777
|
+
function buildRepoEvidenceIndex(root) {
|
|
12778
|
+
const liveIds = RULES.map((rule) => rule.id);
|
|
12779
|
+
const rulesByFamily = groupBy(liveIds);
|
|
12780
|
+
const measured = [];
|
|
12781
|
+
for (const rule of RULES) {
|
|
12782
|
+
const measurement = MEASURED_FP[rule.id];
|
|
12783
|
+
if (measurement && measurement.detectorRevision === declaredDetectorRevision(rule)) measured.push(rule.id);
|
|
12784
|
+
}
|
|
12785
|
+
const measuredByFamily = groupBy(measured);
|
|
12786
|
+
const ruleSpecSources = listFilesRecursive(RULES_TESTS_DIR).filter((file) => file.endsWith(".ts")).map((file) => readFileSync(file, "utf8"));
|
|
12787
|
+
const testedByFamily = groupBy(liveIds.filter((id) => ruleSpecSources.some((source) => source.includes(id))));
|
|
12788
|
+
const quad = [];
|
|
12789
|
+
if (existsSync(join(root, FIXTURES_DIR))) for (const id of liveIds) {
|
|
12790
|
+
const dir = join(root, FIXTURES_DIR, id);
|
|
12791
|
+
if (!existsSync(dir)) continue;
|
|
12792
|
+
try {
|
|
12793
|
+
if (listFilesRecursive(dir).length >= 4) quad.push(id);
|
|
12794
|
+
} catch {}
|
|
12795
|
+
}
|
|
12796
|
+
const fixtureQuadByFamily = groupBy(quad);
|
|
12797
|
+
const corpusVerdictsByFamily = /* @__PURE__ */ new Map();
|
|
12798
|
+
const verdictsDir = join(root, VERDICTS_DIR);
|
|
12799
|
+
if (existsSync(verdictsDir)) for (const file of readdirSync(verdictsDir)) {
|
|
12800
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
12801
|
+
for (const line of readFileSync(join(verdictsDir, file), "utf8").split("\n")) {
|
|
12802
|
+
if (!line.trim()) continue;
|
|
12803
|
+
try {
|
|
12804
|
+
const entry = JSON.parse(line);
|
|
12805
|
+
if (!entry.ruleId) continue;
|
|
12806
|
+
if (entry.verdict !== "TP" && entry.verdict !== "FP") continue;
|
|
12807
|
+
const family = ruleFamily$1(entry.ruleId);
|
|
12808
|
+
corpusVerdictsByFamily.set(family, (corpusVerdictsByFamily.get(family) ?? 0) + 1);
|
|
12809
|
+
} catch {}
|
|
12810
|
+
}
|
|
12811
|
+
}
|
|
12812
|
+
const adapters = /* @__PURE__ */ new Set();
|
|
12813
|
+
const adapterDir = join(root, ADAPTERS_DIR);
|
|
12814
|
+
if (existsSync(adapterDir)) {
|
|
12815
|
+
for (const file of readdirSync(adapterDir)) if (file.endsWith(".ts")) adapters.add(file.replace(/\.ts$/, ""));
|
|
12816
|
+
}
|
|
12817
|
+
const adapterSpecNames = /* @__PURE__ */ new Set();
|
|
12818
|
+
for (const dir of [ADAPTER_TESTS_DIR, join("tests", "frameworks")]) for (const file of listFilesRecursive(join(root, dir))) {
|
|
12819
|
+
if (!file.endsWith(".spec.ts")) continue;
|
|
12820
|
+
const source = readFileSync(file, "utf8");
|
|
12821
|
+
for (const adapter of adapters) if (source.includes(adapter)) adapterSpecNames.add(adapter);
|
|
12822
|
+
}
|
|
12823
|
+
const corpusRoot = join(root, CORPUS_CACHE_DIR);
|
|
12824
|
+
return {
|
|
12825
|
+
rulesByFamily,
|
|
12826
|
+
measuredByFamily,
|
|
12827
|
+
testedByFamily,
|
|
12828
|
+
fixtureQuadByFamily,
|
|
12829
|
+
corpusVerdictsByFamily,
|
|
12830
|
+
adapters,
|
|
12831
|
+
adapterSpecNames,
|
|
12832
|
+
corpusRepos: existsSync(corpusRoot) ? readdirSync(corpusRoot).filter((name) => {
|
|
12833
|
+
try {
|
|
12834
|
+
return statSync(join(corpusRoot, name)).isDirectory();
|
|
12835
|
+
} catch {
|
|
12836
|
+
return false;
|
|
12837
|
+
}
|
|
12838
|
+
}) : []
|
|
12839
|
+
};
|
|
12840
|
+
}
|
|
12841
|
+
/**
|
|
12842
|
+
* Build the resolver from the index. Every arm is conservative and each
|
|
12843
|
+
* `false` is a *named missing gate*, not an oversight — see the comments.
|
|
12844
|
+
*/
|
|
12845
|
+
function createEvidenceResolver(index, root) {
|
|
12846
|
+
return {
|
|
12847
|
+
adapterExists(entry) {
|
|
12848
|
+
const family = familyForEntry(entry);
|
|
12849
|
+
if (family && (index.rulesByFamily.get(family)?.length ?? 0) > 0) return true;
|
|
12850
|
+
const slug = entry.id.split(".").pop() ?? "";
|
|
12851
|
+
return index.adapters.has(slug);
|
|
12852
|
+
},
|
|
12853
|
+
unitTested(entry) {
|
|
12854
|
+
const family = familyForEntry(entry);
|
|
12855
|
+
if (family && (index.testedByFamily.get(family)?.length ?? 0) > 0) return true;
|
|
12856
|
+
const slug = entry.id.split(".").pop() ?? "";
|
|
12857
|
+
return index.adapterSpecNames.has(slug);
|
|
12858
|
+
},
|
|
12859
|
+
fixtureQuadVerified() {
|
|
12860
|
+
return false;
|
|
12861
|
+
},
|
|
12862
|
+
corpusVerified() {
|
|
12863
|
+
return false;
|
|
12864
|
+
},
|
|
12865
|
+
fieldProven() {
|
|
12866
|
+
return false;
|
|
12867
|
+
},
|
|
12868
|
+
observedUpstreamMajor(entry) {
|
|
12869
|
+
return detectUpstreamMajor(entry, root);
|
|
12870
|
+
}
|
|
12871
|
+
};
|
|
12872
|
+
}
|
|
12873
|
+
/**
|
|
12874
|
+
* The npm package name(s) a census slug corresponds to, if any.
|
|
12875
|
+
*
|
|
12876
|
+
* Read from the entry's own `upstreamPackages` — the census is the
|
|
12877
|
+
* authority on naming, so a consumer holding its own copy of the answer
|
|
12878
|
+
* is the parallel-inventory drift ADR 0007 exists to prevent. An earlier
|
|
12879
|
+
* version kept a local map here and a heuristic on the slug, and the
|
|
12880
|
+
* consequence was concrete: `@percy/cypress` was read as `cypress` and
|
|
12881
|
+
* `@swc/jest` as `jest`, inventing staleness verdicts for plugins whose
|
|
12882
|
+
* versions do not track the runner.
|
|
12883
|
+
*/
|
|
12884
|
+
function npmPackageNames(entry) {
|
|
12885
|
+
return entry.upstreamPackages.filter((pkg) => pkg.ecosystem === "npm" || pkg.ecosystem === "any").map((pkg) => pkg.name);
|
|
12886
|
+
}
|
|
12887
|
+
/**
|
|
12888
|
+
* Detect the upstream major version a corpus repository actually uses,
|
|
12889
|
+
* when it is discoverable from a manifest. Returns `null` when it is not
|
|
12890
|
+
* — and `null` means "no staleness verdict", never "up to date".
|
|
12891
|
+
*/
|
|
12892
|
+
function detectUpstreamMajor(entry, root) {
|
|
12893
|
+
if (entry.handledUpstreamMajors.length === 0) return null;
|
|
12894
|
+
const upstream = npmPackageNames(entry);
|
|
12895
|
+
if (upstream.length === 0) return null;
|
|
12896
|
+
for (const repo of corpusReposOf(root)) {
|
|
12897
|
+
const pkgPath = join(root, CORPUS_CACHE_DIR, repo, "package.json");
|
|
12898
|
+
if (!existsSync(pkgPath)) continue;
|
|
12899
|
+
try {
|
|
12900
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
12901
|
+
const deps = {
|
|
12902
|
+
...pkg.dependencies ?? {},
|
|
12903
|
+
...pkg.devDependencies ?? {},
|
|
12904
|
+
...pkg.peerDependencies ?? {}
|
|
12905
|
+
};
|
|
12906
|
+
for (const name of upstream) {
|
|
12907
|
+
const range = deps[name];
|
|
12908
|
+
if (range === void 0) continue;
|
|
12909
|
+
const major = versionMajor(range);
|
|
12910
|
+
if (major !== null) return major;
|
|
12911
|
+
}
|
|
12912
|
+
} catch {}
|
|
12913
|
+
}
|
|
12914
|
+
return null;
|
|
12915
|
+
}
|
|
12916
|
+
function corpusReposOf(root) {
|
|
12917
|
+
const corpusRoot = join(root, CORPUS_CACHE_DIR);
|
|
12918
|
+
if (!existsSync(corpusRoot)) return [];
|
|
12919
|
+
return readdirSync(corpusRoot).filter((name) => {
|
|
12920
|
+
try {
|
|
12921
|
+
return statSync(join(corpusRoot, name)).isDirectory();
|
|
12922
|
+
} catch {
|
|
12923
|
+
return false;
|
|
12924
|
+
}
|
|
12925
|
+
});
|
|
12926
|
+
}
|
|
12927
|
+
//#endregion
|
|
12928
|
+
//#region src/v6/capability-registry.ts
|
|
12929
|
+
/**
|
|
12930
|
+
|
|
12931
|
+
}
|
|
12932
|
+
|
|
12933
|
+
/**
|
|
12934
|
+
* The unified capability registry (ADR 0007) — Wave 1.
|
|
12935
|
+
*
|
|
12936
|
+
* **One registry, never a parallel one.** Before this module the
|
|
12937
|
+
* repository held six capability-adjacent primitives (framework
|
|
12938
|
+
* inventory, provider capability contract, universal pack contract, rule
|
|
12939
|
+
* registry census, the 136-cell support matrix, `src/capabilities.ts`), and
|
|
12940
|
+
* none of them agreed with the others. That is the drift Law 8 exists to
|
|
12941
|
+
* prevent, and it is why a hand-maintained support list is a documentation
|
|
12942
|
+
* defect *by construction* rather than by negligence.
|
|
12943
|
+
*
|
|
12944
|
+
* This module is the **loader**. It reads from the existing primitives and
|
|
12945
|
+
* assembles one entry per capability. It deliberately does not:
|
|
12946
|
+
*
|
|
12947
|
+
* - re-declare any framework, provider, language or rule list;
|
|
12948
|
+
* - contain a `setMaturity` export (maturity is derived — ADR 0001);
|
|
12949
|
+
* - let a consumer hand-write a claim (every claim resolves to a
|
|
12950
|
+
* `ProofRef`, and `claim-registry` is the only place a claim lives).
|
|
12951
|
+
*
|
|
12952
|
+
* A **capability id is not a rule id.** Capability ids live in this
|
|
12953
|
+
* namespace and *reference* `QA-*` ids, which are frozen forever
|
|
12954
|
+
* (ADR 0003). A capability is a claim about coverage; a rule is a
|
|
12955
|
+
* detector. Collapsing them would forbid the one-capability-several-rules
|
|
12956
|
+
* case that already exists.
|
|
12957
|
+
*
|
|
12958
|
+
* The 136-cell support matrix becomes a **projection** of this registry
|
|
12959
|
+
* rather than a second truth (§2.3), which is what makes a matrix cell
|
|
12960
|
+
* impossible to disagree with a capability entry.
|
|
12961
|
+
*/
|
|
12962
|
+
function isRuleId(value) {
|
|
12963
|
+
return typeof value === "string" && /^QA-[A-Z]+-\d+$/.test(value);
|
|
12964
|
+
}
|
|
12965
|
+
const BLIND_EVIDENCE = {
|
|
12966
|
+
hasLiveRule: false,
|
|
12967
|
+
hasMeasurement: false,
|
|
12968
|
+
hasAdapter: false,
|
|
12969
|
+
hasFixtureQuad: false,
|
|
12970
|
+
hasCorpusMeasurement: false,
|
|
12971
|
+
hasFieldEvidence: false
|
|
12972
|
+
};
|
|
12973
|
+
function toMaturityEvidence(e) {
|
|
12974
|
+
return {
|
|
12975
|
+
declared: true,
|
|
12976
|
+
implemented: e.hasLiveRule || e.hasAdapter,
|
|
12977
|
+
unitTested: e.hasLiveRule,
|
|
12978
|
+
fixtureQuadVerified: e.hasFixtureQuad,
|
|
12979
|
+
corpusVerified: e.hasCorpusMeasurement,
|
|
12980
|
+
fieldProven: e.hasFieldEvidence
|
|
12981
|
+
};
|
|
12982
|
+
}
|
|
12983
|
+
const BLIND_REGISTRY_EVIDENCE = {
|
|
12984
|
+
registry: BLIND_EVIDENCE,
|
|
12985
|
+
files: {
|
|
12986
|
+
adapterExists: () => false,
|
|
12987
|
+
fixtureQuadExists: () => false,
|
|
12988
|
+
observedUpstreamMajor: () => null
|
|
12989
|
+
}
|
|
12990
|
+
};
|
|
12991
|
+
function ruleFamily(ruleId) {
|
|
12992
|
+
const parts = ruleId.split("-");
|
|
12993
|
+
return parts.length >= 2 ? `${parts[0]}-${parts[1]}` : ruleId;
|
|
12994
|
+
}
|
|
12995
|
+
function collectRuleFacts() {
|
|
12996
|
+
const group = (ids) => {
|
|
12997
|
+
const map = /* @__PURE__ */ new Map();
|
|
12998
|
+
for (const id of ids) {
|
|
12999
|
+
const family = ruleFamily(id);
|
|
13000
|
+
const bucket = map.get(family) ?? [];
|
|
13001
|
+
bucket.push(id);
|
|
13002
|
+
map.set(family, bucket);
|
|
13003
|
+
}
|
|
13004
|
+
return map;
|
|
13005
|
+
};
|
|
13006
|
+
const live = RULES.map((rule) => rule.id);
|
|
13007
|
+
const measured = RULES.filter((rule) => {
|
|
13008
|
+
const measurement = MEASURED_FP[rule.id];
|
|
13009
|
+
return measurement !== void 0 && measurement.detectorRevision === declaredDetectorRevision(rule);
|
|
13010
|
+
}).map((rule) => rule.id);
|
|
13011
|
+
return {
|
|
13012
|
+
liveByFamily: group(live),
|
|
13013
|
+
measuredByFamily: group(measured),
|
|
13014
|
+
tierByFamily: group(live.map((id) => effectiveTier(getRuleById(id)))),
|
|
13015
|
+
retired: new Set(RETIRED_RULE_IDS)
|
|
13016
|
+
};
|
|
13017
|
+
}
|
|
13018
|
+
function getRuleById(id) {
|
|
13019
|
+
const rule = RULES.find((candidate) => candidate.id === id);
|
|
13020
|
+
if (rule === void 0) throw new Error(`rule ${id} is in the registry index but not in RULES`);
|
|
13021
|
+
return rule;
|
|
13022
|
+
}
|
|
13023
|
+
/**
|
|
13024
|
+
* The framework-inventory id becomes a capability id in the
|
|
13025
|
+
* `test.framework.*` / `test.runner.*` namespace. The mapping is a pure
|
|
13026
|
+
* function of the inventory row, so a framework added to the inventory
|
|
13027
|
+
* appears in the registry without anybody typing its name a second time.
|
|
13028
|
+
*/
|
|
13029
|
+
function frameworkCapabilityId(frameworkId) {
|
|
13030
|
+
return `test.framework.${frameworkId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}`;
|
|
13031
|
+
}
|
|
13032
|
+
function providerCapabilityId(provider) {
|
|
13033
|
+
return `ci.provider.${provider.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}`;
|
|
13034
|
+
}
|
|
13035
|
+
function domainCapabilityId(domainId) {
|
|
13036
|
+
return `qa.domain.${domainId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}`;
|
|
13037
|
+
}
|
|
13038
|
+
function finalize(base, evidence, observedAt) {
|
|
13039
|
+
const { maturity, nextLevelGap } = nextLevelGapFromEvidence(toMaturityEvidence(evidence.registry), base.owner, `capability ${base.id} gains evidence for its next level`);
|
|
13040
|
+
return {
|
|
13041
|
+
...base,
|
|
13042
|
+
proven: maturity,
|
|
13043
|
+
maturity,
|
|
13044
|
+
nextLevelGap,
|
|
13045
|
+
proof: proofFor(base, maturity, evidence, observedAt, collectRuleFacts())
|
|
13046
|
+
};
|
|
13047
|
+
}
|
|
13048
|
+
/**
|
|
13049
|
+
* The proof pointer.
|
|
13050
|
+
*
|
|
13051
|
+
* **Only a measured rule can carry `LOCAL_PROVEN`.** An adapter file on
|
|
13052
|
+
* disk proves the capability is *implemented*, which is M2, but it is not
|
|
13053
|
+
* an observation: nobody recorded when it was checked, against which
|
|
13054
|
+
* revision, with what result. Promoting it to `LOCAL_PROVEN` would be the
|
|
13055
|
+
* precise failure the wave-1 DoD forbids — "no capability advertised
|
|
13056
|
+
* above proven level" — and it is why the honest answer for most entries
|
|
13057
|
+
* today is `BLOCKED`.
|
|
13058
|
+
*
|
|
13059
|
+
* That `BLOCKED` is not a gap in this module. It is the measurement that
|
|
13060
|
+
* Wave 4 produces: a per-capability `detectorRev` binding with a recorded
|
|
13061
|
+
* observation date. Until then, every claim being unbound is the truth
|
|
13062
|
+
* (`GAP-V6-006`), and `claim-registry.json` shows exactly that.
|
|
13063
|
+
*/
|
|
13064
|
+
function proofFor(base, maturity, evidence, observedAt, facts) {
|
|
13065
|
+
if (maturityRank(maturity) >= maturityRank("M2_IMPLEMENTED")) {
|
|
13066
|
+
const measured = evidence.registry.hasMeasurement ? base.rules.find((rule) => !facts.retired.has(rule)) : void 0;
|
|
13067
|
+
if (measured !== void 0 && observedAt !== "") return {
|
|
13068
|
+
status: "LOCAL_PROVEN",
|
|
13069
|
+
artifact: `docs/FP-AUDIT.md#${measured}`,
|
|
13070
|
+
digest: null,
|
|
13071
|
+
observedAt,
|
|
13072
|
+
authority: base.owner
|
|
13073
|
+
};
|
|
13074
|
+
}
|
|
13075
|
+
return {
|
|
13076
|
+
status: "BLOCKED",
|
|
13077
|
+
artifact: null,
|
|
13078
|
+
digest: null,
|
|
13079
|
+
observedAt: null,
|
|
13080
|
+
authority: "NONE"
|
|
13081
|
+
};
|
|
13082
|
+
}
|
|
13083
|
+
/**
|
|
13084
|
+
* Build the registry. The result is a function of the checkout plus the
|
|
13085
|
+
* supplied evidence, and of nothing else — which is what makes
|
|
13086
|
+
* `docs/capability-registry.json` byte-stable for a commit.
|
|
13087
|
+
*/
|
|
13088
|
+
function buildCapabilityRegistry(options = {}) {
|
|
13089
|
+
const root = options.root ?? process.cwd();
|
|
13090
|
+
const evidence = options.evidence ?? BLIND_REGISTRY_EVIDENCE;
|
|
13091
|
+
const observedAt = options.observedAt ?? "1970-01-01";
|
|
13092
|
+
const facts = collectRuleFacts();
|
|
13093
|
+
const byId = /* @__PURE__ */ new Map();
|
|
13094
|
+
for (const framework of FRAMEWORK_INVENTORY) {
|
|
13095
|
+
if (framework.entityType === "CI_PROVIDER") continue;
|
|
13096
|
+
const id = frameworkCapabilityId(framework.frameworkId);
|
|
13097
|
+
const family = familyForFramework(framework.frameworkId);
|
|
13098
|
+
const live = facts.liveByFamily.get(family) ?? [];
|
|
13099
|
+
const adapter = framework.executorAdapterIds.length > 0 ? `src/adapters/${framework.executorAdapterIds[0]}.ts` : null;
|
|
13100
|
+
byId.set(id, finalize({
|
|
13101
|
+
id,
|
|
13102
|
+
name: framework.frameworkId,
|
|
13103
|
+
kind: kindForFramework(framework.entityType),
|
|
13104
|
+
owner: "framework-inventory",
|
|
13105
|
+
rules: live,
|
|
13106
|
+
censusId: null,
|
|
13107
|
+
frameworkId: framework.frameworkId,
|
|
13108
|
+
domains: [],
|
|
13109
|
+
blocksAxes: ["Discovery"],
|
|
13110
|
+
observedUpstreamMajor: evidence.files.observedUpstreamMajor(framework.frameworkId),
|
|
13111
|
+
adapter
|
|
13112
|
+
}, {
|
|
13113
|
+
registry: {
|
|
13114
|
+
...evidence.registry,
|
|
13115
|
+
hasAdapter: evidence.registry.hasAdapter || adapter !== null,
|
|
13116
|
+
hasFixtureQuad: evidence.registry.hasFixtureQuad || live.some((rule) => evidence.files.fixtureQuadExists(rule))
|
|
13117
|
+
},
|
|
13118
|
+
files: evidence.files
|
|
13119
|
+
}, observedAt));
|
|
13120
|
+
}
|
|
13121
|
+
for (const provider of CI_PROVIDER_IDS) {
|
|
13122
|
+
const id = providerCapabilityId(provider);
|
|
13123
|
+
if (byId.has(id)) continue;
|
|
13124
|
+
const live = facts.liveByFamily.get("QA-CI") ?? [];
|
|
13125
|
+
const adapter = `src/adapters/${provider.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.ts`;
|
|
13126
|
+
byId.set(id, finalize({
|
|
13127
|
+
id,
|
|
13128
|
+
name: provider,
|
|
13129
|
+
kind: "ci-cd-provider",
|
|
13130
|
+
owner: "provider-capability-contract",
|
|
13131
|
+
rules: live,
|
|
13132
|
+
censusId: null,
|
|
13133
|
+
frameworkId: null,
|
|
13134
|
+
domains: ["ci-integrity"],
|
|
13135
|
+
blocksAxes: ["CI Integrity", "False-Green Detection"],
|
|
13136
|
+
observedUpstreamMajor: null,
|
|
13137
|
+
adapter
|
|
13138
|
+
}, {
|
|
13139
|
+
registry: {
|
|
13140
|
+
...evidence.registry,
|
|
13141
|
+
hasAdapter: evidence.registry.hasAdapter || evidence.files.adapterExists(adapter),
|
|
13142
|
+
hasFixtureQuad: evidence.registry.hasFixtureQuad || live.some((rule) => evidence.files.fixtureQuadExists(rule))
|
|
13143
|
+
},
|
|
13144
|
+
files: evidence.files
|
|
13145
|
+
}, observedAt));
|
|
13146
|
+
}
|
|
13147
|
+
for (const domain of readDomainCells(root)) {
|
|
13148
|
+
const id = domainCapabilityId(domain.id);
|
|
13149
|
+
if (byId.has(id)) continue;
|
|
13150
|
+
byId.set(id, finalize({
|
|
13151
|
+
id,
|
|
13152
|
+
name: domain.label,
|
|
13153
|
+
kind: "domain",
|
|
13154
|
+
owner: domain.owner,
|
|
13155
|
+
rules: [],
|
|
13156
|
+
censusId: null,
|
|
13157
|
+
frameworkId: null,
|
|
13158
|
+
domains: [domain.id],
|
|
13159
|
+
blocksAxes: ["Domain Coverage"],
|
|
13160
|
+
observedUpstreamMajor: null,
|
|
13161
|
+
adapter: null,
|
|
13162
|
+
declaredDisposition: domain.disposition,
|
|
13163
|
+
blockedReason: domain.blockedReason,
|
|
13164
|
+
revisitTrigger: domain.revisitTrigger
|
|
13165
|
+
}, {
|
|
13166
|
+
registry: BLIND_EVIDENCE,
|
|
13167
|
+
files: evidence.files
|
|
13168
|
+
}, observedAt));
|
|
13169
|
+
}
|
|
13170
|
+
return {
|
|
13171
|
+
schemaVersion: 1,
|
|
13172
|
+
registryId: "mjolnir-capability-registry",
|
|
13173
|
+
observedAt,
|
|
13174
|
+
entries: [...byId.values()].sort((a, b) => compareCodePoints(a.id, b.id))
|
|
13175
|
+
};
|
|
13176
|
+
}
|
|
13177
|
+
const FRAMEWORK_FAMILY = {
|
|
13178
|
+
playwright: "QA-PW",
|
|
13179
|
+
cypress: "QA-CYP",
|
|
13180
|
+
selenium: "QA-SEL",
|
|
13181
|
+
jest: "QA-JV",
|
|
13182
|
+
vitest: "QA-JV",
|
|
13183
|
+
mocha: "QA-JV",
|
|
13184
|
+
jasmine: "QA-JV",
|
|
13185
|
+
pytest: "QA-PY",
|
|
13186
|
+
typescript: "QA-TEST",
|
|
13187
|
+
"github-actions": "QA-CI",
|
|
13188
|
+
"azure-pipelines": "QA-CI",
|
|
13189
|
+
jenkins: "QA-CI",
|
|
13190
|
+
"gitlab-ci": "QA-CI"
|
|
13191
|
+
};
|
|
13192
|
+
/** The rule family a framework's capability is evidenced by. */
|
|
13193
|
+
function familyForFramework(frameworkId) {
|
|
13194
|
+
return FRAMEWORK_FAMILY[frameworkId] ?? "QA-TEST";
|
|
13195
|
+
}
|
|
13196
|
+
function kindForFramework(entityType) {
|
|
13197
|
+
switch (entityType) {
|
|
13198
|
+
case "E2E_FRAMEWORK": return "test-framework";
|
|
13199
|
+
case "AUTOMATION_LIBRARY": return "test-runner";
|
|
13200
|
+
default: return "test-framework";
|
|
13201
|
+
}
|
|
13202
|
+
}
|
|
13203
|
+
/**
|
|
13204
|
+
* Read the `MATRIX-DOMAIN-*` cells from the support matrix.
|
|
13205
|
+
*
|
|
13206
|
+
* Returns an empty list when the matrix is absent rather than throwing: a
|
|
13207
|
+
* missing ledger means "no domain claims are derivable", which is a gap the
|
|
13208
|
+
* gate reports — not a crash in the middle of building a registry.
|
|
13209
|
+
*/
|
|
13210
|
+
function readDomainCells(root = process.cwd()) {
|
|
13211
|
+
const path = join(root, "docs", "M26-SUPPORT-MATRIX.json");
|
|
13212
|
+
if (!existsSync(path)) return [];
|
|
13213
|
+
let cells;
|
|
13214
|
+
try {
|
|
13215
|
+
cells = JSON.parse(readFileSync(path, "utf8")).cells ?? [];
|
|
13216
|
+
} catch {
|
|
13217
|
+
return [];
|
|
13218
|
+
}
|
|
13219
|
+
const out = [];
|
|
13220
|
+
for (const cell of cells) {
|
|
13221
|
+
const cellId = typeof cell.cell_id === "string" ? cell.cell_id : "";
|
|
13222
|
+
if (!cellId.startsWith("MATRIX-DOMAIN-")) continue;
|
|
13223
|
+
const id = cellId.slice(14);
|
|
13224
|
+
if (id === "") continue;
|
|
13225
|
+
out.push({
|
|
13226
|
+
id,
|
|
13227
|
+
label: typeof cell.cell === "string" ? cell.cell : id,
|
|
13228
|
+
disposition: typeof cell.disposition === "string" ? cell.disposition : "UNKNOWN",
|
|
13229
|
+
owner: typeof cell.owner === "string" ? cell.owner : "unowned",
|
|
13230
|
+
blockedReason: typeof cell.blocked_reason === "string" ? cell.blocked_reason : null,
|
|
13231
|
+
revisitTrigger: typeof cell.revisit_trigger === "string" ? cell.revisit_trigger : null
|
|
13232
|
+
});
|
|
13233
|
+
}
|
|
13234
|
+
return out.sort((a, b) => compareCodePoints(a.id, b.id));
|
|
13235
|
+
}
|
|
13236
|
+
/**
|
|
13237
|
+
* Evidence resolved against the real working tree.
|
|
13238
|
+
*
|
|
13239
|
+
* `hasLiveRule` and `hasMeasurement` come from the **rule registry**, not
|
|
13240
|
+
* from the filesystem, and they are read here rather than inside the
|
|
13241
|
+
* builder so that a test can inject an evidence object and have it mean
|
|
13242
|
+
* something. That matters: an earlier version of the builder read the
|
|
13243
|
+
* rule facts itself and only honoured the *capability-level* arms from its
|
|
13244
|
+
* caller, so a test that injected "no measurement" still saw
|
|
13245
|
+
* `LOCAL_PROVEN`. An evidence seam that silently ignores half of itself is
|
|
13246
|
+
* not a seam.
|
|
13247
|
+
*
|
|
13248
|
+
* The `hasCorpusMeasurement` arm is **always false** here, because nothing
|
|
13249
|
+
* binds a *capability* to a locked `detectorRev` yet — that is Wave 4's
|
|
13250
|
+
* `rules:quality:check`. A capability claiming M4 on the strength of a
|
|
13251
|
+
* per-rule measurement is exactly the false proof ADR 0001 forbids, so
|
|
13252
|
+
* M3/M4 stay unreachable until the per-capability gate exists.
|
|
13253
|
+
*/
|
|
13254
|
+
function realCapabilityEvidence() {
|
|
13255
|
+
const probe = createEvidenceResolver(buildRepoEvidenceIndex(process.cwd()), process.cwd());
|
|
13256
|
+
const facts = collectRuleFacts();
|
|
13257
|
+
const measured = new Set([...facts.measuredByFamily.values()].flat());
|
|
13258
|
+
return {
|
|
13259
|
+
registry: {
|
|
13260
|
+
hasLiveRule: new Set([...facts.liveByFamily.values()].flat()).size > 0,
|
|
13261
|
+
hasMeasurement: measured.size > 0,
|
|
13262
|
+
hasAdapter: false,
|
|
13263
|
+
hasFixtureQuad: false,
|
|
13264
|
+
hasCorpusMeasurement: false,
|
|
13265
|
+
hasFieldEvidence: false
|
|
13266
|
+
},
|
|
13267
|
+
files: {
|
|
13268
|
+
adapterExists: (relPath) => {
|
|
13269
|
+
const slug = relPath.replace(/^src\/adapters\//, "").replace(/\.ts$/, "");
|
|
13270
|
+
return probe.adapterExists(adapterProbeEntry(slug));
|
|
13271
|
+
},
|
|
13272
|
+
fixtureQuadExists: () => fixtureQuadExists(),
|
|
13273
|
+
observedUpstreamMajor: (frameworkId) => probe.observedUpstreamMajor(adapterProbeEntry(frameworkId))
|
|
13274
|
+
}
|
|
13275
|
+
};
|
|
13276
|
+
}
|
|
13277
|
+
function adapterProbeEntry(slug) {
|
|
13278
|
+
return {
|
|
13279
|
+
id: `ec.test-framework.${slug}`,
|
|
13280
|
+
name: slug,
|
|
13281
|
+
category: "test-framework",
|
|
13282
|
+
state: "SUPPORTED",
|
|
13283
|
+
owner: "capability-registry",
|
|
13284
|
+
observedAt: "1970-01-01",
|
|
13285
|
+
signals: [],
|
|
13286
|
+
adapter: `src/adapters/${slug}.ts`,
|
|
13287
|
+
blocksAxes: [],
|
|
13288
|
+
successor: null,
|
|
13289
|
+
removalDate: null,
|
|
13290
|
+
notApplicableReason: null,
|
|
13291
|
+
upstreamPackages: [{
|
|
13292
|
+
ecosystem: "npm",
|
|
13293
|
+
name: slug
|
|
13294
|
+
}],
|
|
13295
|
+
handledUpstreamMajors: [],
|
|
13296
|
+
revisitTrigger: "",
|
|
13297
|
+
maturity: "M1_DECLARED",
|
|
13298
|
+
nextLevelGap: null
|
|
13299
|
+
};
|
|
13300
|
+
}
|
|
13301
|
+
/**
|
|
13302
|
+
* A per-rule fixture quad is a real filesystem question, and the answer
|
|
13303
|
+
* here is deliberately **no**: the repository's fixture directories are a
|
|
13304
|
+
* proxy, and a proxy must not buy an M3 claim (Law 1). Wave 4 ships the
|
|
13305
|
+
* real gate.
|
|
13306
|
+
*/
|
|
13307
|
+
function fixtureQuadExists() {
|
|
13308
|
+
return false;
|
|
13309
|
+
}
|
|
13310
|
+
/**
|
|
13311
|
+
* `registry:check` — the Wave 1 DoD gate.
|
|
13312
|
+
*
|
|
13313
|
+
* The single most important rule here is **no capability advertised above
|
|
13314
|
+
* proven level**. A capability whose `maturity` outranks what its own
|
|
13315
|
+
* evidence supports is a false proof, and this is the function every
|
|
13316
|
+
* surface calls before it renders a maturity badge.
|
|
13317
|
+
*/
|
|
13318
|
+
function validateRegistry(registry) {
|
|
13319
|
+
const diagnostics = [];
|
|
13320
|
+
const seen = /* @__PURE__ */ new Set();
|
|
13321
|
+
const liveIds = new Set(RULES.map((rule) => rule.id));
|
|
13322
|
+
const retired = new Set(RETIRED_RULE_IDS);
|
|
13323
|
+
for (const entry of registry.entries) {
|
|
13324
|
+
if (seen.has(entry.id)) diagnostics.push({
|
|
13325
|
+
code: "DUPLICATE_ID",
|
|
13326
|
+
entryId: entry.id,
|
|
13327
|
+
message: "duplicate capability id"
|
|
13328
|
+
});
|
|
13329
|
+
seen.add(entry.id);
|
|
13330
|
+
if (entry.owner.trim() === "") diagnostics.push({
|
|
13331
|
+
code: "MISSING_OWNER",
|
|
13332
|
+
entryId: entry.id,
|
|
13333
|
+
message: "capability has no named owner"
|
|
13334
|
+
});
|
|
13335
|
+
for (const reference of entry.rules) {
|
|
13336
|
+
const rule = reference;
|
|
13337
|
+
if (!isRuleId(rule)) diagnostics.push({
|
|
13338
|
+
code: "UNKNOWN_RULE_REFERENCE",
|
|
13339
|
+
entryId: entry.id,
|
|
13340
|
+
message: `rule reference "${String(rule)}" is not a QA-* rule id (ADR 0003)`
|
|
13341
|
+
});
|
|
13342
|
+
else if (retired.has(rule)) diagnostics.push({
|
|
13343
|
+
code: "RETIRED_RULE_REFERENCE",
|
|
13344
|
+
entryId: entry.id,
|
|
13345
|
+
message: `depends on retired rule ${rule}`
|
|
13346
|
+
});
|
|
13347
|
+
else if (!liveIds.has(rule)) diagnostics.push({
|
|
13348
|
+
code: "UNKNOWN_RULE_REFERENCE",
|
|
13349
|
+
entryId: entry.id,
|
|
13350
|
+
message: `rule reference "${rule}" is not in the live registry`
|
|
13351
|
+
});
|
|
13352
|
+
}
|
|
13353
|
+
if (maturityRank(entry.maturity) > maturityRank(entry.proven)) diagnostics.push({
|
|
13354
|
+
code: "OVER_CLAIMED_MATURITY",
|
|
13355
|
+
entryId: entry.id,
|
|
13356
|
+
message: `advertises ${entry.maturity} but its own evidence proves only ${entry.proven}`
|
|
13357
|
+
});
|
|
13358
|
+
if (entry.nextLevelGap === null && entry.maturity !== "M5_FIELD_PROVEN") diagnostics.push({
|
|
13359
|
+
code: "MISSING_NEXT_LEVEL_GAP",
|
|
13360
|
+
entryId: entry.id,
|
|
13361
|
+
message: `${entry.maturity} must expose a nextLevelGap (D1)`
|
|
13362
|
+
});
|
|
13363
|
+
if (entry.proof.status !== "BLOCKED" && (entry.proof.artifact === null || entry.proof.observedAt === null)) diagnostics.push({
|
|
13364
|
+
code: "PROOF_WITHOUT_ARTIFACT",
|
|
13365
|
+
entryId: entry.id,
|
|
13366
|
+
message: `proof status ${entry.proof.status} requires both an artifact and an observedAt`
|
|
13367
|
+
});
|
|
13368
|
+
}
|
|
13369
|
+
return diagnostics;
|
|
13370
|
+
}
|
|
13371
|
+
//#endregion
|
|
13372
|
+
//#region src/commands/capability.ts
|
|
13373
|
+
/** Parse the flags. Anything flag-shaped that is not documented is a
|
|
13374
|
+
* usage error rather than a silently ignored argument, because
|
|
13375
|
+
* `capability --promote-m3` must not read as "show me everything". */
|
|
13376
|
+
function parseCapabilityArgs(argv) {
|
|
13377
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
13378
|
+
"--json",
|
|
13379
|
+
"--kind",
|
|
13380
|
+
"--maturity",
|
|
13381
|
+
"--id"
|
|
13382
|
+
]);
|
|
13383
|
+
const unknown = [];
|
|
13384
|
+
const query = {
|
|
13385
|
+
kind: null,
|
|
13386
|
+
maturity: null,
|
|
13387
|
+
id: null
|
|
13388
|
+
};
|
|
13389
|
+
let json = false;
|
|
13390
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
13391
|
+
const arg = argv[index] ?? "";
|
|
13392
|
+
if (!arg.startsWith("-")) {
|
|
13393
|
+
unknown.push(arg);
|
|
13394
|
+
continue;
|
|
13395
|
+
}
|
|
13396
|
+
const [flag, inlineValue] = arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, void 0];
|
|
13397
|
+
if (!allowed.has(flag)) {
|
|
13398
|
+
unknown.push(arg);
|
|
13399
|
+
continue;
|
|
13400
|
+
}
|
|
13401
|
+
if (flag === "--json") {
|
|
13402
|
+
json = true;
|
|
13403
|
+
continue;
|
|
13404
|
+
}
|
|
13405
|
+
const value = inlineValue ?? argv[index + 1] ?? "";
|
|
13406
|
+
if (inlineValue === void 0) index += 1;
|
|
13407
|
+
if (flag === "--kind") query.kind = value;
|
|
13408
|
+
if (flag === "--maturity") query.maturity = value;
|
|
13409
|
+
if (flag === "--id") query.id = value;
|
|
13410
|
+
}
|
|
13411
|
+
return {
|
|
13412
|
+
query,
|
|
13413
|
+
json,
|
|
13414
|
+
unknown
|
|
13415
|
+
};
|
|
13416
|
+
}
|
|
13417
|
+
function selectCapabilities(entries, query) {
|
|
13418
|
+
return entries.filter((entry) => {
|
|
13419
|
+
if (query.id !== null && !entry.id.includes(query.id)) return false;
|
|
13420
|
+
if (query.kind !== null && entry.kind !== query.kind) return false;
|
|
13421
|
+
if (query.maturity !== null && MATURITY_SHORT[entry.maturity] !== query.maturity.toUpperCase()) return false;
|
|
13422
|
+
return true;
|
|
13423
|
+
});
|
|
13424
|
+
}
|
|
13425
|
+
function capabilityJson(entries, diagnostics) {
|
|
13426
|
+
return JSON.stringify({
|
|
13427
|
+
contract: "mjolnir.capability.v1",
|
|
13428
|
+
count: entries.length,
|
|
13429
|
+
capabilities: entries.map((entry) => ({
|
|
13430
|
+
capabilityId: entry.id,
|
|
13431
|
+
name: entry.name,
|
|
13432
|
+
kind: entry.kind,
|
|
13433
|
+
owner: entry.owner,
|
|
13434
|
+
maturity: MATURITY_SHORT[entry.maturity],
|
|
13435
|
+
rules: entry.rules,
|
|
13436
|
+
adapter: entry.adapter,
|
|
13437
|
+
proof: entry.proof,
|
|
13438
|
+
nextLevelGap: entry.nextLevelGap,
|
|
13439
|
+
blocksAxes: entry.blocksAxes
|
|
13440
|
+
})),
|
|
13441
|
+
violations: diagnostics.map((d) => ({
|
|
13442
|
+
code: d.code,
|
|
13443
|
+
capabilityId: d.entryId,
|
|
13444
|
+
message: d.message
|
|
13445
|
+
}))
|
|
13446
|
+
}, null, 2) + "\n";
|
|
13447
|
+
}
|
|
13448
|
+
function renderCapabilityText(entries, diagnostics) {
|
|
13449
|
+
const lines = [
|
|
13450
|
+
"Mjolnir capability registry",
|
|
13451
|
+
"",
|
|
13452
|
+
"Maturity is derived from evidence and cannot be set here. A capability is",
|
|
13453
|
+
"shown at the level the machine can prove, or not shown at all.",
|
|
13454
|
+
""
|
|
13455
|
+
];
|
|
13456
|
+
for (const entry of entries) {
|
|
13457
|
+
lines.push(`${entry.id} [${MATURITY_SHORT[entry.maturity]}]`);
|
|
13458
|
+
lines.push(` kind ${entry.kind}`);
|
|
13459
|
+
lines.push(` owner ${entry.owner}`);
|
|
13460
|
+
if (entry.rules.length > 0) lines.push(` rules ${entry.rules.join(", ")}`);
|
|
13461
|
+
if (entry.adapter !== null) lines.push(` adapter ${entry.adapter}`);
|
|
13462
|
+
lines.push(` proof ${entry.proof.status}${entry.proof.artifact === null ? "" : ` (${entry.proof.artifact})`}`);
|
|
13463
|
+
if (entry.nextLevelGap !== null) {
|
|
13464
|
+
lines.push(` next level ${entry.nextLevelGap.target}`);
|
|
13465
|
+
for (const missing of entry.nextLevelGap.missing) lines.push(` - ${missing}`);
|
|
13466
|
+
}
|
|
13467
|
+
lines.push("");
|
|
13468
|
+
}
|
|
13469
|
+
if (diagnostics.length > 0) {
|
|
13470
|
+
lines.push(`${diagnostics.length} violation(s):`);
|
|
13471
|
+
for (const diagnostic of diagnostics) lines.push(` ${diagnostic.code} ${diagnostic.entryId}: ${diagnostic.message}`);
|
|
13472
|
+
} else lines.push("No violations: no capability is advertised above its proven level.");
|
|
13473
|
+
return lines.join("\n");
|
|
13474
|
+
}
|
|
13475
|
+
function runCapabilityCommand(argv, io = {
|
|
13476
|
+
out,
|
|
13477
|
+
err
|
|
13478
|
+
}) {
|
|
13479
|
+
const { query, json, unknown } = parseCapabilityArgs(argv);
|
|
13480
|
+
if (unknown.length > 0) {
|
|
13481
|
+
io.err(`capability: unknown argument ${unknown[0] ?? ""}\n`);
|
|
13482
|
+
io.err(" usage: mjolnir capability [--json] [--kind <k>] [--maturity M0..M5] [--id <substring>]\n");
|
|
13483
|
+
io.err(" note: there is deliberately no --set or --promote. Maturity is derived\n from evidence, so it cannot be edited here.\n");
|
|
13484
|
+
return 10;
|
|
13485
|
+
}
|
|
13486
|
+
const registry = buildCapabilityRegistry({ evidence: realCapabilityEvidence() });
|
|
13487
|
+
const diagnostics = validateRegistry(registry);
|
|
13488
|
+
const entries = selectCapabilities(registry.entries, query);
|
|
13489
|
+
io.out(json ? capabilityJson(entries, diagnostics) : `${renderCapabilityText(entries, diagnostics)}\n`);
|
|
13490
|
+
return diagnostics.length > 0 ? 1 : 0;
|
|
13491
|
+
}
|
|
13492
|
+
//#endregion
|
|
12146
13493
|
//#region src/commands/maturity.ts
|
|
12147
13494
|
/**
|
|
12148
13495
|
* `mjolnir maturity` — organizational QA-maturity signals.
|
|
@@ -12249,6 +13596,7 @@ function runMaturityCommand(argv, io) {
|
|
|
12249
13596
|
return 20;
|
|
12250
13597
|
}
|
|
12251
13598
|
}
|
|
13599
|
+
if (subcommand === "capabilities") return runCapabilityCommand(argv.slice(1), io);
|
|
12252
13600
|
io.err(`Unknown maturity subcommand: ${subcommand}`);
|
|
12253
13601
|
return 10;
|
|
12254
13602
|
}
|
|
@@ -12309,6 +13657,18 @@ const CLI_COMMAND_NAMES = [
|
|
|
12309
13657
|
const CLI_NON_SCAN_COMMANDS = new Set(CLI_COMMAND_NAMES.filter((command) => command !== "scan"));
|
|
12310
13658
|
//#endregion
|
|
12311
13659
|
//#region src/engine/cross-file.ts
|
|
13660
|
+
/**
|
|
13661
|
+
* Cross-file analysis (Upgrade-Plan-v3 Phase 6, tier-3 item #2).
|
|
13662
|
+
*
|
|
13663
|
+
* First cross-file signal: duplicate test names across files in the same
|
|
13664
|
+
* suite. A duplicated test name means two tests report under one label —
|
|
13665
|
+
* CI dashboards, flake trackers, and forensics all conflate them. This is
|
|
13666
|
+
* undetectable per-file by design (each file alone is consistent), which
|
|
13667
|
+
* is exactly why it belongs in a cross-file pass.
|
|
13668
|
+
*
|
|
13669
|
+
* Pure function over discovered test files: no I/O beyond what the caller
|
|
13670
|
+
* already read; deterministic output ordered by first occurrence.
|
|
13671
|
+
*/
|
|
12312
13672
|
const JS_TEST_NAME_RE = /\b(?:test|it)\s*\(\s*['"`]([^'"`]+)['"`]\s*,/g;
|
|
12313
13673
|
const PY_TEST_NAME_RE = /^def\s+(test_\w+)\s*\(/gm;
|
|
12314
13674
|
/** Collect declared test names from one file's text. */
|
|
@@ -12340,7 +13700,7 @@ function findDuplicateTestNames(files) {
|
|
|
12340
13700
|
name,
|
|
12341
13701
|
files: [...filesSet].sort()
|
|
12342
13702
|
});
|
|
12343
|
-
return out.sort((a, b) => a.name
|
|
13703
|
+
return out.sort((a, b) => compareCodePoints(a.name, b.name));
|
|
12344
13704
|
}
|
|
12345
13705
|
//#endregion
|
|
12346
13706
|
//#region src/engine/cross-file-analysis.ts
|
|
@@ -12349,7 +13709,7 @@ function findDuplicateTestNames(files) {
|
|
|
12349
13709
|
* Pure function — same input always produces same output.
|
|
12350
13710
|
*/
|
|
12351
13711
|
function analyzeCrossFileSignals(files, findings, root) {
|
|
12352
|
-
const orderedFiles = [...files].sort((a, b) => a.path
|
|
13712
|
+
const orderedFiles = [...files].sort((a, b) => compareCodePoints(a.path, b.path));
|
|
12353
13713
|
const duplicateTestNames = findDuplicateTestNames(orderedFiles).map((dup) => ({
|
|
12354
13714
|
type: "duplicate-test-name",
|
|
12355
13715
|
files: dup.files,
|
|
@@ -12402,7 +13762,7 @@ function detectSharedImports(files) {
|
|
|
12402
13762
|
files: [...paths].sort(),
|
|
12403
13763
|
message: `Module "${module}" imported by ${paths.size} files`
|
|
12404
13764
|
});
|
|
12405
|
-
return out.sort((a, b) => a.message
|
|
13765
|
+
return out.sort((a, b) => compareCodePoints(a.message, b.message));
|
|
12406
13766
|
}
|
|
12407
13767
|
function detectCircularDependencies(files) {
|
|
12408
13768
|
const importMap = /* @__PURE__ */ new Map();
|
|
@@ -14016,9 +15376,6 @@ function renderTrustTrend(trend) {
|
|
|
14016
15376
|
* be derivable from the scan result, and every derivation must
|
|
14017
15377
|
* be deterministic.
|
|
14018
15378
|
*/
|
|
14019
|
-
function isRecord$1(value) {
|
|
14020
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14021
|
-
}
|
|
14022
15379
|
function optionalDeepEqual(actual, expected) {
|
|
14023
15380
|
return actual === void 0 && expected === void 0 ? true : isDeepStrictEqual(actual, expected);
|
|
14024
15381
|
}
|
|
@@ -14031,7 +15388,7 @@ function verifyMachineContract(result, contract = buildMachineContract(result))
|
|
|
14031
15388
|
const violations = [];
|
|
14032
15389
|
const checks = [];
|
|
14033
15390
|
const expected = buildMachineContract(result);
|
|
14034
|
-
const contractRecord = isRecord
|
|
15391
|
+
const contractRecord = isRecord(contract) ? contract : Object.create(null);
|
|
14035
15392
|
const contractVersion = typeof contractRecord.contractVersion === "number" ? contractRecord.contractVersion : 0;
|
|
14036
15393
|
const versionMatches = contractVersion === 1;
|
|
14037
15394
|
if (!versionMatches) violations.push(`Unsupported contract version ${contractVersion}`);
|
|
@@ -14041,7 +15398,7 @@ function verifyMachineContract(result, contract = buildMachineContract(result))
|
|
|
14041
15398
|
detail: versionMatches ? `Contract version ${contractVersion} confirmed` : `Unsupported contract version ${contractVersion}`
|
|
14042
15399
|
});
|
|
14043
15400
|
const suppliedSummary = contractRecord.summary;
|
|
14044
|
-
const digestMatch = isRecord
|
|
15401
|
+
const digestMatch = isRecord(suppliedSummary) && suppliedSummary.digest === expected.summary.digest;
|
|
14045
15402
|
if (!digestMatch) violations.push("Machine contract digest mismatch");
|
|
14046
15403
|
checks.push({
|
|
14047
15404
|
name: "digest-integrity",
|
|
@@ -14136,7 +15493,7 @@ function verifyEvidenceIntegrity(result) {
|
|
|
14136
15493
|
}
|
|
14137
15494
|
function verifyCompleteness(result, completeness, expected) {
|
|
14138
15495
|
const violations = [];
|
|
14139
|
-
if (!isRecord
|
|
15496
|
+
if (!isRecord(completeness)) return ["Missing completeness"];
|
|
14140
15497
|
const supplied = completeness;
|
|
14141
15498
|
if (supplied.partial !== result.partial) violations.push("Partial flag mismatch");
|
|
14142
15499
|
if (!isDeepStrictEqual(supplied, expected)) violations.push("Completeness projection mismatch");
|
|
@@ -14180,6 +15537,13 @@ function renderContractVerification(result) {
|
|
|
14180
15537
|
* Deterministic sha256 fingerprint of the suppression set. Canonical
|
|
14181
15538
|
* JSON sorted by ruleId+files+reason+expires so order in the config
|
|
14182
15539
|
* file does not affect the hash.
|
|
15540
|
+
*
|
|
15541
|
+
* Code-unit order on the ruleId, not an ambient `localeCompare`: the sorted
|
|
15542
|
+
* array is serialized into the hash, so a locale-dependent collation would
|
|
15543
|
+
* make the SAME suppression file produce two different fingerprints on two
|
|
15544
|
+
* machines — and that fingerprint is what tells a reader their suppressions
|
|
15545
|
+
* changed. (Line 55 keeps a bare `.sort()`: that is `Array.prototype.sort`'s
|
|
15546
|
+
* own code-unit default, which is already locale-free.)
|
|
14183
15547
|
*/
|
|
14184
15548
|
function suppressionFingerprint(suppressions) {
|
|
14185
15549
|
const canonical = suppressions.map((s) => ({
|
|
@@ -14187,7 +15551,7 @@ function suppressionFingerprint(suppressions) {
|
|
|
14187
15551
|
files: [...s.files ?? []].sort(),
|
|
14188
15552
|
reason: s.reason,
|
|
14189
15553
|
...s.expires !== void 0 ? { expires: s.expires } : {}
|
|
14190
|
-
})).sort((a, b) => a.ruleId
|
|
15554
|
+
})).sort((a, b) => compareCodePoints(a.ruleId, b.ruleId));
|
|
14191
15555
|
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
14192
15556
|
}
|
|
14193
15557
|
/**
|
|
@@ -14349,9 +15713,6 @@ const GITLAB_RESERVED_KEYS = /* @__PURE__ */ new Set([
|
|
|
14349
15713
|
"variables",
|
|
14350
15714
|
"workflow"
|
|
14351
15715
|
]);
|
|
14352
|
-
function isRecord(value) {
|
|
14353
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14354
|
-
}
|
|
14355
15716
|
function conditionState(value) {
|
|
14356
15717
|
if (value === void 0 || value === null || value === true) return "active";
|
|
14357
15718
|
if (value === false) return "disabled";
|
|
@@ -15002,7 +16363,7 @@ async function runMilestoneScan(args, io, readSources, strict = false) {
|
|
|
15002
16363
|
readSkipped++;
|
|
15003
16364
|
}
|
|
15004
16365
|
}
|
|
15005
|
-
files.sort((left, right) => left.path
|
|
16366
|
+
files.sort((left, right) => compareCodePoints(left.path, right.path));
|
|
15006
16367
|
}
|
|
15007
16368
|
return {
|
|
15008
16369
|
result,
|
|
@@ -15043,7 +16404,7 @@ function loadPolicy(path) {
|
|
|
15043
16404
|
}
|
|
15044
16405
|
let record;
|
|
15045
16406
|
try {
|
|
15046
|
-
record = parseJsonFile(text, path, isRecord
|
|
16407
|
+
record = parseJsonFile(text, path, isRecord);
|
|
15047
16408
|
} catch (error) {
|
|
15048
16409
|
throw new ConfigValidationError(`invalid suppression policy: ${path}`, { cause: error });
|
|
15049
16410
|
}
|
|
@@ -15062,7 +16423,7 @@ function loadPolicy(path) {
|
|
|
15062
16423
|
};
|
|
15063
16424
|
}
|
|
15064
16425
|
function isTrustSnapshot(value) {
|
|
15065
|
-
return isRecord
|
|
16426
|
+
return isRecord(value) && typeof value.scanId === "string" && typeof value.timestamp === "string" && (typeof value.score === "number" || value.score === null) && typeof value.findings === "number" && typeof value.errors === "number" && typeof value.warnings === "number" && typeof value.infos === "number" && typeof value.advisory === "number" && typeof value.trustLevel === "string" && typeof value.confidence === "number" && typeof value.evidenceCoverage === "number" && typeof value.inconclusiveRate === "number" && typeof value.partial === "boolean" && typeof value.frameworkCount === "number" && typeof value.ruleCount === "number";
|
|
15066
16427
|
}
|
|
15067
16428
|
function loadTrustHistory(path) {
|
|
15068
16429
|
if (!existsSync(path)) return [];
|
|
@@ -15088,46 +16449,46 @@ function isStringArray(value) {
|
|
|
15088
16449
|
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
15089
16450
|
}
|
|
15090
16451
|
function isValidAnalysisStatus(value) {
|
|
15091
|
-
if (!isRecord
|
|
16452
|
+
if (!isRecord(value)) return false;
|
|
15092
16453
|
return (value.discovery === "complete" || value.discovery === "partial") && (value.rules === "complete" || value.rules === "partial") && isNonNegativeInteger(value.skippedFiles) && isFiniteNumber(value.durationMs) && value.durationMs >= 0 && (value.truncationReasons === void 0 || isStringArray(value.truncationReasons)) && (value.rulesCrashed === void 0 || isNonNegativeInteger(value.rulesCrashed));
|
|
15093
16454
|
}
|
|
15094
16455
|
function isValidDimension(value) {
|
|
15095
|
-
return isRecord
|
|
16456
|
+
return isRecord(value) && typeof value.category === "string" && isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100 && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos);
|
|
15096
16457
|
}
|
|
15097
16458
|
function isValidFinding(value) {
|
|
15098
|
-
if (!isRecord
|
|
16459
|
+
if (!isRecord(value)) return false;
|
|
15099
16460
|
return typeof value.ruleId === "string" && value.ruleId.length > 0 && typeof value.category === "string" && typeof value.file === "string" && value.file.length > 0 && isPositiveInteger(value.line) && isPositiveInteger(value.column) && typeof value.message === "string" && value.message.length > 0 && typeof value.severity === "string" && typeof value.confidence === "string" && typeof value.findingType === "string" && typeof value.qaImpact === "string" && typeof value.why === "string" && typeof value.fix === "string";
|
|
15100
16461
|
}
|
|
15101
16462
|
function isValidTrustSummary(value) {
|
|
15102
|
-
if (!isRecord
|
|
16463
|
+
if (!isRecord(value)) return false;
|
|
15103
16464
|
return TRUST_ORDER.includes(String(value.level)) && isFiniteNumber(value.confidence) && value.confidence >= 0 && value.confidence <= 1 && isFiniteNumber(value.evidenceCoverage) && value.evidenceCoverage >= 0 && value.evidenceCoverage <= 1 && isFiniteNumber(value.inconclusiveRate) && value.inconclusiveRate >= 0 && value.inconclusiveRate <= 1 && (value.measuredFpOfFiredRules === void 0 || isFiniteNumber(value.measuredFpOfFiredRules) && value.measuredFpOfFiredRules >= 0 && value.measuredFpOfFiredRules <= 1) && isStringArray(value.provisionalRuleIds) && (value.confidenceCeiling === void 0 || isFiniteNumber(value.confidenceCeiling) && value.confidenceCeiling >= 0 && value.confidenceCeiling <= 1) && isStringArray(value.ceilingReasons);
|
|
15104
16465
|
}
|
|
15105
16466
|
function isValidProvenance(value) {
|
|
15106
|
-
if (!isRecord
|
|
16467
|
+
if (!isRecord(value)) return false;
|
|
15107
16468
|
return isNonNegativeInteger(value.testFiles) && isNonNegativeInteger(value.generatedMarkedFiles) && isNonNegativeInteger(value.codegenLikeFiles) && isFiniteNumber(value.shareMarkedGenerated) && value.shareMarkedGenerated >= 0 && value.shareMarkedGenerated <= 1 && isNonNegativeInteger(value.findingsInGeneratedFiles) && isNonNegativeInteger(value.findingsInUnmarkedFiles) && typeof value.note === "string";
|
|
15108
16469
|
}
|
|
15109
16470
|
function isValidForensicVerdicts(value) {
|
|
15110
|
-
if (!isRecord
|
|
16471
|
+
if (!isRecord(value) || !isRecord(value.byVerdict)) return false;
|
|
15111
16472
|
return isNonNegativeInteger(value.classifications) && isNonNegativeInteger(value.inconclusive) && Object.values(value.byVerdict).every((count) => isNonNegativeInteger(count));
|
|
15112
16473
|
}
|
|
15113
16474
|
function isValidSummary(value) {
|
|
15114
|
-
if (!isRecord
|
|
16475
|
+
if (!isRecord(value)) return false;
|
|
15115
16476
|
return typeof value.digest === "string" && value.digest.length > 0 && isNonNegativeInteger(value.findings) && (value.score === null || isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100) && isNonNegativeInteger(value.errors) && isNonNegativeInteger(value.warnings) && isNonNegativeInteger(value.infos) && isNonNegativeInteger(value.advisory);
|
|
15116
16477
|
}
|
|
15117
16478
|
function isValidAnnotation(value) {
|
|
15118
|
-
if (!isRecord
|
|
16479
|
+
if (!isRecord(value)) return false;
|
|
15119
16480
|
return typeof value.path === "string" && value.path.length > 0 && isPositiveInteger(value.start_line) && (value.annotation_level === "failure" || value.annotation_level === "warning" || value.annotation_level === "notice") && typeof value.message === "string" && typeof value.ruleId === "string" && value.ruleId.length > 0 && (value.detectorRevision === void 0 || isPositiveInteger(value.detectorRevision)) && typeof value.advisory === "boolean";
|
|
15120
16481
|
}
|
|
15121
16482
|
function isValidCompleteness(value) {
|
|
15122
|
-
if (!isRecord
|
|
16483
|
+
if (!isRecord(value)) return false;
|
|
15123
16484
|
return typeof value.partial === "boolean" && (value.discovery === "complete" || value.discovery === "partial") && (value.rules === "complete" || value.rules === "partial") && isNonNegativeInteger(value.skippedFiles) && isNonNegativeInteger(value.rulesCrashed) && isStringArray(value.truncationReasons) && typeof value.frameworkDetectionUnknown === "boolean" && isFiniteNumber(value.durationMs) && value.durationMs >= 0;
|
|
15124
16485
|
}
|
|
15125
16486
|
function isValidContract(value) {
|
|
15126
|
-
if (!isRecord
|
|
16487
|
+
if (!isRecord(value)) return false;
|
|
15127
16488
|
return Number.isSafeInteger(value.contractVersion) && isValidSummary(value.summary) && Array.isArray(value.annotations) && value.annotations.length <= 50 && value.annotations.every((annotation) => isValidAnnotation(annotation)) && typeof value.annotationsTruncated === "boolean" && isValidCompleteness(value.completeness) && (value.trustSummary === void 0 || isValidTrustSummary(value.trustSummary)) && (value.provenance === void 0 || isValidProvenance(value.provenance)) && (value.forensicVerdicts === void 0 || isValidForensicVerdicts(value.forensicVerdicts));
|
|
15128
16489
|
}
|
|
15129
16490
|
function isScanResultDocument(value) {
|
|
15130
|
-
if (!isRecord
|
|
16491
|
+
if (!isRecord(value) || value.schemaVersion !== 1) return false;
|
|
15131
16492
|
return (value.score === null || isFiniteNumber(value.score) && value.score >= 0 && value.score <= 100) && typeof value.partial === "boolean" && isStringArray(value.frameworks) && Array.isArray(value.dimensions) && value.dimensions.every((dimension) => isValidDimension(dimension)) && Array.isArray(value.findings) && value.findings.every((finding) => isValidFinding(finding)) && isValidAnalysisStatus(value.analysisStatus) && (value.agenticProfile === void 0 || isValidProvenance(value.agenticProfile)) && (value.trustSummary === void 0 || isValidTrustSummary(value.trustSummary)) && (value.forensicVerdicts === void 0 || isValidForensicVerdicts(value.forensicVerdicts)) && isValidContract(value.contract);
|
|
15132
16493
|
}
|
|
15133
16494
|
function emitVerification(verification, json, io) {
|
|
@@ -15299,7 +16660,7 @@ async function runTrustTrendCommand(argv, io = {
|
|
|
15299
16660
|
const snapshot = computeTrustSnapshot(scan.result, scan.result.runIdentity, scan.result.trustSummary, args.recordedAt ?? (/* @__PURE__ */ new Date()).toISOString());
|
|
15300
16661
|
const merged = new Map(history.map((entry) => [`${entry.scanId}\u0000${entry.timestamp}`, entry]));
|
|
15301
16662
|
merged.set(`${snapshot.scanId}\u0000${snapshot.timestamp}`, snapshot);
|
|
15302
|
-
const snapshots = [...merged.values()].sort((left, right) => left.timestamp
|
|
16663
|
+
const snapshots = [...merged.values()].sort((left, right) => compareCodePoints(left.timestamp, right.timestamp) || compareCodePoints(left.scanId, right.scanId));
|
|
15303
16664
|
writeFileAtomic(historyPath, `${JSON.stringify(snapshots, null, 2)}\n`);
|
|
15304
16665
|
const trend = analyzeTrustTrends(snapshots);
|
|
15305
16666
|
if (args.json) io.out(JSON.stringify(trend, null, 2));
|
|
@@ -15366,8 +16727,8 @@ async function runCIIntegrityCommand(argv, io = {
|
|
|
15366
16727
|
//#region src/cli.ts
|
|
15367
16728
|
/**
|
|
15368
16729
|
* Mjölnir CLI entry point (W1-02).
|
|
15369
|
-
* Exit codes (§24.1, frozen): 0 clean
|
|
15370
|
-
* 10 usage error
|
|
16730
|
+
* Exit codes (§24.1, frozen): 0 clean · 1 findings ≥ gate · 2 partial ·
|
|
16731
|
+
* 10 usage error · 20 internal error.
|
|
15371
16732
|
*/
|
|
15372
16733
|
/**
|
|
15373
16734
|
* Tool version for `mjolnir --version`.
|
|
@@ -15530,7 +16891,7 @@ function levenshtein(a, b) {
|
|
|
15530
16891
|
};
|
|
15531
16892
|
return walk(0, 0);
|
|
15532
16893
|
}
|
|
15533
|
-
/** Nearest known flags within distance
|
|
16894
|
+
/** Nearest known flags within distance ≤ 2, nearest first. */
|
|
15534
16895
|
function nearestFlags(flag, max = 3) {
|
|
15535
16896
|
return KNOWN_SCAN_FLAGS.map((f) => ({
|
|
15536
16897
|
f,
|