@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
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
11
|
import { readAdrContext } from "./adr.mjs";
|
|
12
|
+
import { edgeEvolutionIdentity } from "../governance/evolution-event.mjs";
|
|
12
13
|
import { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
|
|
13
14
|
import { isComboDepConstraint } from "../rules/tags.mjs";
|
|
14
15
|
import { computeDecisionProvenance } from "../governance/provenance-graph.mjs";
|
|
@@ -16,9 +17,13 @@ import { resolveFileAttribution } from "./provenance.mjs";
|
|
|
16
17
|
|
|
17
18
|
import {
|
|
18
19
|
buildCompleteness,
|
|
20
|
+
buildEvidenceComplete,
|
|
19
21
|
buildGovernanceCompleteness,
|
|
20
22
|
evaluationStatus,
|
|
21
23
|
EVALUATION_STATUS,
|
|
24
|
+
EVALUATION_CONTRACT_TYPES,
|
|
25
|
+
computeDomainCoverage,
|
|
26
|
+
REQUIRED_DOMAINS,
|
|
22
27
|
} from "./completeness.mjs";
|
|
23
28
|
import { computeImpact } from "./impact.mjs";
|
|
24
29
|
import { computeImpactConstraints } from "./edge-constraints.mjs";
|
|
@@ -284,6 +289,116 @@ export function evaluateBoundaryImpact(graph, constraintImpact, targetProject) {
|
|
|
284
289
|
// Canonical Architecture Evaluation
|
|
285
290
|
// ---------------------------------------------------------------------------
|
|
286
291
|
|
|
292
|
+
/**
|
|
293
|
+
* The provenance coverage of a decision set: the fraction of rows that
|
|
294
|
+
* resolved to a known record with authority.
|
|
295
|
+
*
|
|
296
|
+
* An ADR row counts when its record carries decision authority
|
|
297
|
+
* (`hasAuthority` — `accepted`/`active`); a fitness row counts when it
|
|
298
|
+
* resolved to an id the loaded policy binds (`resolution === "known"`).
|
|
299
|
+
* Refs that resolve to nothing are reported by `unresolvedDecisionRefs`
|
|
300
|
+
* and never become rows; a record without authority emits a row the gate
|
|
301
|
+
* fails on loudly rather than counting. One derivation, one home —
|
|
302
|
+
* `deriveEvidenceGates` and the scenario face both read it, so the two
|
|
303
|
+
* callers cannot disagree about what a covered decision is.
|
|
304
|
+
*
|
|
305
|
+
* @param {object[]|null|undefined} decisions Decision rows from
|
|
306
|
+
* `buildDecisionImpact`.
|
|
307
|
+
* @returns {number} A ratio in [0, 1]; 0 when no decision binds an
|
|
308
|
+
* affected row.
|
|
309
|
+
*/
|
|
310
|
+
export function decisionProvenanceCoverage(decisions) {
|
|
311
|
+
const rows = decisions ?? [];
|
|
312
|
+
if (rows.length === 0) return 0;
|
|
313
|
+
const covered = rows.filter((row) =>
|
|
314
|
+
row.kind === "adr" ? row.hasAuthority === true : row.resolution === "known",
|
|
315
|
+
).length;
|
|
316
|
+
return covered / rows.length;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Derives evidence gates from evaluation outputs for the canonical evaluator.
|
|
321
|
+
*
|
|
322
|
+
* Each gate is computed from actual evaluation data rather than being
|
|
323
|
+
* caller-supplied. Gates not applicable to canonical evaluation
|
|
324
|
+
* (mutationCoverage, surfaceParity, baseIdentityValid) are set to 0/false
|
|
325
|
+
* and excluded via EVALUATION_CONTRACT_TYPES.CANONICAL.
|
|
326
|
+
*
|
|
327
|
+
* @param {object} evaluation The full evaluation result.
|
|
328
|
+
* @param {object} evaluation.completeness The completeness result.
|
|
329
|
+
* @param {object} evaluation.impact The structural impact.
|
|
330
|
+
* @param {object|null} evaluation.constraintImpact Constraint impact.
|
|
331
|
+
* @param {object|null} evaluation.decisionImpact Decision impact.
|
|
332
|
+
* @param {object} evaluation.boundaryImpact Boundary impact.
|
|
333
|
+
* @param {object} evaluation.findingsImpact Findings impact.
|
|
334
|
+
* @param {object} evaluation.debtImpact Debt impact.
|
|
335
|
+
* @param {object} evaluation.evolutionAlignment Evolution alignment.
|
|
336
|
+
* @returns {object} Evidence gate values for buildEvidenceComplete.
|
|
337
|
+
*/
|
|
338
|
+
export function deriveEvidenceGates(evaluation) {
|
|
339
|
+
const { completeness, constraintImpact, decisionImpact } = evaluation;
|
|
340
|
+
|
|
341
|
+
// domainCoverage: ratio of evaluated required domains
|
|
342
|
+
/** @type {{ [domain: string]: string }} */
|
|
343
|
+
const domainStatuses = {};
|
|
344
|
+
for (const domain of REQUIRED_DOMAINS) {
|
|
345
|
+
const dom = completeness.domains[domain];
|
|
346
|
+
domainStatuses[domain] = dom ? dom.status : EVALUATION_STATUS.NOT_EVALUATED;
|
|
347
|
+
}
|
|
348
|
+
const dc = computeDomainCoverage(domainStatuses);
|
|
349
|
+
const domainCoverage = dc.coverage;
|
|
350
|
+
|
|
351
|
+
// claimEvidenceCoverage: structural always produces claims with evidence.
|
|
352
|
+
// Constraint/decision claims exist when config is present.
|
|
353
|
+
const structuralClaimEvidence = 1; // structural always produces evidence
|
|
354
|
+
const constraintClaimEvidence = constraintImpact !== null ? 1 : 0;
|
|
355
|
+
const decisionClaimEvidence = decisionImpact !== null ? 1 : 0;
|
|
356
|
+
const totalClaims = 3; // structural + constraint + decision
|
|
357
|
+
const evidencedClaims = structuralClaimEvidence + constraintClaimEvidence + decisionClaimEvidence;
|
|
358
|
+
const claimEvidenceCoverage = totalClaims > 0 ? evidencedClaims / totalClaims : 0;
|
|
359
|
+
|
|
360
|
+
// causalCoverage: constraint consequences with complete causal chains
|
|
361
|
+
// When constraint impact is present, all constraint edges are traced.
|
|
362
|
+
// When absent, causal coverage is 0 (no constraints to trace).
|
|
363
|
+
const causalCoverage = constraintImpact !== null ? 1 : 0;
|
|
364
|
+
|
|
365
|
+
// provenanceCoverage: the fraction of decision rows that resolved to a
|
|
366
|
+
// known record with authority — derived from facts the rows carry, never
|
|
367
|
+
// from a property the row builder never wrote.
|
|
368
|
+
const provenanceCoverage = decisionProvenanceCoverage(decisionImpact?.decisions);
|
|
369
|
+
|
|
370
|
+
// mutationCoverage: not applicable for canonical evaluation
|
|
371
|
+
// surfaceParity: not applicable for canonical evaluation
|
|
372
|
+
|
|
373
|
+
// hiddenGapCount: NOT_EVALUATED domains without a note
|
|
374
|
+
let hiddenGapCount = 0;
|
|
375
|
+
for (const [, domain] of Object.entries(completeness.domains)) {
|
|
376
|
+
if (domain.status === EVALUATION_STATUS.NOT_EVALUATED && !domain.note) {
|
|
377
|
+
hiddenGapCount++;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// falseCompleteCount: detect when domains pass but evidence gates fail
|
|
382
|
+
// This is computed by buildCompleteness from the evidenceComplete contract.
|
|
383
|
+
|
|
384
|
+
// baseIdentityValid: not applicable for canonical evaluation
|
|
385
|
+
|
|
386
|
+
// deterministic: canonical evaluator is deterministic by construction
|
|
387
|
+
const deterministic = true;
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
domainCoverage,
|
|
391
|
+
claimEvidenceCoverage,
|
|
392
|
+
causalCoverage,
|
|
393
|
+
provenanceCoverage,
|
|
394
|
+
mutationCoverage: 0,
|
|
395
|
+
surfaceParity: 0,
|
|
396
|
+
hiddenGapCount,
|
|
397
|
+
falseCompleteCount: 0,
|
|
398
|
+
baseIdentityValid: false,
|
|
399
|
+
deterministic,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
287
402
|
/**
|
|
288
403
|
* Evaluates the complete architecture state for a target project.
|
|
289
404
|
*
|
|
@@ -435,6 +550,76 @@ export function evaluateArchitectureState({
|
|
|
435
550
|
},
|
|
436
551
|
});
|
|
437
552
|
|
|
553
|
+
// Derive evidence gates and build Evidence-Complete contract
|
|
554
|
+
const evaluationResult = {
|
|
555
|
+
completeness,
|
|
556
|
+
impact,
|
|
557
|
+
constraintImpact,
|
|
558
|
+
decisionImpact,
|
|
559
|
+
boundaryImpact,
|
|
560
|
+
findingsImpact,
|
|
561
|
+
debtImpact,
|
|
562
|
+
evolutionAlignment,
|
|
563
|
+
};
|
|
564
|
+
const evidenceGates = deriveEvidenceGates(evaluationResult);
|
|
565
|
+
const evidenceComplete = buildEvidenceComplete({
|
|
566
|
+
...evidenceGates,
|
|
567
|
+
contractType: EVALUATION_CONTRACT_TYPES.CANONICAL,
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// Rebuild completeness with evidenceComplete contract
|
|
571
|
+
const completenessWithEC = buildCompleteness({
|
|
572
|
+
structural: {
|
|
573
|
+
status: structuralStatus,
|
|
574
|
+
evaluated: true,
|
|
575
|
+
partial: false,
|
|
576
|
+
notEvaluated: false,
|
|
577
|
+
unsupported: false,
|
|
578
|
+
refused: false,
|
|
579
|
+
note: "",
|
|
580
|
+
},
|
|
581
|
+
constraint: {
|
|
582
|
+
status: constraintStatus,
|
|
583
|
+
evaluated: hasConfig && config.depConstraints !== undefined,
|
|
584
|
+
partial: false,
|
|
585
|
+
notEvaluated: !hasConfig || config.depConstraints === undefined,
|
|
586
|
+
unsupported: false,
|
|
587
|
+
refused: false,
|
|
588
|
+
note: "",
|
|
589
|
+
},
|
|
590
|
+
boundary: {
|
|
591
|
+
status: boundaryStatus,
|
|
592
|
+
evaluated: hasConfig,
|
|
593
|
+
partial: false,
|
|
594
|
+
notEvaluated: !hasConfig,
|
|
595
|
+
unsupported: false,
|
|
596
|
+
refused: false,
|
|
597
|
+
note: "",
|
|
598
|
+
},
|
|
599
|
+
decision: {
|
|
600
|
+
status: decisionStatus,
|
|
601
|
+
evaluated: hasConfig,
|
|
602
|
+
partial: false,
|
|
603
|
+
notEvaluated: !hasConfig,
|
|
604
|
+
unsupported: false,
|
|
605
|
+
refused: false,
|
|
606
|
+
note: "",
|
|
607
|
+
},
|
|
608
|
+
findings: governanceResult.findings,
|
|
609
|
+
debt: governanceResult.debt,
|
|
610
|
+
governance: governanceResult.domain,
|
|
611
|
+
evidence: {
|
|
612
|
+
status: evidenceStatus,
|
|
613
|
+
evaluated: evidenceEvaluated,
|
|
614
|
+
partial: false,
|
|
615
|
+
notEvaluated: false,
|
|
616
|
+
unsupported: false,
|
|
617
|
+
refused: false,
|
|
618
|
+
note: "",
|
|
619
|
+
},
|
|
620
|
+
evidenceComplete,
|
|
621
|
+
});
|
|
622
|
+
|
|
438
623
|
return {
|
|
439
624
|
project: projectName,
|
|
440
625
|
impact,
|
|
@@ -444,8 +629,9 @@ export function evaluateArchitectureState({
|
|
|
444
629
|
boundaryImpact,
|
|
445
630
|
findingsImpact,
|
|
446
631
|
debtImpact,
|
|
447
|
-
completeness,
|
|
632
|
+
completeness: completenessWithEC,
|
|
448
633
|
affectedProjects,
|
|
634
|
+
evidenceComplete,
|
|
449
635
|
};
|
|
450
636
|
}
|
|
451
637
|
|
|
@@ -473,7 +659,13 @@ export function buildEvolutionAlignment(projectName, impact, constraintImpact, r
|
|
|
473
659
|
for (const entry of constraintImpact) {
|
|
474
660
|
// Collect edge identities for each affected boundary
|
|
475
661
|
for (const edge of entry.edges) {
|
|
476
|
-
|
|
662
|
+
// The canonical spelling — imported, not restated, so this surface
|
|
663
|
+
// cannot drift from `EvolutionEvent.affected`'s vocabulary.
|
|
664
|
+
const edgeId = edgeEvolutionIdentity({
|
|
665
|
+
source: entry.project,
|
|
666
|
+
target: edge.target,
|
|
667
|
+
type: edge.type,
|
|
668
|
+
});
|
|
477
669
|
if (!affectedBoundaries.includes(edgeId)) {
|
|
478
670
|
affectedBoundaries.push(edgeId);
|
|
479
671
|
}
|
|
@@ -80,6 +80,7 @@ import { debtChangeDiff, debtFactId, driftFactOf } from "../governance/debt-ledg
|
|
|
80
80
|
import { computeAffectedDecisions } from "../governance/decision-lineage.mjs";
|
|
81
81
|
import {
|
|
82
82
|
classifyEvolution,
|
|
83
|
+
edgeEvolutionIdentity,
|
|
83
84
|
eventDedupeKey,
|
|
84
85
|
eventId,
|
|
85
86
|
EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
@@ -939,23 +940,24 @@ function buildEvolutionSummary(comparisons) {
|
|
|
939
940
|
comparisons.flatMap((c) => c.observed.projects.changed.map((p) => p.name ?? p)),
|
|
940
941
|
),
|
|
941
942
|
},
|
|
943
|
+
// The identity string is the ONE spelling `edgeEvolutionIdentity` owns —
|
|
944
|
+
// the same spelling `affected.boundaries` and every stored event carry.
|
|
945
|
+
// An edge without a complete triple has no identity to name, so it is
|
|
946
|
+
// dropped from the union and counted into `unnamedEdges` rather than
|
|
947
|
+
// leaking an object serialization into a field of identity strings.
|
|
942
948
|
edges: {
|
|
943
949
|
added: unique(
|
|
944
950
|
comparisons.flatMap((c) =>
|
|
945
|
-
c.observed.edges.added
|
|
946
|
-
e.source && e.target && e.type
|
|
947
|
-
|
|
948
|
-
: JSON.stringify(e),
|
|
949
|
-
),
|
|
951
|
+
c.observed.edges.added
|
|
952
|
+
.filter((e) => e.source && e.target && e.type)
|
|
953
|
+
.map((e) => edgeEvolutionIdentity(e)),
|
|
950
954
|
),
|
|
951
955
|
),
|
|
952
956
|
removed: unique(
|
|
953
957
|
comparisons.flatMap((c) =>
|
|
954
|
-
c.observed.edges.removed
|
|
955
|
-
e.source && e.target && e.type
|
|
956
|
-
|
|
957
|
-
: JSON.stringify(e),
|
|
958
|
-
),
|
|
958
|
+
c.observed.edges.removed
|
|
959
|
+
.filter((e) => e.source && e.target && e.type)
|
|
960
|
+
.map((e) => edgeEvolutionIdentity(e)),
|
|
959
961
|
),
|
|
960
962
|
),
|
|
961
963
|
},
|
|
@@ -983,6 +985,21 @@ function buildEvolutionSummary(comparisons) {
|
|
|
983
985
|
};
|
|
984
986
|
|
|
985
987
|
const notes = unique(comparisons.flatMap((c) => c.notes ?? []));
|
|
988
|
+
const unnamedEdges = comparisons.reduce(
|
|
989
|
+
(count, c) =>
|
|
990
|
+
count +
|
|
991
|
+
[...c.observed.edges.added, ...c.observed.edges.removed].filter(
|
|
992
|
+
(e) => !(e.source && e.target && e.type),
|
|
993
|
+
).length,
|
|
994
|
+
0,
|
|
995
|
+
);
|
|
996
|
+
if (unnamedEdges > 0) {
|
|
997
|
+
// The house shape for "we could not name it": a note naming the gap,
|
|
998
|
+
// never a silent drop dressed as a clean union.
|
|
999
|
+
notes.push(
|
|
1000
|
+
`${unnamedEdges} changed edge(s) carry no complete identity and are not named in observed.edges`,
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
986
1003
|
return {
|
|
987
1004
|
transitions: comparisons.length,
|
|
988
1005
|
disposition,
|
package/src/commands/explain.mjs
CHANGED
|
@@ -79,7 +79,11 @@
|
|
|
79
79
|
* was: the field, and its rendered lines, exist only when the comparison was
|
|
80
80
|
* requested.
|
|
81
81
|
*/
|
|
82
|
-
import {
|
|
82
|
+
import {
|
|
83
|
+
blindSpotRows,
|
|
84
|
+
isWholeFileFailure,
|
|
85
|
+
unresolvableLiteralCount,
|
|
86
|
+
} from "../analysis/source-util.mjs";
|
|
83
87
|
import { UsageError } from "../errors.mjs";
|
|
84
88
|
import { evaluate } from "../rules/index.mjs";
|
|
85
89
|
import { findConstraintsFor } from "../rules/tags.mjs";
|
|
@@ -369,7 +373,13 @@ export function explainCommand(site, commandContext, config, options = {}) {
|
|
|
369
373
|
.filter(isWholeFileFailure)
|
|
370
374
|
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
371
375
|
|
|
372
|
-
|
|
376
|
+
// An unresolvable site was seen but never judged (#595): the graph is
|
|
377
|
+
// missing whatever edge that site would have drawn, and rules that judge
|
|
378
|
+
// the whole graph (circularity, lazy loading) would answer over a gap. The
|
|
379
|
+
// explanation still reports — status no-verdict — naming the site in
|
|
380
|
+
// `coverage.blindSpots`, the same contract `graph`/`discover` run.
|
|
381
|
+
const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
|
|
382
|
+
const complete = notAnalyzed.length === 0 && blindSpotCount === 0;
|
|
373
383
|
const status = complete ? "ok" : "no-verdict";
|
|
374
384
|
|
|
375
385
|
// Find the import record at this site.
|
|
@@ -422,14 +432,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
|
|
|
422
432
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
423
433
|
imports: commandContext.analysis.imports.length,
|
|
424
434
|
notAnalyzed,
|
|
425
|
-
blindSpots: commandContext.analysis.failures
|
|
426
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
427
|
-
.map(({ sourceFile, line, column, reason }) => ({
|
|
428
|
-
file: sourceFile,
|
|
429
|
-
line,
|
|
430
|
-
column,
|
|
431
|
-
reason,
|
|
432
|
-
})),
|
|
435
|
+
blindSpots: blindSpotRows(commandContext.analysis.failures),
|
|
433
436
|
notes: [],
|
|
434
437
|
};
|
|
435
438
|
|
|
@@ -576,9 +579,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
|
|
|
576
579
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
577
580
|
imports: commandContext.analysis.imports.length,
|
|
578
581
|
notAnalyzed,
|
|
579
|
-
blindSpots: commandContext.analysis.failures
|
|
580
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
581
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
582
|
+
blindSpots: blindSpotRows(commandContext.analysis.failures),
|
|
582
583
|
notes: [],
|
|
583
584
|
};
|
|
584
585
|
|
package/src/commands/fitness.mjs
CHANGED
|
@@ -45,10 +45,11 @@
|
|
|
45
45
|
* sorted; JSON rides `canonicalizeJson`. Two runs over an unchanged tree and
|
|
46
46
|
* policy produce byte-identical text and JSON.
|
|
47
47
|
*/
|
|
48
|
-
import {
|
|
48
|
+
import { blindSpotRows } from "../analysis/source-util.mjs";
|
|
49
49
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
50
50
|
import { formatFitnessSection } from "../report/text.mjs";
|
|
51
51
|
import { resolveProvenance } from "./provenance.mjs";
|
|
52
|
+
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
52
53
|
import { driftForCheck } from "./drift.mjs";
|
|
53
54
|
import {
|
|
54
55
|
evaluateFitness,
|
|
@@ -114,9 +115,13 @@ export function declaresFitness(config) {
|
|
|
114
115
|
*
|
|
115
116
|
* @param {object} commandContext From `resolveCommandContext`.
|
|
116
117
|
* @param {{config?: object|null}} [io] The loaded policy, injectable for tests.
|
|
117
|
-
* @returns {Promise<{status: "ok"|"findings"|"no-verdict", fitness
|
|
118
|
-
* report: {text: string, json: string}}>}
|
|
119
|
-
*
|
|
118
|
+
* @returns {Promise<{status: "ok"|"findings"|"no-verdict", fitness?: object,
|
|
119
|
+
* coverage: object, report: {text: string, json: string}}>}
|
|
120
|
+
* `status: "no-verdict"` from the coverage refusal carries no `fitness`
|
|
121
|
+
* payload — the verdict was withheld, and the envelope's `coverage` block is
|
|
122
|
+
* the whole answer (#608).
|
|
123
|
+
* @throws {Error} on every condition the header lists except the coverage one,
|
|
124
|
+
* which returns instead of throwing.
|
|
120
125
|
*/
|
|
121
126
|
export async function fitnessCommand(commandContext, io = {}) {
|
|
122
127
|
const { root, provider, marker, analysis } = commandContext;
|
|
@@ -129,18 +134,16 @@ export async function fitnessCommand(commandContext, io = {}) {
|
|
|
129
134
|
);
|
|
130
135
|
}
|
|
131
136
|
|
|
132
|
-
// A verdict over a tree it could not fully read is a guess.
|
|
133
|
-
// `
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
`unanalyzed files and re-run.`,
|
|
143
|
-
);
|
|
137
|
+
// A verdict over a tree it could not fully read is a guess. Refused through
|
|
138
|
+
// the one structured contract `./coverage-verdict.mjs` builds (#608) — this
|
|
139
|
+
// gate used to check whole-file failures only and then claim
|
|
140
|
+
// `coverage.complete: true` beside `blindSpots` that could carry an unjudged
|
|
141
|
+
// site, which the envelope law refuses as a programming error. The unified
|
|
142
|
+
// completeness returns the no-verdict envelope instead, for every axis the
|
|
143
|
+
// envelope law already withholds over.
|
|
144
|
+
const completeness = coverageVerdict(commandContext);
|
|
145
|
+
if (!completeness.complete) {
|
|
146
|
+
return coverageRefusal({ command: "fitness", commandContext, what: "judging fitness" });
|
|
144
147
|
}
|
|
145
148
|
|
|
146
149
|
// `drift-free` judges the SAME verdict-shaped intent `check`'s fold builds —
|
|
@@ -192,9 +195,7 @@ export async function fitnessCommand(commandContext, io = {}) {
|
|
|
192
195
|
analyzedFiles: analysis.analyzed,
|
|
193
196
|
imports: analysis.imports.length,
|
|
194
197
|
notAnalyzed: [],
|
|
195
|
-
blindSpots: analysis.failures
|
|
196
|
-
.filter((failure) => !isWholeFileFailure(failure))
|
|
197
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
198
|
+
blindSpots: blindSpotRows(analysis.failures),
|
|
198
199
|
notes: [],
|
|
199
200
|
};
|
|
200
201
|
|
package/src/commands/graph.mjs
CHANGED
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
* `entryPoints`, or `declaredPackages`). It is descriptive: it never exits 1,
|
|
11
11
|
* because a snapshot of what is is never a finding.
|
|
12
12
|
*
|
|
13
|
+
* Its completeness verdict is not computed here: `graphCommand` composes
|
|
14
|
+
* `./coverage-verdict.mjs`'s `coverageVerdict`, the one constructor every
|
|
15
|
+
* refusal-contract face reads, so this snapshot's `status`/`exitCode` cannot
|
|
16
|
+
* drift from the axes `check` judges completeness over. The graph-family
|
|
17
|
+
* restatement this replaces is how the zero-analysis axis went missing here
|
|
18
|
+
* while every other face carried it (#612).
|
|
19
|
+
*
|
|
13
20
|
* What it needs from its caller is a `CommandContext` — the preamble every
|
|
14
21
|
* command shares (`./context.mjs`). What it gives back is a `status`, the
|
|
15
22
|
* payload for both the text and the JSON renderers, and enough coverage
|
|
@@ -29,11 +36,12 @@
|
|
|
29
36
|
*/
|
|
30
37
|
import { createHash } from "node:crypto";
|
|
31
38
|
|
|
32
|
-
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
33
39
|
import { canonicalizeJson } from "../canonical.mjs";
|
|
34
40
|
import { DEFAULT_WORKSPACE_LAYOUT } from "../rules/specifiers.mjs";
|
|
35
41
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
36
42
|
import { formatGraphReport } from "../report/graph-text.mjs";
|
|
43
|
+
import { coverageIncompleteReasons } from "../verdict.mjs";
|
|
44
|
+
import { coverageVerdict } from "./coverage-verdict.mjs";
|
|
37
45
|
import { resolveProvenance } from "./provenance.mjs";
|
|
38
46
|
|
|
39
47
|
/**
|
|
@@ -218,13 +226,24 @@ export function graphCommand(commandContext, { config = null } = {}) {
|
|
|
218
226
|
);
|
|
219
227
|
}
|
|
220
228
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
229
|
+
// The completeness verdict is the shared constructor's, not this file's:
|
|
230
|
+
// restating the axes here is how the `analyzed > 0` term went missing from
|
|
231
|
+
// this face while `check` carried it (#612 — a run that judged no file at
|
|
232
|
+
// all used to report `ok` / `complete: true` / exit 0, byte-for-byte the
|
|
233
|
+
// envelope a clean workspace gets). `coverageVerdict` owns the one law —
|
|
234
|
+
// no whole-file failure, no unjudged site, at least one file analyzed —
|
|
235
|
+
// and the same return shape the envelope and the text face both read.
|
|
236
|
+
const verdict = coverageVerdict(commandContext);
|
|
237
|
+
const { complete, status, exitCode } = verdict;
|
|
224
238
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
239
|
+
// The clauses the text face renders over an incomplete run, worded by the
|
|
240
|
+
// same function `verdictFor` joins into `decision.reason` — one wording,
|
|
241
|
+
// two renderings, and neither can drift from the other.
|
|
242
|
+
const coverageIncomplete = coverageIncompleteReasons({
|
|
243
|
+
unchecked: verdict.notAnalyzed.length,
|
|
244
|
+
blindSpots: verdict.blindSpotCount,
|
|
245
|
+
analyzed: commandContext.analysis.analyzed,
|
|
246
|
+
});
|
|
228
247
|
|
|
229
248
|
const projects = buildProjects(graph.nodes);
|
|
230
249
|
const dependencies = buildDependencies(graph.dependencies);
|
|
@@ -244,10 +263,8 @@ export function graphCommand(commandContext, { config = null } = {}) {
|
|
|
244
263
|
projects: projects.length,
|
|
245
264
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
246
265
|
imports: commandContext.analysis.imports.length,
|
|
247
|
-
notAnalyzed,
|
|
248
|
-
blindSpots:
|
|
249
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
250
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
266
|
+
notAnalyzed: verdict.notAnalyzed,
|
|
267
|
+
blindSpots: verdict.blindSpots,
|
|
251
268
|
notes: [],
|
|
252
269
|
};
|
|
253
270
|
|
|
@@ -290,6 +307,7 @@ export function graphCommand(commandContext, { config = null } = {}) {
|
|
|
290
307
|
workspaceLayout,
|
|
291
308
|
workspaceLayoutSource,
|
|
292
309
|
coverage,
|
|
310
|
+
coverageIncomplete,
|
|
293
311
|
}),
|
|
294
312
|
json: renderJson(envelope),
|
|
295
313
|
},
|
package/src/commands/health.mjs
CHANGED
|
@@ -43,7 +43,11 @@
|
|
|
43
43
|
* It does not print, and it does not decide the process's exit code —
|
|
44
44
|
* `../../cli.mjs` owns those (`./README.md`).
|
|
45
45
|
*/
|
|
46
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
blindSpotRows,
|
|
48
|
+
isWholeFileFailure,
|
|
49
|
+
unresolvableLiteralCount,
|
|
50
|
+
} from "../analysis/source-util.mjs";
|
|
47
51
|
import { buildDependencies, buildProjects } from "./graph.mjs";
|
|
48
52
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
49
53
|
import { formatHealthReport } from "../report/health-text.mjs";
|
|
@@ -108,7 +112,12 @@ export function healthCommand(commandContext, io = {}) {
|
|
|
108
112
|
const edges = buildDependencies(graph.dependencies);
|
|
109
113
|
|
|
110
114
|
// The run's coverage facts, the same shape every command's envelope carries.
|
|
111
|
-
|
|
115
|
+
// An unresolvable site is a fact the run saw but never judged (#595) —
|
|
116
|
+
// metrics measured over it would read precision the run does not have,
|
|
117
|
+
// so it defeats file completeness the way a whole-file failure does.
|
|
118
|
+
const fileComplete =
|
|
119
|
+
analysis.failures.filter(isWholeFileFailure).length === 0 &&
|
|
120
|
+
unresolvableLiteralCount(analysis.failures) === 0;
|
|
112
121
|
// The graph is complete only when the files are AND the graph actually sees
|
|
113
122
|
// every polyglot edge — an Nx workspace with an unregistered plugin carries
|
|
114
123
|
// a graph with no Go/Rust/Python edges, which `graph`/`impact` refuse and
|
|
@@ -123,9 +132,7 @@ export function healthCommand(commandContext, io = {}) {
|
|
|
123
132
|
notAnalyzed: analysis.failures
|
|
124
133
|
.filter(isWholeFileFailure)
|
|
125
134
|
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason })),
|
|
126
|
-
blindSpots: analysis.failures
|
|
127
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
128
|
-
.map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
|
|
135
|
+
blindSpots: blindSpotRows(analysis.failures),
|
|
129
136
|
notes: graphComplete
|
|
130
137
|
? []
|
|
131
138
|
: [
|
package/src/commands/history.mjs
CHANGED
|
@@ -74,12 +74,17 @@ import { createHash } from "node:crypto";
|
|
|
74
74
|
import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
75
75
|
import { basename, join, resolve } from "node:path";
|
|
76
76
|
|
|
77
|
-
import {
|
|
77
|
+
import {
|
|
78
|
+
blindSpotRows,
|
|
79
|
+
isWholeFileFailure,
|
|
80
|
+
unresolvableLiteralCount,
|
|
81
|
+
} from "../analysis/source-util.mjs";
|
|
82
|
+
import { canonicalizeJson } from "../canonical.mjs";
|
|
78
83
|
import { containmentViolation } from "../containment.mjs";
|
|
79
84
|
import { classifyEvolution } from "../governance/evolution-event.mjs";
|
|
80
85
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
81
86
|
import { formatHistoryReport } from "../report/history-text.mjs";
|
|
82
|
-
import { computeDiff,
|
|
87
|
+
import { computeDiff, parseBaseline } from "./diff.mjs";
|
|
83
88
|
import { buildDependencies, buildProjects } from "./graph.mjs";
|
|
84
89
|
import { resolveProvenance } from "./provenance.mjs";
|
|
85
90
|
import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
|
|
@@ -117,17 +122,17 @@ export function snapshotIdentity({ projects, dependencies, policy }) {
|
|
|
117
122
|
type,
|
|
118
123
|
tags,
|
|
119
124
|
}));
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
);
|
|
125
|
+
// The canonical string comes from `../canonical.mjs` — the one canonicalizer
|
|
126
|
+
// ("one canonicalizer, in one place, so two serializations cannot drift").
|
|
127
|
+
// This digest's private inline replacer was the last serialization site
|
|
128
|
+
// beside it (#613); `history.test.mjs` pins the digest byte-identical to the
|
|
129
|
+
// retired spelling on every snapshot shape, so composing it moved no byte an
|
|
130
|
+
// unchanged workspace ever saw.
|
|
131
|
+
const canonical = canonicalizeJson({
|
|
132
|
+
projects: identityProjects,
|
|
133
|
+
dependencies,
|
|
134
|
+
policy: policy?.fingerprint ?? null,
|
|
135
|
+
});
|
|
131
136
|
return createHash("sha256").update(canonical).digest("hex");
|
|
132
137
|
}
|
|
133
138
|
|
|
@@ -397,8 +402,12 @@ export function classifyTransition(from, to) {
|
|
|
397
402
|
changed: diff.changedProjects.map((project) => project.name),
|
|
398
403
|
},
|
|
399
404
|
edges: {
|
|
400
|
-
|
|
401
|
-
|
|
405
|
+
// The raw triples — `classifyEvolution` maps them through its own
|
|
406
|
+
// identity spelling (`edgeEvolutionIdentity`), so `affected.boundaries`
|
|
407
|
+
// carries the canonical strings every other event surface uses, not
|
|
408
|
+
// this module's diff-internal key spelling.
|
|
409
|
+
added: diff.addedEdges,
|
|
410
|
+
removed: diff.removedEdges,
|
|
402
411
|
},
|
|
403
412
|
policyChanged: meta.policyChanged,
|
|
404
413
|
policyOneSided: meta.policyOneSided,
|
|
@@ -554,10 +563,23 @@ export function historyCommand(
|
|
|
554
563
|
const notAnalyzed = commandContext.analysis.failures
|
|
555
564
|
.filter(isWholeFileFailure)
|
|
556
565
|
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
557
|
-
|
|
566
|
+
|
|
567
|
+
const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
|
|
568
|
+
if (notAnalyzed.length > 0 || blindSpotCount > 0) {
|
|
558
569
|
throw new Error(
|
|
559
|
-
`archkeep: the head graph has incomplete coverage —
|
|
560
|
-
|
|
570
|
+
`archkeep: the head graph has incomplete coverage — ` +
|
|
571
|
+
[
|
|
572
|
+
notAnalyzed.length > 0
|
|
573
|
+
? `${notAnalyzed.length} file${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed`
|
|
574
|
+
: null,
|
|
575
|
+
blindSpotCount > 0
|
|
576
|
+
? `${blindSpotCount} import site${blindSpotCount === 1 ? "" : "s"} could not be resolved`
|
|
577
|
+
: null,
|
|
578
|
+
]
|
|
579
|
+
.filter(Boolean)
|
|
580
|
+
.join(", ") +
|
|
581
|
+
`, so
|
|
582
|
+
a captured snapshot ` +
|
|
561
583
|
`would under-represent the real architecture. Fix the unanalyzed files and re-run.`,
|
|
562
584
|
);
|
|
563
585
|
}
|
|
@@ -608,14 +630,7 @@ export function historyCommand(
|
|
|
608
630
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
609
631
|
imports: commandContext.analysis.imports.length,
|
|
610
632
|
notAnalyzed: [],
|
|
611
|
-
blindSpots: commandContext.analysis.failures
|
|
612
|
-
.filter((f) => !isWholeFileFailure(f))
|
|
613
|
-
.map(({ sourceFile, line, column, reason }) => ({
|
|
614
|
-
file: sourceFile,
|
|
615
|
-
line,
|
|
616
|
-
column,
|
|
617
|
-
reason,
|
|
618
|
-
})),
|
|
633
|
+
blindSpots: blindSpotRows(commandContext.analysis.failures),
|
|
619
634
|
notes: [],
|
|
620
635
|
},
|
|
621
636
|
result: { ...head, policy: headPolicy ?? undefined },
|