@ecoma-io/archkeep 0.21.0 → 0.22.1
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/cli.mjs +156 -66
- package/gate-attestation.mjs +23 -0
- package/package.json +3 -1
- package/src/analysis/analyze.mjs +6 -0
- package/src/analysis/contract.md +32 -5
- package/src/analysis/csharp.mjs +18 -0
- package/src/analysis/go.mjs +18 -0
- package/src/analysis/java.mjs +15 -0
- package/src/analysis/kotlin.mjs +15 -0
- package/src/analysis/python.mjs +25 -3
- package/src/analysis/rust.mjs +18 -0
- package/src/analysis/source-util.mjs +113 -0
- package/src/analysis/typescript.mjs +86 -5
- package/src/canonical.mjs +43 -25
- package/src/commands/README.md +63 -12
- package/src/commands/change-intent.mjs +25 -1
- package/src/commands/change.mjs +90 -40
- package/src/commands/check.mjs +65 -26
- package/src/commands/completeness.mjs +126 -19
- package/src/commands/context-command.mjs +13 -5
- package/src/commands/context.mjs +31 -4
- package/src/commands/coverage-verdict.mjs +191 -0
- package/src/commands/debt.mjs +18 -15
- package/src/commands/delta-classify.mjs +13 -18
- package/src/commands/delta-snapshot.mjs +13 -5
- package/src/commands/delta.mjs +95 -33
- package/src/commands/diff.mjs +31 -24
- package/src/commands/discover.mjs +70 -29
- package/src/commands/drift.mjs +21 -21
- package/src/commands/edge-constraints.mjs +47 -1
- package/src/commands/evaluation-primitives.mjs +194 -2
- package/src/commands/evolution.mjs +27 -10
- package/src/commands/explain.mjs +14 -13
- package/src/commands/fitness.mjs +20 -19
- package/src/commands/graph.mjs +29 -11
- package/src/commands/health.mjs +12 -5
- package/src/commands/history.mjs +41 -26
- package/src/commands/impact.mjs +17 -18
- package/src/commands/plan-context-command.mjs +10 -5
- package/src/commands/reconcile.mjs +14 -17
- package/src/commands/scenario-evaluation.mjs +93 -16
- package/src/commands/scenario.mjs +28 -18
- package/src/commands/waivers.mjs +36 -28
- package/src/governance/evolution-event.mjs +96 -9
- package/src/intent/intent-manifest.json +83 -39
- package/src/lsp/diagnose.mjs +12 -3
- package/src/report/discover-text.mjs +31 -9
- package/src/report/graph-text.mjs +25 -5
- package/src/report/json.mjs +32 -5
- package/src/report/text.mjs +82 -12
- package/src/verdict.mjs +78 -36
- package/src/verify-gate-attestation.mjs +323 -0
- package/src/workspace.mjs +126 -2
package/src/commands/change.mjs
CHANGED
|
@@ -71,30 +71,37 @@
|
|
|
71
71
|
* change lives in `./change-intent.mjs`'s validation (`parseChangeIntent`),
|
|
72
72
|
* the same loud lane as every other malformed declaration.
|
|
73
73
|
*
|
|
74
|
-
* Refusals
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
74
|
+
* Refusals: incomplete head coverage returns the structured no-verdict
|
|
75
|
+
* envelope `./coverage-verdict.mjs` builds (#608) — status "no-verdict",
|
|
76
|
+
* exit 3, a `coverage` block naming every file and site the run could not
|
|
77
|
+
* judge — where a parser and `--output` can read it; the rest are throws →
|
|
78
|
+
* exit 3 upstream: a manifest that fails shape or reference validation, an
|
|
79
|
+
* unreadable/malformed/foreign-schema baseline, incomplete baseline coverage,
|
|
80
|
+
* a provider mismatch, an unregistered-plugin graph over polyglot manifests,
|
|
81
|
+
* and a run with no boundary law (constraints and the law fingerprint need
|
|
82
|
+
* one).
|
|
79
83
|
*
|
|
80
84
|
* This module computes and returns; `../../cli.mjs`'s `runChange` owns argv,
|
|
81
85
|
* output destination and the process exit code (`./README.md`).
|
|
82
86
|
*/
|
|
83
|
-
import { classifyDelta } from "./delta-classify.mjs";
|
|
87
|
+
import { classifyDelta, edgeEvolutionIdentity } from "./delta-classify.mjs";
|
|
84
88
|
import { computeDiff } from "./diff.mjs";
|
|
85
89
|
import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
|
|
86
90
|
import {
|
|
87
91
|
CONSTRAINT_ORDER,
|
|
88
92
|
CONSTRAINT_ROW_NAMES,
|
|
93
|
+
edgePairKey,
|
|
89
94
|
findChangeIntentReferenceViolations,
|
|
90
95
|
readChangeIntent,
|
|
91
96
|
} from "./change-intent.mjs";
|
|
92
97
|
import {
|
|
93
98
|
evidenceGraphToProjectGraph,
|
|
94
|
-
|
|
99
|
+
refusePluginGapHead,
|
|
95
100
|
sourceProjectAttributor,
|
|
96
101
|
} from "./delta.mjs";
|
|
97
102
|
import { providerMismatch, readEvidenceSnapshot } from "./delta-snapshot.mjs";
|
|
103
|
+
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
104
|
+
import { blindSpotRows } from "../analysis/source-util.mjs";
|
|
98
105
|
import { cyclicProjects } from "../governance/fitness-rules.mjs";
|
|
99
106
|
import { fitnessVerdict } from "../governance/verdict.mjs";
|
|
100
107
|
import { buildDecision } from "../report/evidence.mjs";
|
|
@@ -107,6 +114,7 @@ import { referenceTime } from "../governance/clock.mjs";
|
|
|
107
114
|
import {
|
|
108
115
|
classifyEvolution,
|
|
109
116
|
declarationDigest,
|
|
117
|
+
escapeIdentityField,
|
|
110
118
|
eventDedupeKey,
|
|
111
119
|
eventId,
|
|
112
120
|
EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
@@ -145,27 +153,26 @@ function cmpFacts(a, b) {
|
|
|
145
153
|
* The identity string a delta-classified violation entry carries into the
|
|
146
154
|
* event's `findings` — the same identity fields the delta classification
|
|
147
155
|
* emits (`messageId`, `sourceProject`, `target`), serialized deterministically
|
|
148
|
-
* so the ref is stable across runs over the same transition.
|
|
156
|
+
* so the ref is stable across runs over the same transition. Fields are
|
|
157
|
+
* escaped through `escapeIdentityField`, so a `:` inside a project name no
|
|
158
|
+
* longer reads as the field separator and two distinct entries never share
|
|
159
|
+
* one id (#628). The sentinel is the ONE literal written unescaped: it is
|
|
160
|
+
* this function's own spelling for "absent", so a source project literally
|
|
161
|
+
* named `-` escapes to `\-` instead of merging with it — while an absent
|
|
162
|
+
* source project still spells exactly what earlier versions spelled, byte
|
|
163
|
+
* for byte.
|
|
149
164
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
|
|
153
|
-
function violationFindingId(entry) {
|
|
154
|
-
return `${entry.messageId}:${entry.sourceProject ?? "-"}:${entry.target}`;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* The identity string an observed edge carries into the event's `observed`
|
|
159
|
-
* and `affected` — `(source, target, type)`, the triple `./diff.mjs`'s
|
|
160
|
-
* `edgeIdentityKey` owns, spelled for a human reader (`>` separator, optional
|
|
161
|
-
* type suffix). Two spellings of one triple never diverge because the triple
|
|
162
|
-
* itself is the input.
|
|
165
|
+
* Exported as the ONE spelling of this id — the same arrangement
|
|
166
|
+
* `edgeEvolutionIdentity` holds for edge identity — so a consumer composing
|
|
167
|
+
* the command layer cannot grow a second one that drifts.
|
|
163
168
|
*
|
|
164
|
-
* @param {{
|
|
169
|
+
* @param {{messageId: string, sourceProject: string|null, target: string}} entry
|
|
165
170
|
* @returns {string}
|
|
166
171
|
*/
|
|
167
|
-
function
|
|
168
|
-
|
|
172
|
+
export function violationFindingId(entry) {
|
|
173
|
+
const sourceProject =
|
|
174
|
+
entry.sourceProject == null ? "-" : escapeIdentityField(entry.sourceProject);
|
|
175
|
+
return `${escapeIdentityField(entry.messageId)}:${sourceProject}:${escapeIdentityField(entry.target)}`;
|
|
169
176
|
}
|
|
170
177
|
|
|
171
178
|
/**
|
|
@@ -173,6 +180,11 @@ function edgeIdentityString(edge) {
|
|
|
173
180
|
* mapped from `computeDiff`'s output and the metadata comparison — the same
|
|
174
181
|
* lists the reconciliation already consumed, never recomputed.
|
|
175
182
|
*
|
|
183
|
+
* The edges' identity strings come from `edgeEvolutionIdentity`
|
|
184
|
+
* (`./delta-classify.mjs`) — the ONE spelling the evolution events'
|
|
185
|
+
* `observed.edges`/`affected.boundaries` use. A local spelling here would be
|
|
186
|
+
* a second definition of "same edge", and two definitions drift.
|
|
187
|
+
*
|
|
176
188
|
* @param {{addedProjects: object[], removedProjects: object[],
|
|
177
189
|
* changedProjects: object[], addedEdges: object[], removedEdges: object[]}} structural
|
|
178
190
|
* @param {{policyChanged: boolean|null, policyOneSided: boolean,
|
|
@@ -189,8 +201,8 @@ function observedFrom(structural, meta) {
|
|
|
189
201
|
changed: structural.changedProjects.map((project) => project.name),
|
|
190
202
|
};
|
|
191
203
|
const edges = {
|
|
192
|
-
added: structural.addedEdges.map(
|
|
193
|
-
removed: structural.removedEdges.map(
|
|
204
|
+
added: structural.addedEdges.map(edgeEvolutionIdentity),
|
|
205
|
+
removed: structural.removedEdges.map(edgeEvolutionIdentity),
|
|
194
206
|
};
|
|
195
207
|
return {
|
|
196
208
|
architectureChanged:
|
|
@@ -240,9 +252,13 @@ function observedFrom(structural, meta) {
|
|
|
240
252
|
export function reconcileMaterialDelta(intent, delta) {
|
|
241
253
|
const addedProjectNames = new Set(intent.projects.add);
|
|
242
254
|
const removedProjectNames = new Set(intent.projects.remove);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
255
|
+
// The declared row's identity is `./change-intent.mjs`'s `edgePairKey` — the
|
|
256
|
+
// ONE spelling, shared with the grammar's own duplicate detection. A local
|
|
257
|
+
// spelling here was the live defect (#613): it agreed byte-for-byte until a
|
|
258
|
+
// shape moved, and the reconciliation would then stop recognizing the
|
|
259
|
+
// declarations it was handed while every command-local test stayed green.
|
|
260
|
+
const addEdgeKeys = new Set(intent.edges.add.map(edgePairKey));
|
|
261
|
+
const removeEdgeKeys = new Set(intent.edges.remove.map(edgePairKey));
|
|
246
262
|
|
|
247
263
|
/** @type {object[]} */
|
|
248
264
|
const matched = [];
|
|
@@ -266,7 +282,7 @@ export function reconcileMaterialDelta(intent, delta) {
|
|
|
266
282
|
to: edge.target,
|
|
267
283
|
type: edge.type,
|
|
268
284
|
});
|
|
269
|
-
if (addEdgeKeys.has(
|
|
285
|
+
if (addEdgeKeys.has(edgePairKey({ from: edge.source, to: edge.target }))) matched.push(row);
|
|
270
286
|
else unexpected.push(row);
|
|
271
287
|
}
|
|
272
288
|
for (const edge of delta.removedEdges) {
|
|
@@ -276,7 +292,7 @@ export function reconcileMaterialDelta(intent, delta) {
|
|
|
276
292
|
to: edge.target,
|
|
277
293
|
type: edge.type,
|
|
278
294
|
});
|
|
279
|
-
if (removeEdgeKeys.has(
|
|
295
|
+
if (removeEdgeKeys.has(edgePairKey({ from: edge.source, to: edge.target }))) matched.push(row);
|
|
280
296
|
else unexpected.push(row);
|
|
281
297
|
}
|
|
282
298
|
// No declaration surface in this version: a metadata change to a project
|
|
@@ -295,10 +311,10 @@ export function reconcileMaterialDelta(intent, delta) {
|
|
|
295
311
|
const observedAdded = new Set(delta.addedProjects.map((p) => p.name));
|
|
296
312
|
const observedRemoved = new Set(delta.removedProjects.map((p) => p.name));
|
|
297
313
|
const observedAddEdges = new Set(
|
|
298
|
-
delta.addedEdges.map((edge) =>
|
|
314
|
+
delta.addedEdges.map((edge) => edgePairKey({ from: edge.source, to: edge.target })),
|
|
299
315
|
);
|
|
300
316
|
const observedRemoveEdges = new Set(
|
|
301
|
-
delta.removedEdges.map((edge) =>
|
|
317
|
+
delta.removedEdges.map((edge) => edgePairKey({ from: edge.source, to: edge.target })),
|
|
302
318
|
);
|
|
303
319
|
|
|
304
320
|
// Declared but never observed — the unfulfilled half, kept apart from
|
|
@@ -316,12 +332,12 @@ export function reconcileMaterialDelta(intent, delta) {
|
|
|
316
332
|
}
|
|
317
333
|
}
|
|
318
334
|
for (const edge of intent.edges.add) {
|
|
319
|
-
if (!observedAddEdges.has(
|
|
335
|
+
if (!observedAddEdges.has(edgePairKey(edge))) {
|
|
320
336
|
missingExpected.push(factRow({ kind: "edge-added", from: edge.from, to: edge.to }));
|
|
321
337
|
}
|
|
322
338
|
}
|
|
323
339
|
for (const edge of intent.edges.remove) {
|
|
324
|
-
if (!observedRemoveEdges.has(
|
|
340
|
+
if (!observedRemoveEdges.has(edgePairKey(edge))) {
|
|
325
341
|
missingExpected.push(factRow({ kind: "edge-removed", from: edge.from, to: edge.to }));
|
|
326
342
|
}
|
|
327
343
|
}
|
|
@@ -490,8 +506,14 @@ function judgeDeclaredConstraints(intent, io) {
|
|
|
490
506
|
* change event's `debt` diff judges the intent over this run's base and
|
|
491
507
|
* head graphs and would be untestable without it.
|
|
492
508
|
* @returns {Promise<{status: "ok"|"findings"|"no-verdict",
|
|
493
|
-
* changeIntent
|
|
494
|
-
*
|
|
509
|
+
* changeIntent?: object, coverage: object, report: {text: string, json: string}}>}
|
|
510
|
+
* `status: "no-verdict"` from the coverage refusal (#608) carries no
|
|
511
|
+
* `changeIntent` payload — the reconciliation was withheld, and the
|
|
512
|
+
* envelope's `coverage` block plus its `decision.reason` are the whole
|
|
513
|
+
* answer.
|
|
514
|
+
* @throws {Error} on every refusal the module header lists. Incomplete head
|
|
515
|
+
* coverage returns the structured no-verdict envelope instead of throwing
|
|
516
|
+
* (#608); the unregistered-plugin graph keeps its throw.
|
|
495
517
|
*/
|
|
496
518
|
export async function changeCommand(
|
|
497
519
|
baselinePath,
|
|
@@ -530,7 +552,23 @@ export async function changeCommand(
|
|
|
530
552
|
);
|
|
531
553
|
}
|
|
532
554
|
|
|
533
|
-
|
|
555
|
+
// The plugin-gap refusal stays a throw; the coverage refusal returns the one
|
|
556
|
+
// structured envelope `./coverage-verdict.mjs` builds (#608) — status
|
|
557
|
+
// "no-verdict", exit 3, a `coverage` block naming every file and site the
|
|
558
|
+
// run could not judge — instead of the throw `refuseUnjudgeableHead` used to
|
|
559
|
+
// carry here. Reconciling a declaration over a half-read tree would answer
|
|
560
|
+
// "undeclared" about architecture the run never observed, and that refusal
|
|
561
|
+
// belongs in-band, where a parser and `--output` can read it.
|
|
562
|
+
refusePluginGapHead(commandContext, "reconcile a change intent");
|
|
563
|
+
const completeness = coverageVerdict(commandContext);
|
|
564
|
+
if (!completeness.complete) {
|
|
565
|
+
return coverageRefusal({
|
|
566
|
+
command: "change",
|
|
567
|
+
commandContext,
|
|
568
|
+
what: "reconciling a change intent",
|
|
569
|
+
decision: true,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
534
572
|
|
|
535
573
|
const intent = await (readIntent ? readIntent(intentPath) : readChangeIntent(intentPath));
|
|
536
574
|
|
|
@@ -718,7 +756,12 @@ export async function changeCommand(
|
|
|
718
756
|
analyzedFiles: analysis.analyzed,
|
|
719
757
|
imports: analysis.imports.length,
|
|
720
758
|
notAnalyzed: [],
|
|
721
|
-
|
|
759
|
+
// The positioned failures the run SAW — including the dynamic and external
|
|
760
|
+
// sites that never withhold a verdict. This used to be hardcoded `[]`
|
|
761
|
+
// (#609): a declared limit named nowhere is a disclosure gap, and
|
|
762
|
+
// `blindSpotRows` is the one mapping every other command's coverage block
|
|
763
|
+
// carries.
|
|
764
|
+
blindSpots: blindSpotRows(analysis.failures),
|
|
722
765
|
notes,
|
|
723
766
|
};
|
|
724
767
|
|
|
@@ -745,7 +788,14 @@ export async function changeCommand(
|
|
|
745
788
|
reason: entry.reason,
|
|
746
789
|
}));
|
|
747
790
|
const evolution = classifyEvolution({
|
|
748
|
-
observed
|
|
791
|
+
// The raw triples, not the mapped strings `observed` carries (that object
|
|
792
|
+
// is the event's stored record): `classifyEvolution` owns the identity
|
|
793
|
+
// spelling and takes the triples, so `affected.boundaries` is mapped
|
|
794
|
+
// inside it under the one spelling rather than trusted from the caller.
|
|
795
|
+
observed: {
|
|
796
|
+
...observed,
|
|
797
|
+
edges: { added: structural.addedEdges, removed: structural.removedEdges },
|
|
798
|
+
},
|
|
749
799
|
...(classification === null
|
|
750
800
|
? {}
|
|
751
801
|
: {
|
package/src/commands/check.mjs
CHANGED
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
import { statSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
blindSpotRows,
|
|
17
|
+
fileFailure,
|
|
18
|
+
isWholeFileFailure,
|
|
19
|
+
unresolvableLiteralCount,
|
|
20
|
+
} from "../analysis/source-util.mjs";
|
|
16
21
|
import { tsconfigPathsFacts } from "../analysis/typescript.mjs";
|
|
17
22
|
import { stripTrailingSlashes } from "../path-util.mjs";
|
|
18
23
|
import { suppressionCovers } from "../config.mjs";
|
|
@@ -188,7 +193,7 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
|
|
|
188
193
|
* intentUnresolved: number, intentUnresolvedDecisionRefs: number, fitnessFail: number,
|
|
189
194
|
* fitnessUnknown: number, customRuleFail: number, customRuleUnknown: number,
|
|
190
195
|
* customRuleEvidence: {rule: string, bytes: Uint8Array}[], customRulesDeclared: boolean,
|
|
191
|
-
* analyzed: number, unchecked: number, waived?: number}>}
|
|
196
|
+
* analyzed: number, unchecked: number, blindSpots: number, waived?: number}>}
|
|
192
197
|
*/
|
|
193
198
|
export async function check(options, { cwd, readGraph, listFiles = listTrackedFiles }) {
|
|
194
199
|
const commandContext = resolveCommandContext(
|
|
@@ -196,7 +201,7 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
196
201
|
{ readGraph, listFiles },
|
|
197
202
|
);
|
|
198
203
|
const { root, graph, workspace, tracked } = commandContext;
|
|
199
|
-
const { imports, exemptedFiles } = commandContext.analysis;
|
|
204
|
+
const { imports, exemptedFiles, unsupportedLanguageFiles } = commandContext.analysis;
|
|
200
205
|
const failures = [...commandContext.analysis.failures];
|
|
201
206
|
const analyzed = commandContext.analysis.analyzed;
|
|
202
207
|
|
|
@@ -622,6 +627,18 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
622
627
|
failures.filter(isWholeFileFailure).map((failure) => failure.sourceFile),
|
|
623
628
|
).size;
|
|
624
629
|
|
|
630
|
+
// Sites the run saw but never judged (#595): the file was analyzed, this
|
|
631
|
+
// site was not, and a pass over it would claim a verdict the run does not
|
|
632
|
+
// hold. Counted beside `unchecked` from one classifier
|
|
633
|
+
// (`unresolvableLiteralCount`) so the exit, the report and the envelope all
|
|
634
|
+
// agree from one number. The two classes that withholds nothing are
|
|
635
|
+
// excluded from the count: the declared dynamic limit — a non-literal
|
|
636
|
+
// `import()` argument, unknowable in principle — and the external
|
|
637
|
+
// bare-package site, whose resolvability depends on an installed dependency
|
|
638
|
+
// tree a workspace legitimately may not have (`isDynamicSiteFailure` and
|
|
639
|
+
// `isExternalSiteFailure` in source-util.mjs own the class line).
|
|
640
|
+
const blindSpotCount = unresolvableLiteralCount(failures);
|
|
641
|
+
|
|
625
642
|
// A row of the boundary law that covers nothing is a boundary that stopped
|
|
626
643
|
// being enforced, and — unlike a missing `reason`, which only a human can
|
|
627
644
|
// judge — it is machine-detectable. Two tables can be dead, and both are
|
|
@@ -838,8 +855,38 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
838
855
|
},
|
|
839
856
|
]
|
|
840
857
|
: []),
|
|
858
|
+
// Files a project owns that no analyzer claims (#601): skipped before
|
|
859
|
+
// reading, so they land in no failure list — without this row the run
|
|
860
|
+
// would present the judged surface as the whole story. Disclosure, not
|
|
861
|
+
// failure: the row names them and their extensions, and leaves
|
|
862
|
+
// `complete` to the surface that WAS judged. Sorted, because the row's
|
|
863
|
+
// bytes must not vary with `git ls-files`' order (E-F10).
|
|
864
|
+
...(unsupportedLanguageFiles.length > 0
|
|
865
|
+
? [{ kind: "unsupported-language", files: [...unsupportedLanguageFiles].sort() }]
|
|
866
|
+
: []),
|
|
841
867
|
];
|
|
842
868
|
|
|
869
|
+
// One verdict computation for both faces: the JSON envelope spreads it and
|
|
870
|
+
// the text report renders its `reasons` beside the headline — a second call
|
|
871
|
+
// here would let the two faces disagree about a run neither re-derives from
|
|
872
|
+
// the other (`../verdict.mjs`'s header owns that argument).
|
|
873
|
+
const verdict = verdictFor({
|
|
874
|
+
violations: violations.length,
|
|
875
|
+
declaredEdgeFindings,
|
|
876
|
+
goWorkDrift,
|
|
877
|
+
tsconfigPathsDead,
|
|
878
|
+
intentFindings,
|
|
879
|
+
intentUnresolved,
|
|
880
|
+
intentUnresolvedDecisionRefs: intentUnresolvedDecisionRefRows.length,
|
|
881
|
+
unchecked,
|
|
882
|
+
analyzed,
|
|
883
|
+
blindSpots: blindSpotCount,
|
|
884
|
+
fitnessFail,
|
|
885
|
+
fitnessUnknown,
|
|
886
|
+
customRuleFail,
|
|
887
|
+
customRuleUnknown,
|
|
888
|
+
});
|
|
889
|
+
|
|
843
890
|
const report =
|
|
844
891
|
options.format === "json"
|
|
845
892
|
? renderJson(
|
|
@@ -861,36 +908,20 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
861
908
|
// `decision` — the four-state verb of the same counts — so the
|
|
862
909
|
// envelope's `decision.verdict` and its `status` are built from
|
|
863
910
|
// exactly one computation and can never disagree.
|
|
864
|
-
...
|
|
865
|
-
violations: violations.length,
|
|
866
|
-
declaredEdgeFindings,
|
|
867
|
-
goWorkDrift,
|
|
868
|
-
tsconfigPathsDead,
|
|
869
|
-
intentFindings,
|
|
870
|
-
intentUnresolved,
|
|
871
|
-
intentUnresolvedDecisionRefs: intentUnresolvedDecisionRefRows.length,
|
|
872
|
-
unchecked,
|
|
873
|
-
fitnessFail,
|
|
874
|
-
fitnessUnknown,
|
|
875
|
-
customRuleFail,
|
|
876
|
-
customRuleUnknown,
|
|
877
|
-
}),
|
|
911
|
+
...verdict,
|
|
878
912
|
coverage: {
|
|
879
|
-
|
|
913
|
+
// Complete means the run judged everything in scope: no
|
|
914
|
+
// whole-file failure (unchecked), no unresolvable site (#595),
|
|
915
|
+
// and at least one file analyzed (#599 — a run that judged
|
|
916
|
+
// nothing has no verdict to claim).
|
|
917
|
+
complete: unchecked === 0 && blindSpotCount === 0 && analyzed > 0,
|
|
880
918
|
projects: Object.keys(graph.nodes).length,
|
|
881
919
|
analyzedFiles: analyzed,
|
|
882
920
|
imports: imports.length,
|
|
883
921
|
notAnalyzed: failures
|
|
884
922
|
.filter(isWholeFileFailure)
|
|
885
923
|
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason })),
|
|
886
|
-
blindSpots: failures
|
|
887
|
-
.filter((failure) => !isWholeFileFailure(failure))
|
|
888
|
-
.map(({ sourceFile, line, column, reason }) => ({
|
|
889
|
-
file: sourceFile,
|
|
890
|
-
line,
|
|
891
|
-
column,
|
|
892
|
-
reason,
|
|
893
|
-
})),
|
|
924
|
+
blindSpots: blindSpotRows(failures),
|
|
894
925
|
notes,
|
|
895
926
|
coverageGaps,
|
|
896
927
|
},
|
|
@@ -1020,6 +1051,10 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
1020
1051
|
// inspected" fact does (`../report/text.mjs`'s `formatReport`).
|
|
1021
1052
|
notes,
|
|
1022
1053
|
coverageGaps,
|
|
1054
|
+
// `formatReport` (text) renders these beside the headline; the SARIF
|
|
1055
|
+
// face files the same facts as warning notifications. Both faces of
|
|
1056
|
+
// one run name the same clauses, in the same order.
|
|
1057
|
+
coverageIncomplete: verdict.reasons,
|
|
1023
1058
|
// `formatReport` (text) reads this to annotate an unresolved
|
|
1024
1059
|
// decisionRef inline; `formatSarif` files each one as a warning
|
|
1025
1060
|
// notification. Both faces of one run name the same citations, in
|
|
@@ -1052,5 +1087,9 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
1052
1087
|
customRulesDeclared: customRules !== null,
|
|
1053
1088
|
analyzed,
|
|
1054
1089
|
unchecked,
|
|
1090
|
+
// The site-level count `verdictFor` needs: `cli.mjs` passes this whole
|
|
1091
|
+
// return through `verdictFor` for the process's exit code, so a count the
|
|
1092
|
+
// envelope saw but the exit code did not would let the two disagree.
|
|
1093
|
+
blindSpots: blindSpotCount,
|
|
1055
1094
|
};
|
|
1056
1095
|
}
|
|
@@ -45,6 +45,72 @@ export const NOT_EVALUATED = EVALUATION_STATUS.NOT_EVALUATED;
|
|
|
45
45
|
export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED;
|
|
46
46
|
export const REFUSED = EVALUATION_STATUS.REFUSED;
|
|
47
47
|
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Evaluation contract types — which gates are required per evaluation type
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The evaluation contract types that determine which Evidence-Complete gates
|
|
54
|
+
* are required for `overallComplete`.
|
|
55
|
+
*
|
|
56
|
+
* - `canonical`: Standard architecture evaluation (no mutations, no scenario).
|
|
57
|
+
* Gates NOT required: mutationCoverage, surfaceParity, baseIdentityValid.
|
|
58
|
+
* - `scenario`: Hypothetical scenario evaluation. ALL gates required.
|
|
59
|
+
*
|
|
60
|
+
* @type {Readonly<{CANONICAL: string, SCENARIO: string}>}
|
|
61
|
+
*/
|
|
62
|
+
export const EVALUATION_CONTRACT_TYPES = Object.freeze({
|
|
63
|
+
CANONICAL: "canonical",
|
|
64
|
+
SCENARIO: "scenario",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Which Evidence-Complete gates are required for each contract type.
|
|
69
|
+
* A gate not listed here is still tracked and reported but does NOT block
|
|
70
|
+
* `overallComplete` — it is explicitly not applicable for that evaluation type.
|
|
71
|
+
*
|
|
72
|
+
* @type {Readonly<Object<string, ReadonlySet<string>>>}
|
|
73
|
+
*/
|
|
74
|
+
export const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
|
|
75
|
+
[EVALUATION_CONTRACT_TYPES.CANONICAL]: Object.freeze(
|
|
76
|
+
new Set([
|
|
77
|
+
"domainCoverage",
|
|
78
|
+
"claimEvidenceCoverage",
|
|
79
|
+
"causalCoverage",
|
|
80
|
+
"provenanceCoverage",
|
|
81
|
+
"hiddenGapCount",
|
|
82
|
+
"falseCompleteCount",
|
|
83
|
+
"deterministic",
|
|
84
|
+
]),
|
|
85
|
+
),
|
|
86
|
+
[EVALUATION_CONTRACT_TYPES.SCENARIO]: Object.freeze(
|
|
87
|
+
new Set([
|
|
88
|
+
"domainCoverage",
|
|
89
|
+
"claimEvidenceCoverage",
|
|
90
|
+
"causalCoverage",
|
|
91
|
+
"provenanceCoverage",
|
|
92
|
+
"mutationCoverage",
|
|
93
|
+
"surfaceParity",
|
|
94
|
+
"hiddenGapCount",
|
|
95
|
+
"falseCompleteCount",
|
|
96
|
+
"baseIdentityValid",
|
|
97
|
+
"deterministic",
|
|
98
|
+
]),
|
|
99
|
+
),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Returns true when the given gate key is required for the given contract type.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} gateKey The gate key (e.g. "domainCoverage").
|
|
106
|
+
* @param {string} [contractType] The evaluation contract type.
|
|
107
|
+
* Defaults to SCENARIO (most restrictive).
|
|
108
|
+
* @returns {boolean}
|
|
109
|
+
*/
|
|
110
|
+
export function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES.SCENARIO) {
|
|
111
|
+
const required = REQUIRED_GATES_FOR_CONTRACT[contractType];
|
|
112
|
+
return required ? required.has(gateKey) : true;
|
|
113
|
+
}
|
|
48
114
|
// ---------------------------------------------------------------------------
|
|
49
115
|
// Evidence-Complete gate names — the canonical roster
|
|
50
116
|
// ---------------------------------------------------------------------------
|
|
@@ -69,6 +135,7 @@ export const EVIDENCE_COMPLETE_GATES = Object.freeze([
|
|
|
69
135
|
|
|
70
136
|
/**
|
|
71
137
|
* @typedef {object} EvidenceCompleteContract
|
|
138
|
+
* @property {string} contractType The evaluation contract type (canonical | scenario).
|
|
72
139
|
* @property {number} domainCoverage Ratio of evaluated required domains to required domains (0-1).
|
|
73
140
|
* @property {number} claimEvidenceCoverage Ratio of claims with valid evidence to material claims (0-1).
|
|
74
141
|
* @property {number} causalCoverage Ratio of consequences with complete causal chain to material consequences (0-1).
|
|
@@ -82,16 +149,16 @@ export const EVIDENCE_COMPLETE_GATES = Object.freeze([
|
|
|
82
149
|
* @property {string} overallStatus Overall Evidence-Complete status: "complete" | "incomplete" | "not_evaluated".
|
|
83
150
|
* @property {boolean} overallComplete True only when ALL required gates pass.
|
|
84
151
|
* @property {object} gates Individual gate statuses, keyed by gate name.
|
|
85
|
-
* @property {object} gates.domainCoverage Gate status.
|
|
86
|
-
* @property {object} gates.claimEvidenceCoverage Gate status.
|
|
87
|
-
* @property {object} gates.causalCoverage Gate status.
|
|
88
|
-
* @property {object} gates.provenanceCoverage Gate status.
|
|
89
|
-
* @property {object} gates.mutationCoverage Gate status.
|
|
90
|
-
* @property {object} gates.surfaceParity Gate status.
|
|
91
|
-
* @property {object} gates.hiddenGapCount Gate status.
|
|
92
|
-
* @property {object} gates.falseCompleteCount Gate status.
|
|
93
|
-
* @property {object} gates.baseIdentityValid Gate status.
|
|
94
|
-
* @property {object} gates.deterministic Gate status.
|
|
152
|
+
* @property {object} gates.domainCoverage Gate status with {value, pass, required}.
|
|
153
|
+
* @property {object} gates.claimEvidenceCoverage Gate status with {value, pass, required}.
|
|
154
|
+
* @property {object} gates.causalCoverage Gate status with {value, pass, required}.
|
|
155
|
+
* @property {object} gates.provenanceCoverage Gate status with {value, pass, required}.
|
|
156
|
+
* @property {object} gates.mutationCoverage Gate status with {value, pass, required}.
|
|
157
|
+
* @property {object} gates.surfaceParity Gate status with {value, pass, required}.
|
|
158
|
+
* @property {object} gates.hiddenGapCount Gate status with {value, pass, required}.
|
|
159
|
+
* @property {object} gates.falseCompleteCount Gate status with {value, pass, required}.
|
|
160
|
+
* @property {object} gates.baseIdentityValid Gate status with {value, pass, required}.
|
|
161
|
+
* @property {object} gates.deterministic Gate status with {value, pass, required}.
|
|
95
162
|
*/
|
|
96
163
|
|
|
97
164
|
// ---------------------------------------------------------------------------
|
|
@@ -306,8 +373,10 @@ export function buildCompleteness({
|
|
|
306
373
|
const statuses = Object.values(domains).map((d) => d.status);
|
|
307
374
|
const domainOverallComplete = statuses.every((s) => s === EVALUATION_STATUS.EVALUATED);
|
|
308
375
|
|
|
309
|
-
// If an Evidence-Complete contract is provided, enforce it as a gate
|
|
310
|
-
|
|
376
|
+
// If an Evidence-Complete contract is provided, enforce it as a gate.
|
|
377
|
+
// When no contract is provided, overallComplete MUST be false — the
|
|
378
|
+
// evaluation has not proven its evidence gates.
|
|
379
|
+
let ecComplete = false;
|
|
311
380
|
let falseCompleteCount = 0;
|
|
312
381
|
if (evidenceComplete) {
|
|
313
382
|
ecComplete = evidenceComplete.overallComplete;
|
|
@@ -346,6 +415,10 @@ export function buildCompleteness({
|
|
|
346
415
|
/**
|
|
347
416
|
* Builds an Evidence-Complete contract from the individual gate values.
|
|
348
417
|
*
|
|
418
|
+
* Only gates required for the given `contractType` are considered for
|
|
419
|
+
* `overallComplete`. Gates not required are still tracked and reported
|
|
420
|
+
* but do NOT block completeness.
|
|
421
|
+
*
|
|
349
422
|
* @param {object} gates
|
|
350
423
|
* @param {number} [gates.domainCoverage] Ratio (0-1).
|
|
351
424
|
* @param {number} [gates.claimEvidenceCoverage] Ratio (0-1).
|
|
@@ -357,6 +430,8 @@ export function buildCompleteness({
|
|
|
357
430
|
* @param {number} [gates.falseCompleteCount] Count (0 = pass).
|
|
358
431
|
* @param {boolean} [gates.baseIdentityValid] Boolean (true = pass).
|
|
359
432
|
* @param {boolean} [gates.deterministic] Boolean (true = pass).
|
|
433
|
+
* @param {string} [gates.contractType] Evaluation contract type for gate
|
|
434
|
+
* requirements (defaults to SCENARIO, the most restrictive).
|
|
360
435
|
* @returns {EvidenceCompleteContract}
|
|
361
436
|
*/
|
|
362
437
|
export function buildEvidenceComplete({
|
|
@@ -370,8 +445,9 @@ export function buildEvidenceComplete({
|
|
|
370
445
|
falseCompleteCount = -1,
|
|
371
446
|
baseIdentityValid = false,
|
|
372
447
|
deterministic = false,
|
|
448
|
+
contractType = EVALUATION_CONTRACT_TYPES.SCENARIO,
|
|
373
449
|
} = {}) {
|
|
374
|
-
const
|
|
450
|
+
const rawGates = {
|
|
375
451
|
domainCoverage: { value: domainCoverage, pass: domainCoverage === 1 },
|
|
376
452
|
claimEvidenceCoverage: { value: claimEvidenceCoverage, pass: claimEvidenceCoverage === 1 },
|
|
377
453
|
causalCoverage: { value: causalCoverage, pass: causalCoverage === 1 },
|
|
@@ -384,9 +460,21 @@ export function buildEvidenceComplete({
|
|
|
384
460
|
deterministic: { value: deterministic, pass: deterministic === true },
|
|
385
461
|
};
|
|
386
462
|
|
|
387
|
-
|
|
463
|
+
// Only required gates block overallComplete
|
|
464
|
+
const allRequiredPass = Object.keys(rawGates)
|
|
465
|
+
.filter((key) => isGateRequired(key, contractType))
|
|
466
|
+
.every((key) => rawGates[key].pass);
|
|
467
|
+
// Annotate each gate with whether it is required for this contract type
|
|
468
|
+
/** @type {any} */
|
|
469
|
+
const gates = Object.fromEntries(
|
|
470
|
+
Object.entries(rawGates).map(([key, gate]) => [
|
|
471
|
+
key,
|
|
472
|
+
{ ...gate, required: isGateRequired(key, contractType) },
|
|
473
|
+
]),
|
|
474
|
+
);
|
|
388
475
|
|
|
389
476
|
return {
|
|
477
|
+
contractType,
|
|
390
478
|
domainCoverage,
|
|
391
479
|
claimEvidenceCoverage,
|
|
392
480
|
causalCoverage,
|
|
@@ -397,8 +485,8 @@ export function buildEvidenceComplete({
|
|
|
397
485
|
falseCompleteCount,
|
|
398
486
|
baseIdentityValid,
|
|
399
487
|
deterministic,
|
|
400
|
-
overallStatus:
|
|
401
|
-
overallComplete:
|
|
488
|
+
overallStatus: allRequiredPass ? "complete" : "incomplete",
|
|
489
|
+
overallComplete: allRequiredPass,
|
|
402
490
|
gates,
|
|
403
491
|
};
|
|
404
492
|
}
|
|
@@ -414,16 +502,22 @@ export function buildEvidenceComplete({
|
|
|
414
502
|
export function assertEvidenceComplete(ec) {
|
|
415
503
|
if (ec.overallComplete) return;
|
|
416
504
|
|
|
505
|
+
const contractType = ec.contractType || EVALUATION_CONTRACT_TYPES.SCENARIO;
|
|
417
506
|
const failures = [];
|
|
418
507
|
for (const gate of EVIDENCE_COMPLETE_GATES) {
|
|
419
508
|
const g = ec.gates[gate.key];
|
|
509
|
+
// Skip non-required gates for this contract type
|
|
510
|
+
if (g.required === false) continue;
|
|
420
511
|
if (!g.pass) {
|
|
421
512
|
failures.push(`${gate.label}: ${JSON.stringify(g.value)} (expected pass)`);
|
|
422
513
|
}
|
|
423
514
|
}
|
|
424
515
|
|
|
516
|
+
if (failures.length === 0) return;
|
|
517
|
+
|
|
425
518
|
throw new Error(
|
|
426
519
|
`Evidence-Complete contract not satisfied.\n` +
|
|
520
|
+
` Contract type: ${contractType}\n` +
|
|
427
521
|
` Overall: ${ec.overallStatus}\n` +
|
|
428
522
|
` Failed gates:\n ${failures.join("\n ")}`,
|
|
429
523
|
);
|
|
@@ -571,15 +665,28 @@ export function buildScenarioCompleteness({
|
|
|
571
665
|
evidenceComplete,
|
|
572
666
|
});
|
|
573
667
|
|
|
574
|
-
// Recompute overall
|
|
668
|
+
// Recompute overall: base domains + EC gate + scenario domains.
|
|
669
|
+
// baseResult.overallComplete already includes the evidenceComplete gate,
|
|
670
|
+
// so reusing it prevents the silent-complete defect.
|
|
671
|
+
const overallComplete =
|
|
672
|
+
baseResult.overallComplete &&
|
|
673
|
+
changesDomain.status === EVALUATION_STATUS.EVALUATED &&
|
|
674
|
+
baseDomain.status === EVALUATION_STATUS.EVALUATED &&
|
|
675
|
+
mutationDomain.status === EVALUATION_STATUS.EVALUATED;
|
|
575
676
|
const allStatuses = [
|
|
576
677
|
...Object.values(baseResult.domains).map((d) => d.status),
|
|
577
678
|
changesDomain.status,
|
|
578
679
|
baseDomain.status,
|
|
579
680
|
mutationDomain.status,
|
|
580
681
|
];
|
|
581
|
-
|
|
582
|
-
|
|
682
|
+
// Include EC gate status in overall status: when evidenceComplete is
|
|
683
|
+
// provided and fails, overall status must reflect that.
|
|
684
|
+
const ecStatus = evidenceComplete
|
|
685
|
+
? evidenceComplete.overallComplete
|
|
686
|
+
? EVALUATION_STATUS.EVALUATED
|
|
687
|
+
: EVALUATION_STATUS.NOT_EVALUATED
|
|
688
|
+
: EVALUATION_STATUS.NOT_EVALUATED;
|
|
689
|
+
const overallStatus = worstStatus(...allStatuses, ecStatus);
|
|
583
690
|
|
|
584
691
|
return {
|
|
585
692
|
domains: baseResult.domains,
|