@ecoma-io/archkeep 0.22.1 → 0.23.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/package.json +40 -13
- package/src/analysis/jvm/packages.mjs +0 -17
- package/src/analysis/manifest-util.mjs +14 -5
- package/src/architecture-intent/judge.mjs +1 -1
- package/src/architecture-intent/model.mjs +1 -11
- package/src/commands/README.md +16 -7
- package/src/commands/change-intent.mjs +2 -11
- package/src/commands/check.mjs +69 -5
- package/src/commands/completeness.mjs +0 -32
- package/src/commands/context-command.mjs +1 -1
- package/src/commands/context.mjs +46 -47
- package/src/commands/diff.mjs +1 -1
- package/src/commands/discover.mjs +7 -3
- package/src/commands/evaluation-primitives.mjs +6 -2
- package/src/commands/impact-reachability.mjs +104 -0
- package/src/commands/impact.mjs +9 -71
- package/src/commands/scenario-evaluation.mjs +1 -1
- package/src/config.mjs +1 -15
- package/src/custom-rules/evidence.mjs +1 -1
- package/src/custom-rules/host.mjs +2 -2
- package/src/custom-rules/values.mjs +8 -3
- package/src/errors.mjs +1 -1
- package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -5
- package/src/governance/adr-registry.mjs +31 -16
- package/src/governance/decision-graph.mjs +1 -1
- package/src/governance/evolution-store.mjs +33 -16
- package/src/governance/fitness-registry.mjs +1 -11
- package/src/governance/profile-registry.mjs +20 -23
- package/src/governance/provenance-record.mjs +1 -11
- package/src/governance/reconcile-score.mjs +0 -3
- package/src/governance/row-schema.mjs +1 -11
- package/src/governance/verdict.mjs +168 -4
- package/src/intent/intent-manifest.json +12 -12
- package/src/lsp/diagnose.mjs +2 -2
- package/src/lsp/server.mjs +1 -1
- package/src/lsp/workspace-index.mjs +3 -3
- package/src/options.mjs +1 -1
- package/src/providers/model-gate.mjs +59 -0
- package/src/providers/moon.mjs +1 -1
- package/src/providers/native/model.mjs +2 -16
- package/src/report/README.md +13 -7
- package/src/report/evidence.mjs +11 -168
- package/src/report/json.mjs +10 -7
- package/src/report/sarif.mjs +29 -4
- package/src/report/text.mjs +39 -0
- package/src/rules/README.md +18 -9
- package/src/{commands → rules}/edge-constraints.mjs +18 -12
- package/src/values.mjs +49 -0
- package/src/verdict.mjs +25 -5
- package/src/workspace.mjs +29 -0
package/package.json
CHANGED
|
@@ -1,22 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecoma-io/archkeep",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Architecture
|
|
3
|
+
"version": "0.23.0",
|
|
4
|
+
"description": "Architecture authority for human and agentic software development — deterministic, evidence-backed enforcement of declared architecture.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"architecture",
|
|
7
|
-
"
|
|
7
|
+
"software-architecture",
|
|
8
|
+
"architecture-governance",
|
|
9
|
+
"architecture-enforcement",
|
|
10
|
+
"architecture-as-code",
|
|
11
|
+
"architecture-drift",
|
|
12
|
+
"architecture-compliance",
|
|
13
|
+
"architecture-rules",
|
|
14
|
+
"dependency-analysis",
|
|
15
|
+
"dependency-governance",
|
|
8
16
|
"dependency-graph",
|
|
9
|
-
"monorepo",
|
|
10
|
-
"module-boundaries",
|
|
11
|
-
"polyglot",
|
|
12
17
|
"static-analysis",
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
18
|
+
"code-analysis",
|
|
19
|
+
"code-governance",
|
|
20
|
+
"architecture-fitness",
|
|
21
|
+
"fitness-functions",
|
|
22
|
+
"architecture-decision",
|
|
23
|
+
"architecture-decision-record",
|
|
24
|
+
"adr",
|
|
25
|
+
"architecture-evidence",
|
|
26
|
+
"evidence-based",
|
|
27
|
+
"deterministic-analysis",
|
|
28
|
+
"deterministic-verdicts",
|
|
29
|
+
"drift-detection",
|
|
30
|
+
"change-impact-analysis",
|
|
31
|
+
"architecture-debt",
|
|
32
|
+
"architecture-health",
|
|
33
|
+
"architecture-discovery",
|
|
34
|
+
"architecture-reconciliation",
|
|
35
|
+
"polyglot",
|
|
36
|
+
"monorepo",
|
|
37
|
+
"multi-repo",
|
|
38
|
+
"cross-repo",
|
|
39
|
+
"coding-agents",
|
|
40
|
+
"ai-agents",
|
|
41
|
+
"agentic-development",
|
|
42
|
+
"developer-tools",
|
|
43
|
+
"developer-experience",
|
|
44
|
+
"software-governance",
|
|
45
|
+
"engineering-governance",
|
|
46
|
+
"continuous-compliance"
|
|
20
47
|
],
|
|
21
48
|
"type": "module",
|
|
22
49
|
"license": "Apache-2.0",
|
|
@@ -190,20 +190,3 @@ export function resolveJvmPackagePrefix(specifier, index) {
|
|
|
190
190
|
}
|
|
191
191
|
return null;
|
|
192
192
|
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* The owning project of a resolved prefix, when exactly one project claims
|
|
196
|
-
* the matched name.
|
|
197
|
-
*
|
|
198
|
-
* @param {{ owners: { project: string }[], prefix: string }} resolution
|
|
199
|
-
* @returns {{ target: string, ambiguous?: undefined } |
|
|
200
|
-
* { target: null, ambiguous: true, projects: string[] }} A single
|
|
201
|
-
* target when the owners agree; otherwise every distinct claimant, for the
|
|
202
|
-
* caller's failure record.
|
|
203
|
-
*/
|
|
204
|
-
export function projectOfResolution(resolution) {
|
|
205
|
-
const projects = [...new Set(resolution.owners.map((owner) => owner.project))];
|
|
206
|
-
return projects.length === 1
|
|
207
|
-
? { target: projects[0] }
|
|
208
|
-
: { target: null, ambiguous: true, projects };
|
|
209
|
-
}
|
|
@@ -67,8 +67,12 @@ export function resolveWithinWorkspace(baseDir, relative) {
|
|
|
67
67
|
return segments.join("/");
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
70
|
+
/**
|
|
71
|
+
* A pattern carrying any of these is routed to the glob matcher; everything
|
|
72
|
+
* else compares equal. `(` rides for extglob — `+(x).txt` matches `x.txt`
|
|
73
|
+
* (measured on Node v24) — the character #671 proved the table was missing.
|
|
74
|
+
*/
|
|
75
|
+
const GLOB_METACHARACTERS = /[*?[{(\\]/;
|
|
72
76
|
|
|
73
77
|
/**
|
|
74
78
|
* Does a file's basename match any of a manifest-name pattern list — the
|
|
@@ -82,9 +86,14 @@ const GLOB_METACHARACTERS = /[*?[{\\]/;
|
|
|
82
86
|
* the equality scan it replaced was nanoseconds, because each call compiles
|
|
83
87
|
* its pattern again. The matcher is injected so `../../providers/native/
|
|
84
88
|
* model.mjs`'s validated one and raw `path.posix.matchesGlob` ride the same
|
|
85
|
-
* fast path without this module reaching for either
|
|
86
|
-
*
|
|
87
|
-
*
|
|
89
|
+
* fast path without this module reaching for either. The rule the table has
|
|
90
|
+
* to hold: every pattern carrying a character that can alter the glob's
|
|
91
|
+
* answer is routed to it, and everything else compares equal — a pattern
|
|
92
|
+
* the table does not carry is literal outside constructs the table already
|
|
93
|
+
* routes, so the two answers agree, and routing a literal anyway (an
|
|
94
|
+
* unbalanced `a(b`) costs only the matcher call. #671: `(` was missing
|
|
95
|
+
* from the table, so an extglob manifest pattern like `+(x).csproj` was
|
|
96
|
+
* compared by equality and missed every file the pattern named.
|
|
88
97
|
*
|
|
89
98
|
* @param {string} base The basename under test.
|
|
90
99
|
* @param {readonly string[]} patterns
|
|
@@ -129,7 +129,7 @@ function directEdges(graph) {
|
|
|
129
129
|
* three copies of the same filter is how one of them drifts.
|
|
130
130
|
*
|
|
131
131
|
* `../../src/rules/reachability.mjs`'s `buildReachability` itself stays
|
|
132
|
-
* type-agnostic on purpose:
|
|
132
|
+
* type-agnostic on purpose: `../rules/edge-constraints.mjs`'s
|
|
133
133
|
* `declaredEdgeViolationsForCheck` and `../../src/rules/index.mjs`'s
|
|
134
134
|
* `evaluate()` both deliberately hand it the UNFILTERED graph, because
|
|
135
135
|
* `depConstraints`' `notDependOnLibsWithTags` is a different, tag-based
|
|
@@ -43,6 +43,7 @@ import { resolve } from "node:path";
|
|
|
43
43
|
import { containmentViolation } from "../containment.mjs";
|
|
44
44
|
|
|
45
45
|
import { isValidSelector, splitSelector } from "./selectors.mjs";
|
|
46
|
+
import { describe, isPlainObject } from "../values.mjs";
|
|
46
47
|
import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "../governance/row-schema.mjs";
|
|
47
48
|
|
|
48
49
|
/** The base name of the root file this module reads. */
|
|
@@ -121,17 +122,6 @@ const ROW_KEYS = Object.freeze(["from", "to", "reason", "optional", "decisionRef
|
|
|
121
122
|
/** A boundary `name`, matched exactly by the loaders — names can never contain `:`, so a name can never collide with a `name:`-prefixed selector. */
|
|
122
123
|
const NAME_PATTERN = /^[a-zA-Z0-9_-]+$/u;
|
|
123
124
|
|
|
124
|
-
/** A value's type, for an error message that shows what was actually there. */
|
|
125
|
-
function describe(value) {
|
|
126
|
-
if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
|
|
127
|
-
if (value === null) return "null";
|
|
128
|
-
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** @type {(value: unknown) => value is Record<string, unknown>} */
|
|
132
|
-
const isPlainObject = (value) =>
|
|
133
|
-
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
134
|
-
|
|
135
125
|
/** `key` on `obj` that is not one of `allowed` — for the reject-by-name rule. */
|
|
136
126
|
function unknownKeys(obj, allowed) {
|
|
137
127
|
return Object.keys(obj).filter((key) => !allowed.includes(key));
|
package/src/commands/README.md
CHANGED
|
@@ -377,10 +377,19 @@ the resolution order.
|
|
|
377
377
|
`check` folds into the fitness exit lanes plus the finding catalogue SARIF's
|
|
378
378
|
descriptors are built from; it prints nothing and decides no exit code.
|
|
379
379
|
|
|
380
|
-
- **`
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
(`
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
380
|
+
- **`impact-reachability.mjs`** — reverse reachability (`computeImpact`: a
|
|
381
|
+
project's direct and transitive dependents) shared by the `impact`,
|
|
382
|
+
`scenario` and `context --plan` commands and the canonical evaluator
|
|
383
|
+
(`./evaluation-primitives.mjs`). It lives apart from `./impact.mjs` rather
|
|
384
|
+
than inside it because that module drives `./impact-statement.mjs`, which
|
|
385
|
+
drives the evaluator — holding the walk in the command module is what made
|
|
386
|
+
`evaluation-primitives → impact → impact-statement → evaluation-primitives`
|
|
387
|
+
the engine's only import cycle (#644). `./impact.mjs` re-exports
|
|
388
|
+
`computeImpact`, so its existing importers keep resolving; new code imports
|
|
389
|
+
this module.
|
|
390
|
+
|
|
391
|
+
The edge-constraint analysis `diff`, `impact` and `check` share is not one of
|
|
392
|
+
these modules: it judges graph edges, so it lives in the rules layer whose
|
|
393
|
+
constraint table and tag matching it reads (`../rules/edge-constraints.mjs`) —
|
|
394
|
+
which is also what lets `../lsp/diagnose.mjs` share it without importing from
|
|
395
|
+
this application layer (#649).
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
|
|
42
42
|
import { readFile as readFileFromDisk } from "node:fs/promises";
|
|
43
43
|
|
|
44
|
+
import { describe, isPlainObject } from "../values.mjs";
|
|
45
|
+
|
|
44
46
|
/** The only `version` this module accepts. A different value is a load error. */
|
|
45
47
|
export const CHANGE_INTENT_VERSION = "1";
|
|
46
48
|
|
|
@@ -108,17 +110,6 @@ export function edgePairKey({ from, to }) {
|
|
|
108
110
|
return `${from}\u0000${to}`;
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
/** A value's type, for an error message that shows what was actually there. */
|
|
112
|
-
function describe(value) {
|
|
113
|
-
if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
|
|
114
|
-
if (value === null) return "null";
|
|
115
|
-
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** @type {(value: unknown) => value is Record<string, unknown>} */
|
|
119
|
-
const isPlainObject = (value) =>
|
|
120
|
-
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
121
|
-
|
|
122
113
|
/** `key` on `obj` that is not one of `allowed` — the reject-by-name rule. */
|
|
123
114
|
function unknownKeys(obj, allowed) {
|
|
124
115
|
return Object.keys(obj).filter((key) => !allowed.includes(key));
|
package/src/commands/check.mjs
CHANGED
|
@@ -23,11 +23,15 @@ import { stripTrailingSlashes } from "../path-util.mjs";
|
|
|
23
23
|
import { suppressionCovers } from "../config.mjs";
|
|
24
24
|
import { referenceTime } from "../governance/clock.mjs";
|
|
25
25
|
import { suppressionFate } from "../governance/waiver.mjs";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
resolveCommandContext,
|
|
28
|
+
unownedGapWithoutRunConfiguration,
|
|
29
|
+
untrackedOwnedFiles,
|
|
30
|
+
} from "./context.mjs";
|
|
27
31
|
import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
|
|
28
32
|
import { readAdrContext } from "./adr.mjs";
|
|
29
33
|
import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
|
|
30
|
-
import { declaredEdgeViolationsForCheck } from "
|
|
34
|
+
import { declaredEdgeViolationsForCheck } from "../rules/edge-constraints.mjs";
|
|
31
35
|
import { customRulesForCheck, declaresCustomRules } from "./custom-rules.mjs";
|
|
32
36
|
import { driftForCheck } from "./drift.mjs";
|
|
33
37
|
import { fitnessForCheck } from "./fitness.mjs";
|
|
@@ -44,7 +48,7 @@ import { evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
|
|
|
44
48
|
import { orphanedNotDependOnTags, unmatchedConstraintRows } from "../rules/tags.mjs";
|
|
45
49
|
import { judgeTsconfigPaths } from "../tsconfig-paths.mjs";
|
|
46
50
|
import { verdictFor } from "../verdict.mjs";
|
|
47
|
-
import { listTrackedFiles } from "../workspace.mjs";
|
|
51
|
+
import { listTrackedFiles, listUntrackedFiles } from "../workspace.mjs";
|
|
48
52
|
|
|
49
53
|
/**
|
|
50
54
|
* A total order over violations, so a report's byte sequence is an invariant
|
|
@@ -184,10 +188,21 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
|
|
|
184
188
|
* readers: a test drives the real analysis, the real rules and the real
|
|
185
189
|
* report over a fixture tree, and pins the exact `file:line:column` a
|
|
186
190
|
* developer would act on, without an Nx installation or a git repository.
|
|
191
|
+
* `listUntracked` is the third git seam, the one the tracked universe is
|
|
192
|
+
* audited against (#675): it answers with the worktree files the universe
|
|
193
|
+
* left out (`../workspace.mjs`'s `listUntrackedFiles`). It is injected
|
|
194
|
+
* separately rather than derived from `listFiles` because the two are only
|
|
195
|
+
* paired when BOTH are git's — a caller that injected a tracked universe has
|
|
196
|
+
* already decided what the whole tree is, and asking git what else exists
|
|
197
|
+
* would answer a different tree than the one `listFiles` named. That is why
|
|
198
|
+
* the default below resolves the real listing only when `listFiles` is also
|
|
199
|
+
* the real one, and treats an injected universe as the whole story: its
|
|
200
|
+
* untracked complement is empty by construction, not by claim.
|
|
187
201
|
*
|
|
188
202
|
* @param {{format: string, config: string|null, paths: string[],
|
|
189
203
|
* evidenceOut?: string|null}} options
|
|
190
|
-
* @param {{cwd: string, readGraph?: Function, listFiles?: Function
|
|
204
|
+
* @param {{cwd: string, readGraph?: Function, listFiles?: Function,
|
|
205
|
+
* listUntracked?: Function}} context
|
|
191
206
|
* @returns {Promise<{report: string, violations: number, declaredEdgeFindings: number,
|
|
192
207
|
* goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number,
|
|
193
208
|
* intentUnresolved: number, intentUnresolvedDecisionRefs: number, fitnessFail: number,
|
|
@@ -195,7 +210,10 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
|
|
|
195
210
|
* customRuleEvidence: {rule: string, bytes: Uint8Array}[], customRulesDeclared: boolean,
|
|
196
211
|
* analyzed: number, unchecked: number, blindSpots: number, waived?: number}>}
|
|
197
212
|
*/
|
|
198
|
-
export async function check(
|
|
213
|
+
export async function check(
|
|
214
|
+
options,
|
|
215
|
+
{ cwd, readGraph, listFiles = listTrackedFiles, listUntracked },
|
|
216
|
+
) {
|
|
199
217
|
const commandContext = resolveCommandContext(
|
|
200
218
|
{ cwd, paths: options.paths },
|
|
201
219
|
{ readGraph, listFiles },
|
|
@@ -791,6 +809,45 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
791
809
|
...(unconstrainedImportNote === null ? [] : [unconstrainedImportNote]),
|
|
792
810
|
];
|
|
793
811
|
|
|
812
|
+
// The tracked-universe boundary, audited (#675): every file a project owns,
|
|
813
|
+
// that an analyzer could judge, and that this run never read because git
|
|
814
|
+
// does not track it yet. The universe above is `listFiles(root)` —
|
|
815
|
+
// `git ls-files` verbatim — so a file that was never `git add`-ed never
|
|
816
|
+
// entered it, and before this audit a run over such a tree printed the same
|
|
817
|
+
// clean verdict as a run over the whole tree, byte for byte, with nothing
|
|
818
|
+
// anywhere naming what the universe had left out. `untrackedOwnedFiles`
|
|
819
|
+
// (`./context.mjs`) narrows the complement to the files whose absence can
|
|
820
|
+
// change what the verdict claims; the ownership and language tests are the
|
|
821
|
+
// two this module already runs over the tracked list, reused rather than
|
|
822
|
+
// re-derived.
|
|
823
|
+
//
|
|
824
|
+
// The verdict posture is the `unowned-files` bargain, and the completeness
|
|
825
|
+
// law decides it: the three no-verdict axes (`verdictFor`'s `unchecked`,
|
|
826
|
+
// `blindSpots`, `analyzed`) are all about files the universe CONTAINED —
|
|
827
|
+
// a whole-file failure, an unjudged site, a run that judged nothing —
|
|
828
|
+
// and the precedent for withholding (zero analysis, #619/#620/#634) is a
|
|
829
|
+
// verdict that was VACUOUS, not one that was scoped. This run judged every
|
|
830
|
+
// file its universe held, so the verdict over that universe stands — exit
|
|
831
|
+
// code and `coverage.complete` unchanged, exactly as the `coverageGaps`
|
|
832
|
+
// channel's contract states (`../../../../docs/reference/json-output.md`:
|
|
833
|
+
// "no kind changes `complete`, `status`, or the exit code") — but the clean
|
|
834
|
+
// verdict over a partial universe is now distinguishable from a clean
|
|
835
|
+
// verdict over the whole one, in both faces, with the full list a parser
|
|
836
|
+
// can act on. What is forbidden here is not exit 0; it is silence.
|
|
837
|
+
//
|
|
838
|
+
// Workspace-wide on purpose, the same posture `unownedGap` holds: a
|
|
839
|
+
// `check <path>` run must not be able to hide an unread file elsewhere in
|
|
840
|
+
// the tree by naming a path that excludes it. And resolved through the
|
|
841
|
+
// `listUntracked` seam's conditional default — an injected universe is the
|
|
842
|
+
// whole story (see this function's doc), so the audit asks git only when
|
|
843
|
+
// git also built the universe.
|
|
844
|
+
const untrackedListing =
|
|
845
|
+
listUntracked ?? (listFiles === listTrackedFiles ? listUntrackedFiles : null);
|
|
846
|
+
const untrackedOwned = untrackedOwnedFiles({
|
|
847
|
+
untracked: untrackedListing === null ? [] : untrackedListing(root),
|
|
848
|
+
projects: commandContext.workspace.projects,
|
|
849
|
+
});
|
|
850
|
+
|
|
794
851
|
// A polyglot coverage gap: the Nx graph carries no polyglot edges because
|
|
795
852
|
// the plugin is not registered, but polyglot manifests exist under project
|
|
796
853
|
// roots. The checker still judged every import it found — this is not a
|
|
@@ -864,6 +921,13 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
864
921
|
...(unsupportedLanguageFiles.length > 0
|
|
865
922
|
? [{ kind: "unsupported-language", files: [...unsupportedLanguageFiles].sort() }]
|
|
866
923
|
: []),
|
|
924
|
+
// The universe audit above (#675): project-owned, analyzable files the
|
|
925
|
+
// worktree holds that this run never read, because the universe is the
|
|
926
|
+
// tracked set. Contributed only when the list is non-empty, so a tree
|
|
927
|
+
// with nothing beyond its index reports exactly the bytes it reported
|
|
928
|
+
// before; sorted already, by the one function that built it, so the row's
|
|
929
|
+
// bytes cannot vary with git's worktree-traversal order (E-F10).
|
|
930
|
+
...(untrackedOwned.length > 0 ? [{ kind: "untracked-files", files: untrackedOwned }] : []),
|
|
867
931
|
];
|
|
868
932
|
|
|
869
933
|
// One verdict computation for both faces: the JSON envelope spreads it and
|
|
@@ -491,38 +491,6 @@ export function buildEvidenceComplete({
|
|
|
491
491
|
};
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
-
/**
|
|
495
|
-
* Asserts that the Evidence-Complete contract is satisfied.
|
|
496
|
-
* Throws with a detailed message listing every failing gate.
|
|
497
|
-
*
|
|
498
|
-
* @param {EvidenceCompleteContract} ec The Evidence-Complete contract to verify.
|
|
499
|
-
* @returns {void}
|
|
500
|
-
* @throws {Error} When any gate fails.
|
|
501
|
-
*/
|
|
502
|
-
export function assertEvidenceComplete(ec) {
|
|
503
|
-
if (ec.overallComplete) return;
|
|
504
|
-
|
|
505
|
-
const contractType = ec.contractType || EVALUATION_CONTRACT_TYPES.SCENARIO;
|
|
506
|
-
const failures = [];
|
|
507
|
-
for (const gate of EVIDENCE_COMPLETE_GATES) {
|
|
508
|
-
const g = ec.gates[gate.key];
|
|
509
|
-
// Skip non-required gates for this contract type
|
|
510
|
-
if (g.required === false) continue;
|
|
511
|
-
if (!g.pass) {
|
|
512
|
-
failures.push(`${gate.label}: ${JSON.stringify(g.value)} (expected pass)`);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
if (failures.length === 0) return;
|
|
517
|
-
|
|
518
|
-
throw new Error(
|
|
519
|
-
`Evidence-Complete contract not satisfied.\n` +
|
|
520
|
-
` Contract type: ${contractType}\n` +
|
|
521
|
-
` Overall: ${ec.overallStatus}\n` +
|
|
522
|
-
` Failed gates:\n ${failures.join("\n ")}`,
|
|
523
|
-
);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
494
|
// ---------------------------------------------------------------------------
|
|
527
495
|
// Governance completeness
|
|
528
496
|
// ---------------------------------------------------------------------------
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
unresolvableLiteralCount,
|
|
39
39
|
} from "../analysis/source-util.mjs";
|
|
40
40
|
import { UsageError } from "../errors.mjs";
|
|
41
|
-
import { judgeEdge } from "
|
|
41
|
+
import { judgeEdge } from "../rules/edge-constraints.mjs";
|
|
42
42
|
import { findConstraintsFor } from "../rules/tags.mjs";
|
|
43
43
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
44
44
|
import { formatContextReport } from "../report/context-text.mjs";
|
package/src/commands/context.mjs
CHANGED
|
@@ -35,7 +35,7 @@ import { jvmIndexFailures } from "../analysis/jvm/packages.mjs";
|
|
|
35
35
|
import { languageOf } from "../analysis/registry.mjs";
|
|
36
36
|
import { pythonUnmodelledFailures } from "../analysis/python.mjs";
|
|
37
37
|
import { rustManifestFailures } from "../analysis/rust.mjs";
|
|
38
|
-
import { dedupeWholeFileFailures, fileFailure } from "../analysis/source-util.mjs";
|
|
38
|
+
import { dedupeWholeFileFailures, fileFailure, projectOwning } from "../analysis/source-util.mjs";
|
|
39
39
|
import {
|
|
40
40
|
DEFAULT_OPTIONS,
|
|
41
41
|
NX_CONFIG_FILE,
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
} from "../providers/moon.mjs";
|
|
55
55
|
import { nativeProvider } from "../providers/native/index.mjs";
|
|
56
56
|
import { mergeDeclaredEdges } from "../providers/native/graph.mjs";
|
|
57
|
+
import { requireSingleProjectModel } from "../providers/model-gate.mjs";
|
|
57
58
|
import {
|
|
58
59
|
resolveDeclaredManifestEdges,
|
|
59
60
|
resolveDeclaredManifestFailures,
|
|
@@ -119,7 +120,8 @@ function readFileAbsolute(path) {
|
|
|
119
120
|
|
|
120
121
|
/**
|
|
121
122
|
* Which Moon directory marks `root` — or none. Presence facts only; see
|
|
122
|
-
* `requireSingleProjectModel`
|
|
123
|
+
* `requireSingleProjectModel` (`../providers/model-gate.mjs`) for the
|
|
124
|
+
* one-decision gate.
|
|
123
125
|
*
|
|
124
126
|
* @param {string} root
|
|
125
127
|
* @returns {{hasNx: boolean, hasNative: boolean, hasMoon: boolean}}
|
|
@@ -132,48 +134,6 @@ export function markersAt(root) {
|
|
|
132
134
|
};
|
|
133
135
|
}
|
|
134
136
|
|
|
135
|
-
/**
|
|
136
|
-
* The one gate deciding whether `root` may be judged at all: more than ONE
|
|
137
|
-
* project-model marker present is refused, naming what conflicts.
|
|
138
|
-
*
|
|
139
|
-
* Every entry point that picks a provider must answer this identically —
|
|
140
|
-
* `resolveCommandContext` below reads it before any command runs, and
|
|
141
|
-
* `../lsp/workspace-index.mjs`'s index build reads it before choosing a
|
|
142
|
-
* branch. A second copy of the condition was exactly how the faces drifted
|
|
143
|
-
* apart once: the CLI refused a tree carrying a Moon directory beside
|
|
144
|
-
* `nx.json`/`archkeep.json` while the editor indexed it anyway — a clean
|
|
145
|
-
* diagnostic list over a tree nobody agreed could be judged (#223's silent
|
|
146
|
-
* shape, one level up). Moon-versus-Moon coexistence (`.moon/` AND
|
|
147
|
-
* `.config/moon/`) is refused inside `../providers/moon.mjs`'s
|
|
148
|
-
* `moonMarkerAt`, which this gate calls first; the cross-family pairs are
|
|
149
|
-
* refused here, all in the same terms: which model to judge against is a
|
|
150
|
-
* decision nobody made, not one this tool can make for them.
|
|
151
|
-
*
|
|
152
|
-
* @param {string} root
|
|
153
|
-
* @param {{exists?: (path: string) => boolean}} [io] Injectable existence
|
|
154
|
-
* test (absolute paths), so a test drives this without a filesystem.
|
|
155
|
-
* @returns {{hasNx: boolean, hasNative: boolean, moonMarker: string|null}}
|
|
156
|
-
* The facts a provider choice needs; `moonMarker` names whichever Moon
|
|
157
|
-
* directory is present, `null` when neither spelling is.
|
|
158
|
-
* @throws {Error} when more than one marker is present.
|
|
159
|
-
*/
|
|
160
|
-
export function requireSingleProjectModel(root, { exists = existsSync } = {}) {
|
|
161
|
-
const moonMarker = moonMarkerAt(root, { exists });
|
|
162
|
-
const hasNx = exists(join(root, NX_CONFIG_FILE));
|
|
163
|
-
const hasNative = exists(join(root, ARCHKEEP_MODEL_FILE));
|
|
164
|
-
const refusal = (a, b) =>
|
|
165
|
-
new Error(
|
|
166
|
-
`archkeep: ${root} declares both ${a} and ${b} — this tool judges a workspace ` +
|
|
167
|
-
`against exactly one project model, and a tree carrying both is a decision nobody made ` +
|
|
168
|
-
`rather than one this tool can make for them. Remove whichever one is not the ` +
|
|
169
|
-
`workspace's real source of truth for projects and tags.`,
|
|
170
|
-
);
|
|
171
|
-
if (moonMarker !== null && hasNx) throw refusal(moonMarker, NX_CONFIG_FILE);
|
|
172
|
-
if (moonMarker !== null && hasNative) throw refusal(moonMarker, ARCHKEEP_MODEL_FILE);
|
|
173
|
-
if (hasNx && hasNative) throw refusal(NX_CONFIG_FILE, ARCHKEEP_MODEL_FILE);
|
|
174
|
-
return { hasNx, hasNative, moonMarker };
|
|
175
|
-
}
|
|
176
|
-
|
|
177
137
|
/**
|
|
178
138
|
* Every marker a workspace root may be recognised by, as one list.
|
|
179
139
|
*
|
|
@@ -191,7 +151,8 @@ export function requireSingleProjectModel(root, { exists = existsSync } = {}) {
|
|
|
191
151
|
* selected the user's home directory as a workspace through `~/.moon`,
|
|
192
152
|
* moonrepo's user-level state directory (#339). A bare `.moon` beside an
|
|
193
153
|
* `nx.json` at a root already chosen is a different question, and stays with
|
|
194
|
-
* `requireSingleProjectModel`'s directory-presence gate
|
|
154
|
+
* `requireSingleProjectModel`'s directory-presence gate
|
|
155
|
+
* (`../providers/model-gate.mjs`).
|
|
195
156
|
*
|
|
196
157
|
* @type {string[]}
|
|
197
158
|
*/
|
|
@@ -485,6 +446,43 @@ export function unownedGapWithoutRunConfiguration(gap, configNames) {
|
|
|
485
446
|
};
|
|
486
447
|
}
|
|
487
448
|
|
|
449
|
+
/**
|
|
450
|
+
* The population a tracked-file universe silently leaves out (#675): files
|
|
451
|
+
* present in the worktree that git does not track, that a project owns, and
|
|
452
|
+
* that an analyzer could have judged — the exact complement of the universe
|
|
453
|
+
* `listTrackedFiles` cuts, narrowed to the files whose absence changes what a
|
|
454
|
+
* verdict can claim.
|
|
455
|
+
*
|
|
456
|
+
* Ownership is decided by `projectOwning` — the same predicate
|
|
457
|
+
* `createWorkspace` attributed the tracked list with, so a file cannot be
|
|
458
|
+
* "owned" here and unowned there — and the language test is the same one
|
|
459
|
+
* `unownedAnalyzableFiles` above applies: a file no analyzer claims (a
|
|
460
|
+
* README, a manifest, an image) could never have entered a verdict, so naming
|
|
461
|
+
* it would be a gap that fires on every work-in-progress tree and teaches a
|
|
462
|
+
* reader to skip the line it is written on. Both filters reuse existing
|
|
463
|
+
* answers; neither re-derives a judgment this module already owns.
|
|
464
|
+
*
|
|
465
|
+
* Sorted by plain string comparison, because the caller renders the list as
|
|
466
|
+
* report bytes: `git ls-files --others` answers in worktree-traversal order,
|
|
467
|
+
* and a row whose order varied with the order files happened to be created
|
|
468
|
+
* would break the byte-identity contract (`../../../../docs/reference/json-output.md`)
|
|
469
|
+
* for a tree that never changed.
|
|
470
|
+
*
|
|
471
|
+
* @param {{untracked: string[], projects: {name: string, root: string}[]}} args
|
|
472
|
+
* `untracked` is `listUntrackedFiles`' answer (`../workspace.mjs`),
|
|
473
|
+
* `projects` the workspace's `{name, root}` list — the same array
|
|
474
|
+
* `createWorkspace` attributed the tracked files with.
|
|
475
|
+
* @returns {string[]} Sorted, deduplicated by construction (git lists a path
|
|
476
|
+
* once), and empty exactly when the universe omitted nothing a verdict
|
|
477
|
+
* could have reached.
|
|
478
|
+
*/
|
|
479
|
+
export function untrackedOwnedFiles({ untracked, projects }) {
|
|
480
|
+
const files = untracked.filter(
|
|
481
|
+
(file) => projectOwning(projects, file) !== null && languageOf(file) !== null,
|
|
482
|
+
);
|
|
483
|
+
return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
484
|
+
}
|
|
485
|
+
|
|
488
486
|
/**
|
|
489
487
|
* Resolves everything a command needs before it can ask its own question:
|
|
490
488
|
* which workspace, which provider, which files, and what analyzing them
|
|
@@ -531,8 +529,9 @@ export function resolveCommandContext(
|
|
|
531
529
|
);
|
|
532
530
|
}
|
|
533
531
|
// Which provider may judge at all — the one gate
|
|
534
|
-
// (`requireSingleProjectModel`
|
|
535
|
-
// language server alike. Moon-versus-Moon rides it
|
|
532
|
+
// (`requireSingleProjectModel`, `../providers/model-gate.mjs`) every entry
|
|
533
|
+
// point shares, CLI and language server alike. Moon-versus-Moon rides it
|
|
534
|
+
// through `moonMarkerAt`.
|
|
536
535
|
const { hasNative, moonMarker } = requireSingleProjectModel(root);
|
|
537
536
|
const hasMoon = moonMarker !== null;
|
|
538
537
|
|
package/src/commands/diff.mjs
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
import { readFileSync } from "node:fs";
|
|
42
42
|
|
|
43
43
|
import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
|
|
44
|
-
import { computeRuleImpact } from "
|
|
44
|
+
import { computeRuleImpact } from "../rules/edge-constraints.mjs";
|
|
45
45
|
import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
|
|
46
46
|
import { SCHEMA_VERSION } from "../report/json.mjs";
|
|
47
47
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
@@ -14,9 +14,13 @@
|
|
|
14
14
|
* - **`--propose`** — computes the candidate architecture over those same
|
|
15
15
|
* observations (`src/governance/discovery-proposal.mjs`'s
|
|
16
16
|
* `evaluateDiscovery`) and emits it with `proposed: true` and
|
|
17
|
-
* `notAuthoritative: true` on every candidate.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* `notAuthoritative: true` on every candidate. This layer performs no
|
|
18
|
+
* write: the evaluator is pure, and the one route from a proposal to
|
|
19
|
+
* `architecture-intent.json` is the CLI's own `--write-intent <file>` flag
|
|
20
|
+
* (`cli.mjs`'s `runDiscover`, serialized through `proposalToIntent`
|
|
21
|
+
* below) — explicit, named by the operator, and refused when a file
|
|
22
|
+
* already stands at the target. The command never hands a candidate the
|
|
23
|
+
* authority of a decision.
|
|
20
24
|
*
|
|
21
25
|
* ## The empty-result invariant
|
|
22
26
|
*
|
|
@@ -25,8 +25,12 @@ import {
|
|
|
25
25
|
computeDomainCoverage,
|
|
26
26
|
REQUIRED_DOMAINS,
|
|
27
27
|
} from "./completeness.mjs";
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// The reachability walk comes from `./impact-reachability.mjs`, not from
|
|
29
|
+
// `./impact.mjs`: that module drives `./impact-statement.mjs`, which drives
|
|
30
|
+
// this one, so importing the walk from there closed the engine's only import
|
|
31
|
+
// cycle (#644).
|
|
32
|
+
import { computeImpact } from "./impact-reachability.mjs";
|
|
33
|
+
import { computeImpactConstraints } from "../rules/edge-constraints.mjs";
|
|
30
34
|
// ---------------------------------------------------------------------------
|
|
31
35
|
// Decision resolution
|
|
32
36
|
// ---------------------------------------------------------------------------
|