@ecoma-io/archkeep 0.23.0 → 0.24.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/package.json +1 -1
- package/src/analysis/csharp.mjs +3 -1
- package/src/analysis/dotnet/csproj.mjs +5 -1
- package/src/analysis/dotnet/namespaces.mjs +1 -0
- package/src/analysis/go.mjs +6 -0
- package/src/analysis/java.mjs +2 -0
- package/src/analysis/jvm/gradle.mjs +3 -1
- package/src/analysis/jvm/maven.mjs +6 -1
- package/src/analysis/jvm/packages.mjs +1 -0
- package/src/analysis/jvm/resolve.mjs +4 -2
- package/src/analysis/kotlin.mjs +2 -0
- package/src/analysis/markdown.mjs +341 -0
- package/src/analysis/python.mjs +8 -0
- package/src/analysis/rust.mjs +5 -1
- package/src/analysis/source-util.mjs +6 -5
- package/src/analysis/typescript.mjs +148 -0
- package/src/architecture-intent/model.mjs +13 -8
- package/src/architecture-intent/selectors.mjs +2 -1
- package/src/commands/change-intent.mjs +10 -9
- package/src/commands/change.mjs +17 -1
- package/src/commands/check.mjs +177 -17
- package/src/commands/completeness.mjs +7 -6
- package/src/commands/context-command.mjs +12 -20
- package/src/commands/coverage-acceptance.mjs +46 -0
- package/src/commands/coverage-verdict.mjs +12 -2
- package/src/commands/custom-rules.mjs +1 -0
- package/src/commands/delta-classify.mjs +3 -0
- package/src/commands/delta-snapshot.mjs +3 -6
- package/src/commands/delta.mjs +35 -21
- package/src/commands/drift.mjs +1 -1
- package/src/commands/evaluation-primitives.mjs +4 -4
- package/src/commands/evolution.mjs +2 -0
- package/src/commands/explain.mjs +19 -20
- package/src/commands/graph.mjs +44 -15
- package/src/commands/health.mjs +4 -0
- package/src/commands/history.mjs +2 -0
- package/src/commands/plan-context-command.mjs +9 -2
- package/src/commands/policy.mjs +8 -5
- package/src/commands/provenance.mjs +8 -2
- package/src/commands/scenario-evaluation.mjs +1 -1
- package/src/commands/trajectory.mjs +2 -1
- package/src/config.mjs +170 -2
- package/src/custom-rules/host.mjs +3 -3
- package/src/errors.mjs +23 -1
- package/src/eslint-config.mjs +3 -5
- package/src/fixtures/evolution-lifecycle/workspace.mjs +15 -4
- package/src/go-work.mjs +1 -1
- package/src/governance/adr-registry.mjs +6 -2
- package/src/governance/debt-ledger.mjs +1 -1
- package/src/governance/decision-fitness.mjs +2 -0
- package/src/governance/decision-graph.mjs +1 -0
- package/src/governance/discovery-proposal.mjs +8 -2
- package/src/governance/evolution-event.mjs +42 -0
- package/src/governance/evolution-store.mjs +3 -2
- package/src/governance/fitness-registry.mjs +2 -3
- package/src/governance/preset-fingerprints.json +14 -14
- package/src/governance/profile-registry.mjs +22 -3
- package/src/governance/provenance-record.mjs +4 -1
- package/src/governance/reconcile-score.mjs +4 -0
- package/src/governance/row-schema.mjs +1 -3
- package/src/governance/verdict.mjs +1 -0
- package/src/governance/waiver.mjs +1 -0
- package/src/intent/intent-manifest.json +6 -6
- package/src/intent/mask-non-code.mjs +1 -0
- package/src/lsp/diagnostics.mjs +3 -2
- package/src/lsp/protocol.mjs +2 -1
- package/src/lsp/server.mjs +3 -0
- package/src/lsp/workspace-index.mjs +3 -1
- package/src/providers/moon.mjs +5 -5
- package/src/providers/native/differential.fixtures.mjs +29 -11
- package/src/providers/native/index.mjs +2 -1
- package/src/providers/native/model.mjs +4 -0
- package/src/report/envelope-shape.mjs +2 -0
- package/src/report/sarif.mjs +21 -8
- package/src/report/snapshot-text.mjs +3 -3
- package/src/report/text.mjs +10 -2
- package/src/rules/index.mjs +30 -0
- package/src/rules/match.mjs +7 -5
- package/src/rules/specifiers.mjs +2 -0
- package/src/rules/tags.mjs +3 -2
- package/src/rules/topology.mjs +6 -1
- package/src/verdict.mjs +33 -2
- package/src/workspace.mjs +1 -0
package/src/commands/check.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { statSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
|
|
15
|
+
import { foldMarkdownTrack } from "../analysis/markdown.mjs";
|
|
15
16
|
import {
|
|
16
17
|
blindSpotRows,
|
|
17
18
|
fileFailure,
|
|
@@ -28,10 +29,13 @@ import {
|
|
|
28
29
|
unownedGapWithoutRunConfiguration,
|
|
29
30
|
untrackedOwnedFiles,
|
|
30
31
|
} from "./context.mjs";
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
partitionUnownedCoverage,
|
|
34
|
+
withdrawAcceptedUnclaimedFailures,
|
|
35
|
+
} from "./coverage-acceptance.mjs";
|
|
32
36
|
import { readAdrContext } from "./adr.mjs";
|
|
33
37
|
import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
|
|
34
|
-
import { declaredEdgeViolationsForCheck } from "../rules/edge-constraints.mjs";
|
|
38
|
+
import { declaredEdgeViolationsForCheck, judgeEdge } from "../rules/edge-constraints.mjs";
|
|
35
39
|
import { customRulesForCheck, declaresCustomRules } from "./custom-rules.mjs";
|
|
36
40
|
import { driftForCheck } from "./drift.mjs";
|
|
37
41
|
import { fitnessForCheck } from "./fitness.mjs";
|
|
@@ -40,14 +44,16 @@ import { resolvePolicy } from "./policy.mjs";
|
|
|
40
44
|
import { resolveProvenance } from "./provenance.mjs";
|
|
41
45
|
import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
|
|
42
46
|
import { compareGoWork, parseGoWorkUse } from "../go-work.mjs";
|
|
47
|
+
import { mergeDeclaredEdges } from "../providers/native/graph.mjs";
|
|
43
48
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
44
49
|
import { formatSarif } from "../report/sarif.mjs";
|
|
45
50
|
import { formatReport } from "../report/text.mjs";
|
|
46
51
|
import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
|
|
47
|
-
import { evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
|
|
52
|
+
import { applySuppressionTable, evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
|
|
53
|
+
import { buildReachability } from "../rules/reachability.mjs";
|
|
48
54
|
import { orphanedNotDependOnTags, unmatchedConstraintRows } from "../rules/tags.mjs";
|
|
49
55
|
import { judgeTsconfigPaths } from "../tsconfig-paths.mjs";
|
|
50
|
-
import { verdictFor } from "../verdict.mjs";
|
|
56
|
+
import { coverageComplete, verdictFor } from "../verdict.mjs";
|
|
51
57
|
import { listTrackedFiles, listUntrackedFiles } from "../workspace.mjs";
|
|
52
58
|
|
|
53
59
|
/**
|
|
@@ -220,7 +226,7 @@ export async function check(
|
|
|
220
226
|
);
|
|
221
227
|
const { root, graph, workspace, tracked } = commandContext;
|
|
222
228
|
const { imports, exemptedFiles, unsupportedLanguageFiles } = commandContext.analysis;
|
|
223
|
-
|
|
229
|
+
let failures = [...commandContext.analysis.failures];
|
|
224
230
|
const analyzed = commandContext.analysis.analyzed;
|
|
225
231
|
|
|
226
232
|
// The config's location is a separate fact from the workspace root, which is
|
|
@@ -305,17 +311,16 @@ export async function check(
|
|
|
305
311
|
// acceptance now — stated below as the `"accepted-unowned-files"` coverage
|
|
306
312
|
// gap, never silently — rather than the exit-3 refusal an unanswered
|
|
307
313
|
// orphan earns. Uncovered unclaimed files keep their failures, and with
|
|
308
|
-
// them the exit code, byte-identical to before the channel existed.
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
314
|
+
// them the exit code, byte-identical to before the channel existed. The
|
|
315
|
+
// one-failure-per-file assumption the withdrawal rests on is enforced where
|
|
316
|
+
// it is spent (`./coverage-acceptance.mjs`), not stated here: an accepted
|
|
317
|
+
// file carrying a second failure refuses the run instead of losing both
|
|
318
|
+
// rows to a by-file splice.
|
|
312
319
|
const acceptedUnclaimed = new Set(
|
|
313
320
|
commandContext.unclaimedGap.files.filter((file) => unownedCoverage.acceptedFiles.has(file)),
|
|
314
321
|
);
|
|
315
322
|
if (acceptedUnclaimed.size > 0) {
|
|
316
|
-
|
|
317
|
-
if (acceptedUnclaimed.has(failures[at].sourceFile)) failures.splice(at, 1);
|
|
318
|
-
}
|
|
323
|
+
failures = withdrawAcceptedUnclaimedFailures(failures, acceptedUnclaimed);
|
|
319
324
|
}
|
|
320
325
|
|
|
321
326
|
// The go.work drift check, keyed off the manifest's presence the way every
|
|
@@ -453,6 +458,41 @@ export async function check(
|
|
|
453
458
|
}
|
|
454
459
|
}
|
|
455
460
|
|
|
461
|
+
// The markdown document track, keyed by presence like every fold above it:
|
|
462
|
+
// a policy that declares no `markdown` block reaches nothing here and hears
|
|
463
|
+
// nothing anywhere — no edges, no failures, no envelope key — so a
|
|
464
|
+
// config-absent run is byte-identical to one this fold never existed for
|
|
465
|
+
// (`../../../../AGENTS.md`, "a change to what is reported on an unchanged
|
|
466
|
+
// workspace is a breaking change"). A policy that DOES declare one has its
|
|
467
|
+
// documents read (`../analysis/markdown.mjs`'s `foldMarkdownTrack`), the
|
|
468
|
+
// resolved pairings folded into the graph the way the declared manifest
|
|
469
|
+
// track's edges already are (`mergeDeclaredEdges`, the same dedup key), and
|
|
470
|
+
// every whole-file failure the read earned pushed into the same `failures`
|
|
471
|
+
// funnel the analysis uses — an unresolvable marker is an `unchecked` file,
|
|
472
|
+
// never a clean one.
|
|
473
|
+
//
|
|
474
|
+
// The fold runs BEFORE the walk below on purpose: a document pairing is a
|
|
475
|
+
// project-to-project dependency claim, and from here on this run's graph
|
|
476
|
+
// carries it exactly as it carries a csproj's `ProjectReference` — the tag
|
|
477
|
+
// rows, the circularity walk and the reachability behind them all read one
|
|
478
|
+
// graph, so a pairing cannot be legal here and violating in `context`.
|
|
479
|
+
// What it does not join is the twelve non-tag import-site rules: those need
|
|
480
|
+
// a specifier, a file and a resolution, and a document marker has none of
|
|
481
|
+
// them — it is judged as the EDGE it drew, the same 3-of-15 limit
|
|
482
|
+
// `declaredEdgeViolationsForCheck` documents for `implicit` edges.
|
|
483
|
+
let markdownTrack = null;
|
|
484
|
+
if (config !== null && config.markdown !== undefined) {
|
|
485
|
+
markdownTrack = foldMarkdownTrack({
|
|
486
|
+
tracked,
|
|
487
|
+
owned: commandContext.owned,
|
|
488
|
+
readFile: (file) => workspace.readFile(file),
|
|
489
|
+
workspace,
|
|
490
|
+
markdown: config.markdown,
|
|
491
|
+
});
|
|
492
|
+
mergeDeclaredEdges(graph, markdownTrack.edges);
|
|
493
|
+
failures.push(...markdownTrack.failures);
|
|
494
|
+
}
|
|
495
|
+
|
|
456
496
|
// Both faces of one walk: the run's verdict, and the raw superset it was
|
|
457
497
|
// picked from — every candidate up to each site's surviving group, including
|
|
458
498
|
// the verdicts the suppression table removed to get there. `evaluate` alone
|
|
@@ -462,8 +502,66 @@ export async function check(
|
|
|
462
502
|
// the gate's waiver-expiry judgement below and the engine's are the same
|
|
463
503
|
// judgement, not two reads of the clock a boundary instant could split.
|
|
464
504
|
const now = referenceTime();
|
|
465
|
-
const { violations: judged, rawViolations } = evaluateRun(imports, graph, {
|
|
466
|
-
|
|
505
|
+
const { violations: judged, rawViolations: judgedRaw } = evaluateRun(imports, graph, {
|
|
506
|
+
...config,
|
|
507
|
+
now,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// The document pairings' own verdicts, judged by the machinery that already
|
|
511
|
+
// existed: `judgeEdge` — the same function `declaredEdgeViolationsForCheck`
|
|
512
|
+
// runs `implicit` edges through, so a doc pairing and a declared manifest
|
|
513
|
+
// edge can never disagree about the same boundary — over the graph the fold
|
|
514
|
+
// above already joined, with reachability built once for the whole claim
|
|
515
|
+
// list. The same empty-table exit `declaredEdgeViolationsForCheck` takes: a
|
|
516
|
+
// workspace declaring no `depConstraints` has opted out of tag enforcement
|
|
517
|
+
// entirely, and folding `projectWithoutTagsCannotHaveDependencies` onto
|
|
518
|
+
// every marker anyway would flag an opted-out workspace for a reason its
|
|
519
|
+
// import sites are never flagged for. Each verdict is then reshaped into the
|
|
520
|
+
// exact `Violation` record `violationOf` builds — position and specifier
|
|
521
|
+
// from the MARKER (the line a reader edits is the document's), project and
|
|
522
|
+
// constraint from the edge — so suppression, waiver annotation, sorting and
|
|
523
|
+
// the SARIF face all treat it as the ordinary violation it is.
|
|
524
|
+
/** @type {object[]} */
|
|
525
|
+
const markdownRaw = [];
|
|
526
|
+
if (config !== null && markdownTrack !== null && config.depConstraints.length > 0) {
|
|
527
|
+
const reachability = buildReachability(graph);
|
|
528
|
+
for (const claim of markdownTrack.claims) {
|
|
529
|
+
for (const verdict of judgeEdge(
|
|
530
|
+
{ source: claim.source, target: claim.target },
|
|
531
|
+
graph.nodes,
|
|
532
|
+
graph.dependencies,
|
|
533
|
+
config.depConstraints,
|
|
534
|
+
reachability,
|
|
535
|
+
)) {
|
|
536
|
+
markdownRaw.push({
|
|
537
|
+
sourceFile: claim.file,
|
|
538
|
+
line: claim.line,
|
|
539
|
+
column: claim.column,
|
|
540
|
+
specifier: claim.name,
|
|
541
|
+
kind: claim.type,
|
|
542
|
+
messageId: verdict.messageId,
|
|
543
|
+
message: verdict.message,
|
|
544
|
+
sourceProject: verdict.source,
|
|
545
|
+
targetProject: verdict.target,
|
|
546
|
+
constraint: verdict.constraint,
|
|
547
|
+
data: verdict.data,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// The same table, the same behaviour: a suppression row whose glob covers a
|
|
553
|
+
// document removes that document's verdict (or waives it, with the same
|
|
554
|
+
// expiry evidence an import-site waiver carries), and the RAW records join
|
|
555
|
+
// `rawViolations` below so a row covering only document verdicts still
|
|
556
|
+
// counts as alive — a table that is doing its job must not read as dead
|
|
557
|
+
// because the violations it removes live in a different fold.
|
|
558
|
+
const markdownViolations =
|
|
559
|
+
config !== null && markdownTrack !== null
|
|
560
|
+
? applySuppressionTable(config.suppressions, markdownRaw, now)
|
|
561
|
+
: [];
|
|
562
|
+
|
|
563
|
+
const rawViolations = [...judgedRaw, ...markdownRaw];
|
|
564
|
+
const violations = sortViolations([...judged, ...markdownViolations]);
|
|
467
565
|
// An ACTIVE waiver keeps the violation it accepts in the findings list,
|
|
468
566
|
// marked `waivedBy` — the run is still non-zero (waiving does not flip
|
|
469
567
|
// exit 1 → 0), and this count is the additive "accepted violations" number
|
|
@@ -704,7 +802,10 @@ export async function check(
|
|
|
704
802
|
config !== null &&
|
|
705
803
|
options.paths.length === 0 &&
|
|
706
804
|
failures.length === 0 &&
|
|
707
|
-
(config.suppressions.length > 0 ||
|
|
805
|
+
(config.suppressions.length > 0 ||
|
|
806
|
+
config.depConstraints.length > 0 ||
|
|
807
|
+
coverageRows.length > 0 ||
|
|
808
|
+
config.markdown !== undefined)
|
|
708
809
|
) {
|
|
709
810
|
const deadRows = [];
|
|
710
811
|
// The third dead table: a `coverage.unowned` row matching no unowned file
|
|
@@ -755,6 +856,34 @@ export async function check(
|
|
|
755
856
|
);
|
|
756
857
|
}
|
|
757
858
|
}
|
|
859
|
+
// The document track's two dead tables, measured against what the fold
|
|
860
|
+
// actually selected (`markdownTrack.includeCounts`/`rowMatches` — the
|
|
861
|
+
// same counts the fold's own selection is built from, not a re-derivation
|
|
862
|
+
// that could disagree): an include glob matching no tracked document, and
|
|
863
|
+
// a marker row matching no line in any included document, both govern
|
|
864
|
+
// nothing while reading as enforced — every pairing the workspace meant
|
|
865
|
+
// to declare goes unjudged and the run stays green. A whole-file failure
|
|
866
|
+
// above already disabled this gate, so reaching here with a row that
|
|
867
|
+
// matched nothing means the tree genuinely has no such line.
|
|
868
|
+
if (markdownTrack !== null && config.markdown !== undefined) {
|
|
869
|
+
markdownTrack.includeCounts.forEach((count, index) => {
|
|
870
|
+
if (count > 0) return;
|
|
871
|
+
deadRows.push(
|
|
872
|
+
`markdown.include[${index}]: '${config.markdown.include[index]}' matches no tracked ` +
|
|
873
|
+
`document — the document track reads nothing, so every marker row below it governs ` +
|
|
874
|
+
`nothing while reading as enforced. Either the path was never right, or the ` +
|
|
875
|
+
`documents are not tracked`,
|
|
876
|
+
);
|
|
877
|
+
});
|
|
878
|
+
markdownTrack.rowMatches.forEach((count, index) => {
|
|
879
|
+
if (count > 0) return;
|
|
880
|
+
deadRows.push(
|
|
881
|
+
`markdown.markers[${index}]: the pattern matches no line in any included document — ` +
|
|
882
|
+
`this row extracts no pairing, so it enforces nothing while reading as enforced. ` +
|
|
883
|
+
`Either the documents do not carry the marker, or the pattern was never right`,
|
|
884
|
+
);
|
|
885
|
+
});
|
|
886
|
+
}
|
|
758
887
|
if (deadRows.length > 0) {
|
|
759
888
|
throw new Error(
|
|
760
889
|
`archkeep: ${policySource ?? "the boundary config"} describes a workspace that does not ` +
|
|
@@ -977,8 +1106,14 @@ export async function check(
|
|
|
977
1106
|
// Complete means the run judged everything in scope: no
|
|
978
1107
|
// whole-file failure (unchecked), no unresolvable site (#595),
|
|
979
1108
|
// and at least one file analyzed (#599 — a run that judged
|
|
980
|
-
// nothing has no verdict to claim).
|
|
981
|
-
|
|
1109
|
+
// nothing has no verdict to claim). Read from the one predicate
|
|
1110
|
+
// `verdictFor`'s decision face reads, so this field and the
|
|
1111
|
+
// envelope's `decision.coverageComplete` are one derivation —
|
|
1112
|
+
// the counts are this command's (the go.work/tsconfig failures
|
|
1113
|
+
// pushed above and the accepted files withdrawn above make the
|
|
1114
|
+
// universe wider than `commandContext.analysis`), the law is
|
|
1115
|
+
// not.
|
|
1116
|
+
complete: coverageComplete({ unchecked, blindSpotCount, analyzed }),
|
|
982
1117
|
projects: Object.keys(graph.nodes).length,
|
|
983
1118
|
analyzedFiles: analyzed,
|
|
984
1119
|
imports: imports.length,
|
|
@@ -1029,6 +1164,31 @@ export async function check(
|
|
|
1029
1164
|
judged: declaredEdges.judged,
|
|
1030
1165
|
findings: declaredEdges.findings,
|
|
1031
1166
|
},
|
|
1167
|
+
// The document track is a policy DECLARATION, so it takes the
|
|
1168
|
+
// same omitted-key-not-null discipline the intent/fitness/
|
|
1169
|
+
// customRules blocks below state: a workspace whose policy
|
|
1170
|
+
// declares no `markdown` block gets no key at all, and its
|
|
1171
|
+
// envelope is byte-identical to the one it got before this
|
|
1172
|
+
// section existed. The findings themselves are NOT restated
|
|
1173
|
+
// here — they are members of `violations` above (sorted with,
|
|
1174
|
+
// suppressible by, and counted into the verdict exactly like
|
|
1175
|
+
// every import-site violation), so an array here would be one
|
|
1176
|
+
// fact counted in two places; the block states what the track
|
|
1177
|
+
// DID: how many documents the globs selected, how many markers
|
|
1178
|
+
// they carried, where each resolution went.
|
|
1179
|
+
...(markdownTrack === null
|
|
1180
|
+
? {}
|
|
1181
|
+
: {
|
|
1182
|
+
markdown: {
|
|
1183
|
+
checked: true,
|
|
1184
|
+
documents: markdownTrack.documents,
|
|
1185
|
+
judged: markdownTrack.judged,
|
|
1186
|
+
resolved: markdownTrack.resolved,
|
|
1187
|
+
...(markdownTrack.selfPaired > 0
|
|
1188
|
+
? { selfPaired: markdownTrack.selfPaired }
|
|
1189
|
+
: {}),
|
|
1190
|
+
},
|
|
1191
|
+
}),
|
|
1032
1192
|
// Intent is a governance DECLARATION, absent when the workspace
|
|
1033
1193
|
// chose not to make one: the key is omitted, never written as
|
|
1034
1194
|
// null — the design contract `docs/reference/json-output.md` will
|
|
@@ -40,10 +40,10 @@ export const EVALUATION_STATUS = Object.freeze({
|
|
|
40
40
|
REFUSED: "refused",
|
|
41
41
|
});
|
|
42
42
|
export const EVALUATED = EVALUATION_STATUS.EVALUATED;
|
|
43
|
-
export const PARTIAL = EVALUATION_STATUS.PARTIAL;
|
|
43
|
+
export const PARTIAL = EVALUATION_STATUS.PARTIAL; // used by its own test
|
|
44
44
|
export const NOT_EVALUATED = EVALUATION_STATUS.NOT_EVALUATED;
|
|
45
|
-
export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED;
|
|
46
|
-
export const REFUSED = EVALUATION_STATUS.REFUSED;
|
|
45
|
+
export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED; // used by its own test
|
|
46
|
+
export const REFUSED = EVALUATION_STATUS.REFUSED; // used by its own test
|
|
47
47
|
|
|
48
48
|
// ---------------------------------------------------------------------------
|
|
49
49
|
// Evaluation contract types — which gates are required per evaluation type
|
|
@@ -71,7 +71,7 @@ export const EVALUATION_CONTRACT_TYPES = Object.freeze({
|
|
|
71
71
|
*
|
|
72
72
|
* @type {Readonly<Object<string, ReadonlySet<string>>>}
|
|
73
73
|
*/
|
|
74
|
-
|
|
74
|
+
const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
|
|
75
75
|
[EVALUATION_CONTRACT_TYPES.CANONICAL]: Object.freeze(
|
|
76
76
|
new Set([
|
|
77
77
|
"domainCoverage",
|
|
@@ -107,7 +107,7 @@ export const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
|
|
|
107
107
|
* Defaults to SCENARIO (most restrictive).
|
|
108
108
|
* @returns {boolean}
|
|
109
109
|
*/
|
|
110
|
-
|
|
110
|
+
function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES.SCENARIO) {
|
|
111
111
|
const required = REQUIRED_GATES_FOR_CONTRACT[contractType];
|
|
112
112
|
return required ? required.has(gateKey) : true;
|
|
113
113
|
}
|
|
@@ -121,6 +121,7 @@ export function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES
|
|
|
121
121
|
* @type {Readonly<{key: string, label: string, type: string}[]>}
|
|
122
122
|
*/
|
|
123
123
|
export const EVIDENCE_COMPLETE_GATES = Object.freeze([
|
|
124
|
+
// used by its own test
|
|
124
125
|
{ key: "domainCoverage", label: "Domain coverage", type: "ratio" },
|
|
125
126
|
{ key: "claimEvidenceCoverage", label: "Claim evidence coverage", type: "ratio" },
|
|
126
127
|
{ key: "causalCoverage", label: "Causal coverage", type: "ratio" },
|
|
@@ -265,7 +266,7 @@ export const REQUIRED_DOMAINS = Object.freeze([
|
|
|
265
266
|
* @param {string} status EVALUATION_STATUS value
|
|
266
267
|
* @returns {boolean}
|
|
267
268
|
*/
|
|
268
|
-
|
|
269
|
+
function isDomainEvaluated(status) {
|
|
269
270
|
return status === EVALUATION_STATUS.EVALUATED;
|
|
270
271
|
}
|
|
271
272
|
|
|
@@ -32,16 +32,12 @@
|
|
|
32
32
|
* than explaining constraints from a graph whose edges silently under-represent
|
|
33
33
|
* the real architecture.
|
|
34
34
|
*/
|
|
35
|
-
import {
|
|
36
|
-
blindSpotRows,
|
|
37
|
-
isWholeFileFailure,
|
|
38
|
-
unresolvableLiteralCount,
|
|
39
|
-
} from "../analysis/source-util.mjs";
|
|
40
35
|
import { UsageError } from "../errors.mjs";
|
|
41
36
|
import { judgeEdge } from "../rules/edge-constraints.mjs";
|
|
42
37
|
import { findConstraintsFor } from "../rules/tags.mjs";
|
|
43
38
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
44
39
|
import { formatContextReport } from "../report/context-text.mjs";
|
|
40
|
+
import { coverageVerdict } from "./coverage-verdict.mjs";
|
|
45
41
|
import { resolveProvenance } from "./provenance.mjs";
|
|
46
42
|
import { readAdrContext } from "./adr.mjs";
|
|
47
43
|
import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
|
|
@@ -150,27 +146,23 @@ export function contextCommand(projectName, commandContext, config) {
|
|
|
150
146
|
);
|
|
151
147
|
}
|
|
152
148
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
const complete =
|
|
163
|
-
notAnalyzed.length === 0 && blindSpotCount === 0 && commandContext.analysis.analyzed > 0;
|
|
164
|
-
const status = complete ? "ok" : "no-verdict";
|
|
165
|
-
const exitCode = complete ? 0 : 3;
|
|
149
|
+
// The completeness verdict is the shared constructor's, not this file's —
|
|
150
|
+
// the same law `graph`/`discover` run (#595, #599): unjudged sites and a
|
|
151
|
+
// zero-analyzed run defeat it here exactly as they do there, so a context
|
|
152
|
+
// report cannot look complete over a tree the run could not fully read
|
|
153
|
+
// while the faces beside it refuse the same tree. The restatement this
|
|
154
|
+
// replaces carried all three axes, so composing it moves no byte on any
|
|
155
|
+
// input.
|
|
156
|
+
const verdict = coverageVerdict(commandContext);
|
|
157
|
+
const { complete, status, exitCode } = verdict;
|
|
166
158
|
|
|
167
159
|
const coverage = {
|
|
168
160
|
complete,
|
|
169
161
|
projects: Object.keys(graph.nodes).length,
|
|
170
162
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
171
163
|
imports: commandContext.analysis.imports.length,
|
|
172
|
-
notAnalyzed,
|
|
173
|
-
blindSpots:
|
|
164
|
+
notAnalyzed: verdict.notAnalyzed,
|
|
165
|
+
blindSpots: verdict.blindSpots,
|
|
174
166
|
notes: [
|
|
175
167
|
"per-edge violations cover only depConstraints (3 of 15 violation types). " +
|
|
176
168
|
"A dependency with no violations here may still violate npm-ban, circular-dependency, " +
|
|
@@ -111,3 +111,49 @@ export function partitionUnownedCoverage({ rows, unownedGap, unclaimedFiles, tra
|
|
|
111
111
|
},
|
|
112
112
|
};
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Withdraws each accepted unclaimed file's failure from the run's failure
|
|
117
|
+
* list — the acceptance channel's effect on it, kept beside the matching so
|
|
118
|
+
* the channel's contract (accept the file ⇒ withdraw its refusal) is one
|
|
119
|
+
* module's. `./check.mjs` holds the list and narrows `acceptedFiles` to the
|
|
120
|
+
* unclaimed half; `./waivers.mjs` has no failure list and only reports rows.
|
|
121
|
+
*
|
|
122
|
+
* An accepted unclaimed file carries exactly one failure — its unowned-file
|
|
123
|
+
* refusal, the only row any producer appends for a file no analyzer ever
|
|
124
|
+
* read. The withdrawal does not TRUST that assumption, it ENFORCES it: an
|
|
125
|
+
* accepted file carrying more than one failure means some producer appended a
|
|
126
|
+
* second row for it, and a splice by `sourceFile` would take the second one
|
|
127
|
+
* down with the first — the failure count falls, `coverage.notAnalyzed` loses
|
|
128
|
+
* a row, and a could-not-look run tips toward a clean one, the silent
|
|
129
|
+
* direction (`../../../../AGENTS.md`). The run refuses instead, naming the
|
|
130
|
+
* file and the count it found.
|
|
131
|
+
*
|
|
132
|
+
* @param {{sourceFile: string}[]} failures The run's failure list, in order.
|
|
133
|
+
* @param {Set<string>} acceptedFiles The accepted subset of the unclaimed
|
|
134
|
+
* list — `partitionUnownedCoverage`'s `acceptedFiles` narrowed to that list
|
|
135
|
+
* by the caller.
|
|
136
|
+
* @returns {{sourceFile: string}[]} A new array without the accepted files'
|
|
137
|
+
* single failures; the input is not mutated.
|
|
138
|
+
* @throws {Error} when an accepted file carries more than one failure.
|
|
139
|
+
*/
|
|
140
|
+
export function withdrawAcceptedUnclaimedFailures(failures, acceptedFiles) {
|
|
141
|
+
const carried = new Map();
|
|
142
|
+
for (const failure of failures) {
|
|
143
|
+
if (!acceptedFiles.has(failure.sourceFile)) continue;
|
|
144
|
+
carried.set(failure.sourceFile, (carried.get(failure.sourceFile) ?? 0) + 1);
|
|
145
|
+
}
|
|
146
|
+
const overloaded = [...carried.entries()].filter(([, count]) => count > 1);
|
|
147
|
+
if (overloaded.length > 0) {
|
|
148
|
+
const named = overloaded
|
|
149
|
+
.map(([file, count]) => `'${file}' (carries ${count} failures)`)
|
|
150
|
+
.join(", ");
|
|
151
|
+
throw new Error(
|
|
152
|
+
`archkeep: the coverage.unowned acceptance channel cannot withdraw ${named} — ` +
|
|
153
|
+
`acceptance withdraws exactly one failure per file (the unowned-file refusal), and ` +
|
|
154
|
+
`withdrawing by file would drop the second one with it, understating what this run ` +
|
|
155
|
+
`could not read`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return failures.filter((failure) => !acceptedFiles.has(failure.sourceFile));
|
|
159
|
+
}
|
|
@@ -49,7 +49,7 @@ import {
|
|
|
49
49
|
isWholeFileFailure,
|
|
50
50
|
unresolvableLiteralCount,
|
|
51
51
|
} from "../analysis/source-util.mjs";
|
|
52
|
-
import { EXIT, coverageIncompleteReasons } from "../verdict.mjs";
|
|
52
|
+
import { EXIT, coverageComplete, coverageIncompleteReasons } from "../verdict.mjs";
|
|
53
53
|
import { buildDecision } from "../report/evidence.mjs";
|
|
54
54
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
55
55
|
import { formatCoverageIncomplete } from "../report/text.mjs";
|
|
@@ -96,7 +96,17 @@ export function coverageVerdict(commandContext, { acceptedFiles } = {}) {
|
|
|
96
96
|
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
97
97
|
const blindSpots = blindSpotRows(analysis.failures);
|
|
98
98
|
const blindSpotCount = unresolvableLiteralCount(analysis.failures);
|
|
99
|
-
|
|
99
|
+
// The conjunction is `../verdict.mjs`'s `coverageComplete` — the same
|
|
100
|
+
// predicate `verdictFor`'s decision face reads, so `check`'s envelope
|
|
101
|
+
// cannot carry a `coverage.complete` and a `decision.coverageComplete`
|
|
102
|
+
// that disagree about one run. The predicate is defined there, not here,
|
|
103
|
+
// because `verdictFor` needs it and this module already imports from that
|
|
104
|
+
// one: the reverse import would be a cycle.
|
|
105
|
+
const complete = coverageComplete({
|
|
106
|
+
unchecked: notAnalyzed.length,
|
|
107
|
+
blindSpotCount,
|
|
108
|
+
analyzed: analysis.analyzed,
|
|
109
|
+
});
|
|
100
110
|
return {
|
|
101
111
|
notAnalyzed,
|
|
102
112
|
blindSpots,
|
|
@@ -140,6 +140,7 @@ export function declaresCustomRules(config) {
|
|
|
140
140
|
* @returns {(artifact: string) => Uint8Array|null}
|
|
141
141
|
*/
|
|
142
142
|
export function readArtifactBytes(root) {
|
|
143
|
+
// used by its own test
|
|
143
144
|
return (artifact) => {
|
|
144
145
|
const abs = join(root, artifact);
|
|
145
146
|
if (containmentViolation(root, abs) !== null) return null;
|
|
@@ -79,6 +79,7 @@ import { namespacedId } from "./custom-rules.mjs";
|
|
|
79
79
|
* constraint: object|null}}|{ok: false, reason: string}}
|
|
80
80
|
*/
|
|
81
81
|
export function violationIdentity(violation) {
|
|
82
|
+
// used by its own test
|
|
82
83
|
if (violation === null || typeof violation !== "object" || Array.isArray(violation)) {
|
|
83
84
|
return { ok: false, reason: `violation is ${describe(violation)}, not an object` };
|
|
84
85
|
}
|
|
@@ -148,6 +149,7 @@ export function violationIdentity(violation) {
|
|
|
148
149
|
* original violation plus the `reason` its identity could not be stated.
|
|
149
150
|
*/
|
|
150
151
|
export function classifyViolations({ base, head, suppressions = [], now = referenceTime() }) {
|
|
152
|
+
// used by its own test
|
|
151
153
|
const baseIdentified = base.map(identityOf);
|
|
152
154
|
const headIdentified = head.map(identityOf);
|
|
153
155
|
|
|
@@ -237,6 +239,7 @@ export function classifyViolations({ base, head, suppressions = [], now = refere
|
|
|
237
239
|
* unknown: object[]}}
|
|
238
240
|
*/
|
|
239
241
|
export function classifyUnresolvableRecords({ base, head, sourceProjectOf }) {
|
|
242
|
+
// used by its own test
|
|
240
243
|
const attribute = sourceProjectOf ?? (() => null);
|
|
241
244
|
const baseIdentified = base
|
|
242
245
|
.filter(isUnresolvable)
|
|
@@ -48,10 +48,11 @@
|
|
|
48
48
|
import { readFileSync } from "node:fs";
|
|
49
49
|
|
|
50
50
|
import { canonicalJsonReplacer } from "../canonical.mjs";
|
|
51
|
+
import { isPlainObject } from "../values.mjs";
|
|
51
52
|
import { buildDependencies, buildProjects } from "./graph.mjs";
|
|
52
53
|
|
|
53
54
|
/** The only snapshot schemaVersion this module writes and reads. */
|
|
54
|
-
export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1;
|
|
55
|
+
export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1; // used by its own test
|
|
55
56
|
|
|
56
57
|
/**
|
|
57
58
|
* Builds the snapshot object from already-captured evidence.
|
|
@@ -337,6 +338,7 @@ export function readEvidenceSnapshot(path, io = {}) {
|
|
|
337
338
|
* @throws {Error} on every condition above.
|
|
338
339
|
*/
|
|
339
340
|
export function parseEvidenceSnapshot(text, path) {
|
|
341
|
+
// used by its own test
|
|
340
342
|
let parsed;
|
|
341
343
|
try {
|
|
342
344
|
parsed = JSON.parse(text);
|
|
@@ -518,11 +520,6 @@ export function providerMismatch(baselineProvider, currentProvider) {
|
|
|
518
520
|
);
|
|
519
521
|
}
|
|
520
522
|
|
|
521
|
-
/** Non-empty plain-object guard used across validation. */
|
|
522
|
-
function isPlainObject(value) {
|
|
523
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
523
|
/** Describes a value for error messages without dumping it. */
|
|
527
524
|
function describe(value) {
|
|
528
525
|
if (value === null) return "null";
|
package/src/commands/delta.mjs
CHANGED
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
import { stripTrailingSlashes } from "../path-util.mjs";
|
|
75
75
|
import { referenceTime } from "../governance/clock.mjs";
|
|
76
76
|
import {
|
|
77
|
+
assertReproducibleEventIdentity,
|
|
77
78
|
eventDedupeKey,
|
|
78
79
|
eventId,
|
|
79
80
|
EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
@@ -161,6 +162,7 @@ export function refusePluginGapHead(commandContext, activity) {
|
|
|
161
162
|
* @throws {Error} on either condition.
|
|
162
163
|
*/
|
|
163
164
|
export function refuseUnjudgeableHead(commandContext, activity) {
|
|
165
|
+
// used by its own test
|
|
164
166
|
refusePluginGapHead(commandContext, activity);
|
|
165
167
|
const notAnalyzed = commandContext.analysis.failures.filter(isWholeFileFailure);
|
|
166
168
|
|
|
@@ -271,6 +273,8 @@ export function captureDelta(commandContext, { config }) {
|
|
|
271
273
|
* type: string}[], workspaceLayout?: object, exemptedFiles?: string[]}} storedGraph
|
|
272
274
|
* A validated snapshot's `graph` section (`parseEvidenceSnapshot`).
|
|
273
275
|
* @returns {object} A graph `evaluate()` consumes.
|
|
276
|
+
* @throws {Error} when an `exemptedFiles` entry is not a string — the engine's
|
|
277
|
+
* own filter would drop it in silence (the refusal beside the copy below).
|
|
274
278
|
*/
|
|
275
279
|
export function evidenceGraphToProjectGraph(storedGraph) {
|
|
276
280
|
/** @type {Record<string, object>} */
|
|
@@ -297,7 +301,26 @@ export function evidenceGraphToProjectGraph(storedGraph) {
|
|
|
297
301
|
const graph = { nodes, dependencies };
|
|
298
302
|
if (storedGraph.workspaceLayout !== undefined)
|
|
299
303
|
graph.workspaceLayout = storedGraph.workspaceLayout;
|
|
300
|
-
if (Array.isArray(storedGraph.exemptedFiles))
|
|
304
|
+
if (Array.isArray(storedGraph.exemptedFiles)) {
|
|
305
|
+
// An entry that is not a string is refused here rather than carried:
|
|
306
|
+
// `../rules/index.mjs`'s `createContext` filters `graph.exemptedFiles`
|
|
307
|
+
// with `typeof file === "string"`, so a corrupted snapshot's entry would
|
|
308
|
+
// ride through this conversion and vanish there — the exemption set the
|
|
309
|
+
// snapshot recorded silently shrinking by one file, the under-count
|
|
310
|
+
// disclosed nowhere.
|
|
311
|
+
const malformed = storedGraph.exemptedFiles
|
|
312
|
+
.map((entry, at) => ({ entry, at }))
|
|
313
|
+
.filter(({ entry }) => typeof entry !== "string");
|
|
314
|
+
if (malformed.length > 0) {
|
|
315
|
+
const { entry, at } = malformed[0];
|
|
316
|
+
throw new Error(
|
|
317
|
+
`archkeep: the snapshot's graph.exemptedFiles[${at}] is ${JSON.stringify(entry)}, ` +
|
|
318
|
+
`not a string — the rule engine drops such entries in silence, which would shrink ` +
|
|
319
|
+
`the exemption set the snapshot recorded; re-capture the baseline or correct the file`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
graph.exemptedFiles = storedGraph.exemptedFiles;
|
|
323
|
+
}
|
|
301
324
|
return graph;
|
|
302
325
|
}
|
|
303
326
|
|
|
@@ -373,6 +396,7 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
|
|
|
373
396
|
* @returns {"accepted"|"rejected"|"no-verdict"}
|
|
374
397
|
*/
|
|
375
398
|
export function deltaDisposition({ status }) {
|
|
399
|
+
// used by its own test
|
|
376
400
|
if (status === "no-verdict") return "no-verdict";
|
|
377
401
|
if (status === "findings") return "rejected";
|
|
378
402
|
return "accepted";
|
|
@@ -810,26 +834,16 @@ export async function deltaCommand(
|
|
|
810
834
|
let eventWrite = null;
|
|
811
835
|
if (eventOut !== null && eventOut !== undefined) {
|
|
812
836
|
// F-delta-event-id: an evolution event is only written from a reproducible
|
|
813
|
-
// identity — a committed, clean head and a clean base
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
"collide on one event id. Commit the head, or capture without --event-out.",
|
|
824
|
-
);
|
|
825
|
-
}
|
|
826
|
-
if (baseline.provenance?.dirty === true || headProvenance?.dirty === true) {
|
|
827
|
-
throw new Error(
|
|
828
|
-
"archkeep: refusing to write a delta event from a dirty working tree — the event " +
|
|
829
|
-
"would name a commit whose evidence is uncommitted, and distinct uncommitted " +
|
|
830
|
-
"states would collide on one event id. Commit both sides first.",
|
|
831
|
-
);
|
|
832
|
-
}
|
|
837
|
+
// identity — a committed, clean head and a clean base, by the shared law
|
|
838
|
+
// `assertReproducibleEventIdentity` owns. The wording is frozen there; the
|
|
839
|
+
// messages consumers match on are byte-identical to the inline refusals
|
|
840
|
+
// this call replaces.
|
|
841
|
+
assertReproducibleEventIdentity({
|
|
842
|
+
label: "delta",
|
|
843
|
+
headCommit,
|
|
844
|
+
baseDirty: baseline.provenance?.dirty === true,
|
|
845
|
+
headDirty: headProvenance?.dirty === true,
|
|
846
|
+
});
|
|
833
847
|
// The architecture-debt sub-ledger (design §8): judged by re-running the
|
|
834
848
|
// current intent over this run's base and head graphs — a drift finding
|
|
835
849
|
// present at head but not base is introduced; one gone is resolved. Both
|
package/src/commands/drift.mjs
CHANGED
|
@@ -250,7 +250,7 @@ export async function driftForCheck(commandContext, io = {}) {
|
|
|
250
250
|
* @param {object} intent The normalized intent model.
|
|
251
251
|
* @returns {{kind: string, row: object}[]}
|
|
252
252
|
*/
|
|
253
|
-
|
|
253
|
+
function intentDecisionRefRows(intent) {
|
|
254
254
|
return governanceIntentRows(intent).filter(
|
|
255
255
|
({ row }) => typeof row?.decisionRef === "string" && row.decisionRef.trim() !== "",
|
|
256
256
|
);
|