@ecoma-io/archkeep 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -5
- package/cli.mjs +571 -61
- package/commands.mjs +57 -0
- package/lsp.mjs +15 -2
- package/package.json +8 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +485 -0
- package/src/analysis/dotnet/csproj.mjs +380 -0
- package/src/analysis/dotnet/mask.mjs +178 -0
- package/src/analysis/dotnet/namespaces.mjs +172 -0
- package/src/analysis/dotnet/resolve.mjs +89 -0
- package/src/analysis/go.mjs +289 -5
- package/src/analysis/java.mjs +329 -0
- package/src/analysis/jvm/gradle.mjs +545 -0
- package/src/analysis/jvm/mask.mjs +170 -0
- package/src/analysis/jvm/maven.mjs +612 -0
- package/src/analysis/jvm/packages.mjs +209 -0
- package/src/analysis/jvm/resolve.mjs +139 -0
- package/src/analysis/kotlin.mjs +210 -0
- package/src/analysis/manifest-util.mjs +30 -0
- package/src/analysis/python.mjs +3 -2
- package/src/analysis/registry.mjs +11 -0
- package/src/analysis/rust.mjs +171 -17
- package/src/analysis/source-util.mjs +155 -6
- package/src/analysis/typescript.mjs +11 -3
- package/src/commands/README.md +52 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +2 -1
- package/src/commands/context.mjs +124 -16
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +195 -33
- package/src/commands/delta-snapshot.mjs +156 -1
- package/src/commands/delta.mjs +142 -17
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +57 -0
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -0
- package/src/commands/trajectory.mjs +437 -0
- package/src/governance/profile-registry.mjs +0 -1
- package/src/graph/create-dependencies.mjs +138 -15
- package/src/lsp/diagnose.mjs +1 -1
- package/src/lsp/server.mjs +97 -1
- package/src/lsp/workspace-index.mjs +106 -15
- package/src/options.mjs +30 -7
- package/src/path-util.mjs +40 -0
- package/src/process.mjs +10 -1
- package/src/providers/moon.mjs +287 -36
- package/src/providers/native/differential.fixtures.mjs +32 -6
- package/src/providers/native/discover.mjs +83 -4
- package/src/providers/native/graph.mjs +58 -0
- package/src/providers/native/model.mjs +59 -1
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +82 -1
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +255 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/rules/index.mjs +21 -6
- package/src/rules/reachability.mjs +2 -0
- package/src/rules/tags.mjs +7 -5
- package/src/rules/topology.mjs +5 -3
- package/src/tsconfig-paths.mjs +3 -2
- package/src/workspace.mjs +115 -23
package/src/commands/context.mjs
CHANGED
|
@@ -26,9 +26,16 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
|
|
28
28
|
import { containmentViolation } from "../containment.mjs";
|
|
29
|
+
import { dotnetIndexFailures } from "../analysis/dotnet/namespaces.mjs";
|
|
30
|
+
import { dotnetManifestFailures } from "../analysis/dotnet/csproj.mjs";
|
|
31
|
+
import { goManifestFailures } from "../analysis/go.mjs";
|
|
32
|
+
import { mavenManifestFailures } from "../analysis/jvm/maven.mjs";
|
|
33
|
+
import { gradleManifestFailures } from "../analysis/jvm/gradle.mjs";
|
|
34
|
+
import { jvmIndexFailures } from "../analysis/jvm/packages.mjs";
|
|
29
35
|
import { languageOf } from "../analysis/registry.mjs";
|
|
30
36
|
import { pythonUnmodelledFailures } from "../analysis/python.mjs";
|
|
31
|
-
import {
|
|
37
|
+
import { rustManifestFailures } from "../analysis/rust.mjs";
|
|
38
|
+
import { dedupeWholeFileFailures, fileFailure } from "../analysis/source-util.mjs";
|
|
32
39
|
import {
|
|
33
40
|
DEFAULT_OPTIONS,
|
|
34
41
|
NX_CONFIG_FILE,
|
|
@@ -39,13 +46,18 @@ import {
|
|
|
39
46
|
import { readProjectGraph } from "../providers/nx.mjs";
|
|
40
47
|
import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
|
|
41
48
|
import {
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
MOON_ALT_WORKSPACE_MARKER,
|
|
50
|
+
MOON_WORKSPACE_MARKER,
|
|
44
51
|
mergeImportEdges,
|
|
45
52
|
moonMarkerAt,
|
|
46
53
|
moonProvider,
|
|
47
54
|
} from "../providers/moon.mjs";
|
|
48
55
|
import { nativeProvider } from "../providers/native/index.mjs";
|
|
56
|
+
import { mergeDeclaredEdges } from "../providers/native/graph.mjs";
|
|
57
|
+
import {
|
|
58
|
+
resolveDeclaredManifestEdges,
|
|
59
|
+
resolveDeclaredManifestFailures,
|
|
60
|
+
} from "../graph/create-dependencies.mjs";
|
|
49
61
|
import {
|
|
50
62
|
analyzeWorkspace,
|
|
51
63
|
annotateMFERemotes,
|
|
@@ -173,9 +185,22 @@ export function requireSingleProjectModel(root, { exists = existsSync } = {}) {
|
|
|
173
185
|
* two callers still differ in posture — one throws where the other returns a
|
|
174
186
|
* default — but they may not differ in what a workspace root IS.
|
|
175
187
|
*
|
|
188
|
+
* The Moon entries are the SHAPED markers — `workspace.yml` inside the
|
|
189
|
+
* directory, not the directory alone (`../providers/moon.mjs`'s
|
|
190
|
+
* `MOON_WORKSPACE_MARKER` owns why): a walk reading directory existence
|
|
191
|
+
* selected the user's home directory as a workspace through `~/.moon`,
|
|
192
|
+
* moonrepo's user-level state directory (#339). A bare `.moon` beside an
|
|
193
|
+
* `nx.json` at a root already chosen is a different question, and stays with
|
|
194
|
+
* `requireSingleProjectModel`'s directory-presence gate below.
|
|
195
|
+
*
|
|
176
196
|
* @type {string[]}
|
|
177
197
|
*/
|
|
178
|
-
export const WORKSPACE_MARKERS = [
|
|
198
|
+
export const WORKSPACE_MARKERS = [
|
|
199
|
+
NX_CONFIG_FILE,
|
|
200
|
+
ARCHKEEP_MODEL_FILE,
|
|
201
|
+
MOON_WORKSPACE_MARKER,
|
|
202
|
+
MOON_ALT_WORKSPACE_MARKER,
|
|
203
|
+
];
|
|
179
204
|
|
|
180
205
|
/**
|
|
181
206
|
* @typedef {object} CommandContext
|
|
@@ -495,9 +520,11 @@ export function resolveCommandContext(
|
|
|
495
520
|
if (root === null) {
|
|
496
521
|
throw new Error(
|
|
497
522
|
`archkeep: no workspace root above ${cwd} — looked for an nx.json, a archkeep.json, or a ` +
|
|
498
|
-
`.moon (or .config/moon)
|
|
499
|
-
`
|
|
500
|
-
`
|
|
523
|
+
`.moon/workspace.yml (or .config/moon/workspace.yml) in every parent, stopping at the top ` +
|
|
524
|
+
`level of the enclosing git repository: beyond it, a marker such as ~/.moon is user-level ` +
|
|
525
|
+
`tooling state, not this workspace's root. The tree to judge is found from the working ` +
|
|
526
|
+
`directory, never from this tool's own location: installed from the registry, this tool ` +
|
|
527
|
+
`lives under the consumer's node_modules and the two are always different trees.`,
|
|
501
528
|
);
|
|
502
529
|
}
|
|
503
530
|
// Which provider may judge at all — the one gate
|
|
@@ -573,6 +600,22 @@ export function resolveCommandContext(
|
|
|
573
600
|
});
|
|
574
601
|
annotateMFERemotes(graph.nodes, workspace.readFile);
|
|
575
602
|
annotatePackageFacts(graph.nodes, workspace.readFile);
|
|
603
|
+
// The manifest track's edges, folded into the graph the verdicts judge.
|
|
604
|
+
// ADR 0006 Decision 3 and ADR 0005 Decision 4 draw declared edges with no
|
|
605
|
+
// face qualifier, and this branch's graph IS the verdict input — without
|
|
606
|
+
// the fold, a cycle closed only by a `<ProjectReference>` (or a Maven
|
|
607
|
+
// `<dependency>`, or a Gradle `project(":x")`) reports empty, the one
|
|
608
|
+
// result this tool may never produce. The failure list doubles as the
|
|
609
|
+
// no-throw guard (`../graph/create-dependencies.mjs`'s
|
|
610
|
+
// `resolveDeclaredManifestFailures` — each resolver refuses on exactly
|
|
611
|
+
// the failures its `*ManifestFailures` twin reports, same memoized
|
|
612
|
+
// model), and the same list joins the funnel below, so a tree that WOULD
|
|
613
|
+
// refuse still refuses through the structured could-not-complete
|
|
614
|
+
// envelope instead of an unhandled error.
|
|
615
|
+
const manifestRefusalFailures = resolveDeclaredManifestFailures(workspace);
|
|
616
|
+
if (manifestRefusalFailures.length === 0) {
|
|
617
|
+
mergeDeclaredEdges(graph, resolveDeclaredManifestEdges(workspace));
|
|
618
|
+
}
|
|
576
619
|
|
|
577
620
|
// `boundaryConfigDeclared` is carried straight off the model rather than
|
|
578
621
|
// re-derived here: `../providers/native/model.mjs`'s
|
|
@@ -605,15 +648,21 @@ export function resolveCommandContext(
|
|
|
605
648
|
// sources feeding the SAME whole-file failure shape a language analyzer
|
|
606
649
|
// produces for an unreadable file, so nothing downstream needs to know
|
|
607
650
|
// which provider found the gap.
|
|
608
|
-
failures = [
|
|
651
|
+
failures = dedupeWholeFileFailures([
|
|
609
652
|
...wholeTreeAnalysis.failures.filter((failure) => selectedFiles.has(failure.sourceFile)),
|
|
610
653
|
...discovered.failures,
|
|
611
654
|
// Workspace-scoped on purpose, the same posture the two unclaimed
|
|
612
655
|
// equivalents above hold: a wildcard run must not be able to hide a
|
|
613
656
|
// project whose manifest it cannot read by naming a path that excludes
|
|
614
|
-
// it (`../analysis/python.mjs`'s `pythonUnmodelledFailures`).
|
|
657
|
+
// it (`../analysis/python.mjs`'s `pythonUnmodelledFailures`). The four
|
|
658
|
+
// manifest lists are computed once above, where the declared-edge fold
|
|
659
|
+
// needs them as its no-throw guard.
|
|
615
660
|
...pythonUnmodelledFailures(workspace),
|
|
616
|
-
|
|
661
|
+
...manifestRefusalFailures,
|
|
662
|
+
...goManifestFailures(workspace),
|
|
663
|
+
...rustManifestFailures(workspace),
|
|
664
|
+
...jvmIndexFailures(workspace),
|
|
665
|
+
]);
|
|
617
666
|
analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
|
|
618
667
|
analyzed = analyzedFiles.length;
|
|
619
668
|
// Unaffected by `paths`: an exempted file is by definition unowned by any
|
|
@@ -703,6 +752,16 @@ export function resolveCommandContext(
|
|
|
703
752
|
importSites: wholeTreeAnalysis.imports,
|
|
704
753
|
projectOf: (file) => projectOfFile.get(file),
|
|
705
754
|
});
|
|
755
|
+
// The manifest track folds in after the import sites, for the same
|
|
756
|
+
// no-face-qualifier reason the native branch above states at its own
|
|
757
|
+
// fold — Moon has no plugin hook to draw declared edges either, and its
|
|
758
|
+
// `dependsOn` entries are a different track (`moon.yml`), not a
|
|
759
|
+
// substitute for Maven/Gradle/csproj manifests tracked inside its
|
|
760
|
+
// projects. Same provable guard, same funnel reuse.
|
|
761
|
+
const manifestRefusalFailures = resolveDeclaredManifestFailures(workspace);
|
|
762
|
+
if (manifestRefusalFailures.length === 0) {
|
|
763
|
+
mergeDeclaredEdges(graph, resolveDeclaredManifestEdges(workspace));
|
|
764
|
+
}
|
|
706
765
|
|
|
707
766
|
const selected = selectFiles(
|
|
708
767
|
owned.map(({ file }) => file),
|
|
@@ -717,11 +776,15 @@ export function resolveCommandContext(
|
|
|
717
776
|
// analyzes the whole tree before `paths` narrows anything, for the same
|
|
718
777
|
// reason).
|
|
719
778
|
const unclaimedFiles = unclaimedAnalyzableFiles({ tracked, owned });
|
|
720
|
-
failures = [
|
|
779
|
+
failures = dedupeWholeFileFailures([
|
|
721
780
|
...wholeTreeAnalysis.failures.filter((failure) => selectedFiles.has(failure.sourceFile)),
|
|
722
781
|
...unclaimedFileFailures({ files: unclaimedFiles, providerLabel: "the Moon project graph" }),
|
|
723
782
|
...pythonUnmodelledFailures(workspace),
|
|
724
|
-
|
|
783
|
+
...manifestRefusalFailures,
|
|
784
|
+
...goManifestFailures(workspace),
|
|
785
|
+
...rustManifestFailures(workspace),
|
|
786
|
+
...jvmIndexFailures(workspace),
|
|
787
|
+
]);
|
|
725
788
|
unclaimedGap = { files: unclaimedFiles };
|
|
726
789
|
analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
|
|
727
790
|
analyzed = analyzedFiles.length;
|
|
@@ -778,11 +841,18 @@ export function resolveCommandContext(
|
|
|
778
841
|
// `discovered.failures` has, so a scoped `check <path>` cannot hide an
|
|
779
842
|
// orphan file elsewhere in the tree by naming a path that excludes it.
|
|
780
843
|
const unclaimedFiles = unclaimedAnalyzableFiles({ tracked, owned });
|
|
781
|
-
failures = [
|
|
844
|
+
failures = dedupeWholeFileFailures([
|
|
782
845
|
...failures,
|
|
783
846
|
...unclaimedFileFailures({ files: unclaimedFiles, providerLabel: "the Nx project graph" }),
|
|
784
847
|
...pythonUnmodelledFailures(workspace),
|
|
785
|
-
|
|
848
|
+
...goManifestFailures(workspace),
|
|
849
|
+
...rustManifestFailures(workspace),
|
|
850
|
+
...mavenManifestFailures(workspace),
|
|
851
|
+
...gradleManifestFailures(workspace),
|
|
852
|
+
...jvmIndexFailures(workspace),
|
|
853
|
+
...dotnetManifestFailures(workspace),
|
|
854
|
+
...dotnetIndexFailures(workspace),
|
|
855
|
+
]);
|
|
786
856
|
unclaimedGap = { files: unclaimedFiles };
|
|
787
857
|
// Same reason as the Moon branch above: `coverage.exempt` is a native-only
|
|
788
858
|
// concept, so an Nx workspace has nothing to report here.
|
|
@@ -802,8 +872,7 @@ export function resolveCommandContext(
|
|
|
802
872
|
// can name it correctly.
|
|
803
873
|
return {
|
|
804
874
|
root,
|
|
805
|
-
|
|
806
|
-
marker: hasMoon ? moonMarker : hasNative ? ARCHKEEP_MODEL_FILE : NX_CONFIG_FILE,
|
|
875
|
+
...workspaceNames(hasMoon, hasNative, moonMarker),
|
|
807
876
|
graph,
|
|
808
877
|
workspace,
|
|
809
878
|
tracked,
|
|
@@ -821,6 +890,45 @@ export function resolveCommandContext(
|
|
|
821
890
|
};
|
|
822
891
|
}
|
|
823
892
|
|
|
893
|
+
/**
|
|
894
|
+
* The provider/marker pair a workspace root carries, derived from marker
|
|
895
|
+
* presence through the one mapping every envelope header must agree on.
|
|
896
|
+
* `resolveCommandContext` reads it for its own context; a caller that needs
|
|
897
|
+
* the identity WITHOUT judging the tree — an envelope header over a run whose
|
|
898
|
+
* analyzed revisions each carry their own contexts (`./evolution.mjs`) —
|
|
899
|
+
* calls `describeWorkspaceRoot`, so the vocabulary ("nx"/"native"/"moon" and
|
|
900
|
+
* the marker that decided it) has exactly one home.
|
|
901
|
+
*
|
|
902
|
+
* @param {boolean} hasMoon Whether a Moon directory marks the root.
|
|
903
|
+
* @param {boolean} hasNative Whether `archkeep.json` marks the root.
|
|
904
|
+
* @param {string|null} moonMarker Which Moon directory is present.
|
|
905
|
+
* @returns {{provider: "nx" | "moon" | "native", marker: string}}
|
|
906
|
+
*/
|
|
907
|
+
function workspaceNames(hasMoon, hasNative, moonMarker) {
|
|
908
|
+
return {
|
|
909
|
+
provider: hasMoon ? "moon" : hasNative ? "native" : "nx",
|
|
910
|
+
marker: hasMoon ? moonMarker : hasNative ? ARCHKEEP_MODEL_FILE : NX_CONFIG_FILE,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* The workspace identity of `root` — which project model governs it and which
|
|
916
|
+
* marker decided that — without reading one source file or building one graph.
|
|
917
|
+
* The single-project-model gate runs here exactly as it does in
|
|
918
|
+
* `resolveCommandContext`: both markers present is refused here for the same
|
|
919
|
+
* reason it is refused there, because a caller about to describe this
|
|
920
|
+
* workspace must not name a model the full read would have refused.
|
|
921
|
+
*
|
|
922
|
+
* @param {string} root Absolute path to the workspace root.
|
|
923
|
+
* @returns {{provider: "nx" | "moon" | "native", marker: string}}
|
|
924
|
+
* @throws {Error} when more than one project-model marker is present
|
|
925
|
+
* (`requireSingleProjectModel`).
|
|
926
|
+
*/
|
|
927
|
+
export function describeWorkspaceRoot(root) {
|
|
928
|
+
const { hasNative, moonMarker } = requireSingleProjectModel(root);
|
|
929
|
+
return workspaceNames(moonMarker !== null, hasNative, moonMarker);
|
|
930
|
+
}
|
|
931
|
+
|
|
824
932
|
// Re-exported so a caller that only needs "does this tree look like a
|
|
825
933
|
// workspace at all" (`../../cli.mjs`'s `optionsForUsage`) is not forced to
|
|
826
934
|
// duplicate the marker check a second time; `DEFAULT_OPTIONS` rides along for
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
import { readFileSync } from "node:fs";
|
|
74
74
|
import { join } from "node:path";
|
|
75
75
|
|
|
76
|
+
import { canonicalizeJson } from "../canonical.mjs";
|
|
76
77
|
import { containmentViolation } from "../containment.mjs";
|
|
77
78
|
import { buildEvidenceBundle, serializeEvidenceBundle } from "../custom-rules/evidence.mjs";
|
|
78
79
|
import {
|
|
@@ -92,13 +93,14 @@ import { fitnessVerdict } from "../governance/verdict.mjs";
|
|
|
92
93
|
* `custom/<ruleName>/<findingId>`. Built here, once, and carried on the
|
|
93
94
|
* records this module hands back — so `../report/text.mjs`, `../report/sarif.mjs`
|
|
94
95
|
* and `../../cli.mjs`'s JSON envelope render an id rather than compose one,
|
|
95
|
-
* and three faces cannot come to spell the same finding three ways.
|
|
96
|
+
* and three faces cannot come to spell the same finding three ways. Exported
|
|
97
|
+
* for `./delta-classify.mjs`, whose classified entries name the same id.
|
|
96
98
|
*
|
|
97
99
|
* @param {string} ruleName
|
|
98
100
|
* @param {string} findingId
|
|
99
101
|
* @returns {string}
|
|
100
102
|
*/
|
|
101
|
-
function namespacedId(ruleName, findingId) {
|
|
103
|
+
export function namespacedId(ruleName, findingId) {
|
|
102
104
|
return `custom/${ruleName}/${findingId}`;
|
|
103
105
|
}
|
|
104
106
|
|
|
@@ -426,3 +428,285 @@ export async function customRulesForCheck(
|
|
|
426
428
|
|
|
427
429
|
return { decisions, overall: fitnessVerdictFor(decisions), catalogue, evidence };
|
|
428
430
|
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The base side's evidence facts, rebuilt from a validated snapshot.
|
|
434
|
+
*
|
|
435
|
+
* The mirror of `observedFacts`, over stored evidence: projects and edges come
|
|
436
|
+
* from the snapshot's `graph` section (already the normalized rows
|
|
437
|
+
* `buildProjects`/`buildDependencies` wrote), imports from the stored records
|
|
438
|
+
* attributed through the stored `owned` map. Attribution is NOT re-derived
|
|
439
|
+
* from root prefixes — ownership is the workspace layer's answer and the base
|
|
440
|
+
* workspace no longer exists to ask, which is exactly why the snapshot stores
|
|
441
|
+
* the map (`./delta-snapshot.mjs`, the optional-blocks section). A record the
|
|
442
|
+
* stored map does not claim keeps `sourceProject: undefined`, and
|
|
443
|
+
* `buildEvidenceBundle` refuses it by name — the caller routes that refusal to
|
|
444
|
+
* an `unknown` rule rather than a silently thinner evidence set.
|
|
445
|
+
*
|
|
446
|
+
* @param {{graph: {projects: object[], dependencies: object[]},
|
|
447
|
+
* records: object[], owned?: {file: string, project: string}[]}} baseline
|
|
448
|
+
* A validated snapshot (`parseEvidenceSnapshot`).
|
|
449
|
+
* @param {object} policy The CURRENT loaded boundary policy — both sides are
|
|
450
|
+
* judged under one law, the same bargain the boundary re-judgment strikes.
|
|
451
|
+
* @returns {{projects: object[], edges: object[], imports: object[], policy: object}}
|
|
452
|
+
*/
|
|
453
|
+
function baselineFacts(baseline, policy) {
|
|
454
|
+
const projectOfFile = new Map((baseline.owned ?? []).map(({ file, project }) => [file, project]));
|
|
455
|
+
return {
|
|
456
|
+
projects: baseline.graph.projects.map((project) => ({
|
|
457
|
+
name: project.name,
|
|
458
|
+
root: project.root,
|
|
459
|
+
tags: Array.isArray(project.tags) ? project.tags : [],
|
|
460
|
+
})),
|
|
461
|
+
edges: baseline.graph.dependencies,
|
|
462
|
+
imports: baseline.records.map((site) => ({
|
|
463
|
+
site,
|
|
464
|
+
sourceProject: projectOfFile.get(site.sourceFile),
|
|
465
|
+
})),
|
|
466
|
+
policy,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The reason a declared head row cannot be judged on both sides, or `null`
|
|
472
|
+
* when the baseline row pins the identical law.
|
|
473
|
+
*
|
|
474
|
+
* Digest drift and params drift are the same refusal: the artifact bytes and
|
|
475
|
+
* the declared parameters together ARE the rule's law (params ride inside the
|
|
476
|
+
* evidence bundle), and a finding difference under a law that itself moved
|
|
477
|
+
* cannot be attributed to the code — the same reasoning the policy-fingerprint
|
|
478
|
+
* note states for the boundary side, but per rule and fail-closed to
|
|
479
|
+
* `unknown` because unlike the boundary law the OLD custom law cannot be
|
|
480
|
+
* re-applied: only the head artifact exists to run.
|
|
481
|
+
*
|
|
482
|
+
* @param {{name: string, sha256: string, params?: object}} row Head-declared.
|
|
483
|
+
* @param {{sha256: string, params?: object}|undefined} baseRow The stored row.
|
|
484
|
+
* @returns {string|null}
|
|
485
|
+
*/
|
|
486
|
+
function unjudgeableRowReason(row, baseRow) {
|
|
487
|
+
if (baseRow === undefined) {
|
|
488
|
+
return (
|
|
489
|
+
"no base-side evidence exists for this rule — the baseline does not declare it, so its " +
|
|
490
|
+
"findings cannot be told apart from pre-existing ones; re-capture the baseline with the " +
|
|
491
|
+
"rule declared"
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
if (baseRow.sha256 !== row.sha256) {
|
|
495
|
+
return (
|
|
496
|
+
`the rule's artifact digest changed between capture (${baseRow.sha256}) and head ` +
|
|
497
|
+
`(${row.sha256}) — the law itself moved, so a finding difference cannot be attributed ` +
|
|
498
|
+
`to the code; re-capture the baseline under the current artifact`
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
if (canonicalizeJson(baseRow.params ?? null) !== canonicalizeJson(row.params ?? null)) {
|
|
502
|
+
return (
|
|
503
|
+
"the rule's declared params changed between capture and head — params ride inside the " +
|
|
504
|
+
"evidence bundle, so this is law drift exactly as a digest change is; re-capture the " +
|
|
505
|
+
"baseline under the current declaration"
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Every custom rule the head policy declares, evaluated over BOTH sides of a
|
|
513
|
+
* delta — the current tree's facts and a baseline snapshot's stored ones —
|
|
514
|
+
* under the current declaration.
|
|
515
|
+
*
|
|
516
|
+
* Fail-closed throughout: every path that cannot produce a two-sided judgment
|
|
517
|
+
* lands the rule in `unknownRules` with a reason a reader can act on, never in
|
|
518
|
+
* `judged` with a thinner answer — with ONE exception, deliberately shared
|
|
519
|
+
* with `customRulesForCheck`: a LOAD-class failure (unreadable artifact, hash
|
|
520
|
+
* mismatch, bytes that are not the contract) THROWS, because the head law
|
|
521
|
+
* could not be read at all and `check` on the same tree would refuse the same
|
|
522
|
+
* way; a delta that soft-reported it would let a permanently unloadable rule
|
|
523
|
+
* ride every delta as one more unknown row.
|
|
524
|
+
*
|
|
525
|
+
* The routes into `unknownRules`, each with its reason:
|
|
526
|
+
* - the baseline carries no custom-rule blocks at all (`baselineAbsentReason`
|
|
527
|
+
* below rides every rule);
|
|
528
|
+
* - the baseline never declared this rule (added since capture);
|
|
529
|
+
* - digest or params drift (`unjudgeableRowReason`);
|
|
530
|
+
* - either side's evidence bundle refuses to build (an unattributable stored
|
|
531
|
+
* record, a graph row the bundle cannot read);
|
|
532
|
+
* - either side's evaluation fails, or the rule itself answers `unknown`;
|
|
533
|
+
* - the rule answers `not_applicable` on exactly one side (the asymmetric
|
|
534
|
+
* case the paragraph below argues).
|
|
535
|
+
*
|
|
536
|
+
* A rule that answers `not_applicable` on BOTH sides contributes an EMPTY
|
|
537
|
+
* finding list per side plus a note naming each reason — not applicable is a
|
|
538
|
+
* judged answer, not a failure (`../governance/fitness-rules.mjs` draws the
|
|
539
|
+
* same line). A rule that answers `not_applicable` on only ONE side lands in
|
|
540
|
+
* `unknownRules` instead: an empty list for the inapplicable side beside real
|
|
541
|
+
* findings (or a judged pass) on the other would classify every base finding
|
|
542
|
+
* as resolved — or every head finding as introduced — on the strength of a
|
|
543
|
+
* side the rule never judged, which is the silent direction.
|
|
544
|
+
*
|
|
545
|
+
* Rules the baseline declares that the head no longer does are returned as
|
|
546
|
+
* `removedRules` — nothing is judged for them (the head declares no law to
|
|
547
|
+
* run), and the caller turns the list into a coverage note.
|
|
548
|
+
*
|
|
549
|
+
* @param {object} commandContext From `./context.mjs`'s `resolveCommandContext`.
|
|
550
|
+
* @param {{rows: object[], policy: object, baseline: object,
|
|
551
|
+
* readArtifact?: (artifact: string) => Uint8Array|null, timeoutMs?: number}} run
|
|
552
|
+
* `rows` is the head policy's validated `customRules` list, `policy` the
|
|
553
|
+
* loaded head policy, `baseline` the validated evidence snapshot.
|
|
554
|
+
* @returns {Promise<{judged: {name: string, sha256: string,
|
|
555
|
+
* baseFindings: object[], headFindings: object[], notes?: string[]}[],
|
|
556
|
+
* unknownRules: {name: string, reason: string}[], removedRules: string[],
|
|
557
|
+
* catalogue: {ruleId: string, rule: string, findingId: string, message: string}[]}>}
|
|
558
|
+
* `judged` findings are each side's verdict-document findings verbatim
|
|
559
|
+
* (un-namespaced ids — `./delta-classify.mjs` namespaces per entry).
|
|
560
|
+
* @throws {Error} on any load-class failure, naming the rule and the reason.
|
|
561
|
+
*/
|
|
562
|
+
export async function customRulesForDelta(
|
|
563
|
+
commandContext,
|
|
564
|
+
{ rows, policy, baseline, readArtifact, timeoutMs = CUSTOM_RULE_TIMEOUT_MS },
|
|
565
|
+
) {
|
|
566
|
+
/** @type {{name: string, reason: string}[]} */
|
|
567
|
+
const unknownRules = [];
|
|
568
|
+
/** @type {object[]} */
|
|
569
|
+
const judgeableRows = [];
|
|
570
|
+
const baseRows =
|
|
571
|
+
baseline.customRules === undefined
|
|
572
|
+
? null
|
|
573
|
+
: new Map(baseline.customRules.map((row) => [row.name, row]));
|
|
574
|
+
|
|
575
|
+
for (const row of rows) {
|
|
576
|
+
if (baseRows === null) {
|
|
577
|
+
unknownRules.push({
|
|
578
|
+
name: row.name,
|
|
579
|
+
reason:
|
|
580
|
+
"the baseline carries no custom-rule evidence — it was captured before custom rules " +
|
|
581
|
+
"were declared, or by a version that did not store them; re-capture the baseline",
|
|
582
|
+
});
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
const reason = unjudgeableRowReason(row, baseRows.get(row.name));
|
|
586
|
+
if (reason !== null) {
|
|
587
|
+
unknownRules.push({ name: row.name, reason });
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
judgeableRows.push(row);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const removedRules =
|
|
594
|
+
baseRows === null
|
|
595
|
+
? []
|
|
596
|
+
: baseline.customRules
|
|
597
|
+
.filter((baseRow) => !rows.some((row) => row.name === baseRow.name))
|
|
598
|
+
.map((baseRow) => baseRow.name);
|
|
599
|
+
|
|
600
|
+
// The load pass, whole-law-or-refuse, exactly as `customRulesForCheck`:
|
|
601
|
+
// every judgeable rule is loaded before any is evaluated.
|
|
602
|
+
const read = readArtifact ?? readArtifactBytes(commandContext.root);
|
|
603
|
+
/** @type {{row: object, module: WebAssembly.Module, describe: Record<string, any>}[]} */
|
|
604
|
+
const loaded = [];
|
|
605
|
+
for (const row of judgeableRows) {
|
|
606
|
+
const artifactBytes = read(row.artifact);
|
|
607
|
+
if (artifactBytes === null || artifactBytes === undefined) {
|
|
608
|
+
refuseLoad(
|
|
609
|
+
row.name,
|
|
610
|
+
`the artifact "${row.artifact}" could not be read — a path that does not exist, cannot ` +
|
|
611
|
+
`be opened, or resolves through a symlink out of the workspace all reach this run as ` +
|
|
612
|
+
`no bytes at all, and a declared law with no bytes behind it is a run that refuses ` +
|
|
613
|
+
`rather than a rule that quietly judges nothing`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
const outcome = await loadCustomRule({
|
|
617
|
+
name: row.name,
|
|
618
|
+
artifactBytes,
|
|
619
|
+
declaredSha256: row.sha256,
|
|
620
|
+
timeoutMs,
|
|
621
|
+
});
|
|
622
|
+
if (!outcome.ok) throw new Error(`archkeep: ${outcome.failure.reason}`);
|
|
623
|
+
loaded.push({ row, module: outcome.module, describe: outcome.describe });
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const catalogue = loaded.flatMap(({ row, describe }) =>
|
|
627
|
+
describe.findings.map((entry) => ({
|
|
628
|
+
ruleId: namespacedId(row.name, entry.id),
|
|
629
|
+
rule: row.name,
|
|
630
|
+
findingId: entry.id,
|
|
631
|
+
message: entry.message,
|
|
632
|
+
})),
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
const sides = [
|
|
636
|
+
{ side: "base", observed: baselineFacts(baseline, policy) },
|
|
637
|
+
{ side: "head", observed: observedFacts(commandContext, policy) },
|
|
638
|
+
];
|
|
639
|
+
|
|
640
|
+
/** @type {{name: string, sha256: string, baseFindings: object[], headFindings: object[], notes?: string[]}[]} */
|
|
641
|
+
const judged = [];
|
|
642
|
+
for (const { row, module, describe } of loaded) {
|
|
643
|
+
/** @type {Record<string, object[]>} */
|
|
644
|
+
const findingsBySide = {};
|
|
645
|
+
/** @type {string[]} */
|
|
646
|
+
const notes = [];
|
|
647
|
+
/** @type {string[]} */
|
|
648
|
+
const notApplicableSides = [];
|
|
649
|
+
/** @type {string|null} */
|
|
650
|
+
let unknownReason = null;
|
|
651
|
+
for (const { side, observed } of sides) {
|
|
652
|
+
let evidenceBytes;
|
|
653
|
+
try {
|
|
654
|
+
evidenceBytes = serializeEvidenceBundle(buildEvidenceBundle({ ...observed, rule: row }));
|
|
655
|
+
} catch (cause) {
|
|
656
|
+
// Fail-closed on BOTH sides, base and head alike: an evidence set the
|
|
657
|
+
// bundle refuses (an unattributable record above all) is a side this
|
|
658
|
+
// rule cannot honestly judge, and the rule says so rather than being
|
|
659
|
+
// judged over the records that survived.
|
|
660
|
+
unknownReason =
|
|
661
|
+
`the ${side}-side evidence could not be assembled: ` +
|
|
662
|
+
`${cause instanceof Error ? cause.message : String(cause)}`;
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
const outcome = await evaluateCustomRule({ module, describe, evidenceBytes, timeoutMs });
|
|
666
|
+
if (!outcome.ok) {
|
|
667
|
+
unknownReason = `on the ${side} side, ${outcome.failure.reason}`;
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
if (outcome.verdict.verdict === "unknown") {
|
|
671
|
+
unknownReason = `the rule could not judge the ${side} side — ${outcome.verdict.reason}`;
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
if (outcome.verdict.verdict === "not_applicable") {
|
|
675
|
+
notes.push(`${side} side: not applicable — ${outcome.verdict.notApplicableReason}`);
|
|
676
|
+
notApplicableSides.push(side);
|
|
677
|
+
findingsBySide[side] = [];
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
findingsBySide[side] = outcome.verdict.findings;
|
|
681
|
+
}
|
|
682
|
+
// Applicability must be SYMMETRIC to judge a delta: a rule that did not
|
|
683
|
+
// apply on one side contributed an empty list there, and classifying real
|
|
684
|
+
// findings from the other side against that emptiness would call base
|
|
685
|
+
// findings resolved — or head findings introduced — on the strength of a
|
|
686
|
+
// side the rule never judged. Both-sides not_applicable stays a judged
|
|
687
|
+
// (empty) answer; one-sided lands in `unknownRules`, fail-closed.
|
|
688
|
+
if (unknownReason === null && notApplicableSides.length === 1) {
|
|
689
|
+
const inapplicable = notApplicableSides[0];
|
|
690
|
+
const applicable = inapplicable === "base" ? "head" : "base";
|
|
691
|
+
unknownReason =
|
|
692
|
+
`the rule did not apply at ${inapplicable} while it judged the ${applicable} side — ` +
|
|
693
|
+
(inapplicable === "head"
|
|
694
|
+
? `base findings cannot be called resolved by a side the rule did not judge`
|
|
695
|
+
: `head findings cannot be called introduced against a side the rule did not judge`) +
|
|
696
|
+
`; ${notes.join("; ")}`;
|
|
697
|
+
}
|
|
698
|
+
if (unknownReason !== null) {
|
|
699
|
+
unknownRules.push({ name: row.name, reason: unknownReason });
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
judged.push({
|
|
703
|
+
name: row.name,
|
|
704
|
+
sha256: row.sha256,
|
|
705
|
+
baseFindings: findingsBySide.base,
|
|
706
|
+
headFindings: findingsBySide.head,
|
|
707
|
+
...(notes.length === 0 ? {} : { notes }),
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return { judged, unknownRules, removedRules, catalogue };
|
|
712
|
+
}
|