@ecoma-io/archkeep 0.22.2 → 0.24.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/analysis/markdown.mjs +340 -0
- package/src/analysis/source-util.mjs +5 -4
- package/src/analysis/typescript.mjs +146 -0
- package/src/architecture-intent/judge.mjs +1 -1
- package/src/architecture-intent/model.mjs +3 -12
- package/src/commands/README.md +16 -7
- package/src/commands/change-intent.mjs +2 -11
- package/src/commands/check.mjs +234 -12
- package/src/commands/completeness.mjs +0 -32
- package/src/commands/context-command.mjs +13 -21
- package/src/commands/context.mjs +46 -47
- package/src/commands/coverage-verdict.mjs +12 -2
- package/src/commands/delta-snapshot.mjs +1 -5
- 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/explain.mjs +17 -20
- package/src/commands/graph.mjs +7 -0
- package/src/commands/health.mjs +4 -0
- package/src/commands/impact-reachability.mjs +104 -0
- package/src/commands/impact.mjs +9 -71
- package/src/commands/plan-context-command.mjs +5 -1
- package/src/commands/policy.mjs +4 -4
- package/src/commands/provenance.mjs +8 -2
- package/src/commands/scenario-evaluation.mjs +1 -1
- package/src/config.mjs +171 -17
- 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 +24 -2
- package/src/eslint-config.mjs +2 -5
- package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -5
- package/src/governance/adr-registry.mjs +33 -17
- package/src/governance/decision-graph.mjs +1 -1
- package/src/governance/evolution-store.mjs +36 -18
- package/src/governance/fitness-registry.mjs +1 -14
- 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 -14
- package/src/governance/verdict.mjs +168 -4
- package/src/intent/intent-manifest.json +16 -16
- 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 +6 -6
- 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/rules/index.mjs +30 -0
- package/src/values.mjs +49 -0
- package/src/verdict.mjs +58 -7
- package/src/workspace.mjs +29 -0
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
|
@@ -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,
|
|
@@ -23,11 +24,15 @@ import { stripTrailingSlashes } from "../path-util.mjs";
|
|
|
23
24
|
import { suppressionCovers } from "../config.mjs";
|
|
24
25
|
import { referenceTime } from "../governance/clock.mjs";
|
|
25
26
|
import { suppressionFate } from "../governance/waiver.mjs";
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
resolveCommandContext,
|
|
29
|
+
unownedGapWithoutRunConfiguration,
|
|
30
|
+
untrackedOwnedFiles,
|
|
31
|
+
} from "./context.mjs";
|
|
27
32
|
import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
|
|
28
33
|
import { readAdrContext } from "./adr.mjs";
|
|
29
34
|
import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
|
|
30
|
-
import { declaredEdgeViolationsForCheck } from "
|
|
35
|
+
import { declaredEdgeViolationsForCheck, judgeEdge } from "../rules/edge-constraints.mjs";
|
|
31
36
|
import { customRulesForCheck, declaresCustomRules } from "./custom-rules.mjs";
|
|
32
37
|
import { driftForCheck } from "./drift.mjs";
|
|
33
38
|
import { fitnessForCheck } from "./fitness.mjs";
|
|
@@ -36,15 +41,17 @@ import { resolvePolicy } from "./policy.mjs";
|
|
|
36
41
|
import { resolveProvenance } from "./provenance.mjs";
|
|
37
42
|
import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
|
|
38
43
|
import { compareGoWork, parseGoWorkUse } from "../go-work.mjs";
|
|
44
|
+
import { mergeDeclaredEdges } from "../providers/native/graph.mjs";
|
|
39
45
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
40
46
|
import { formatSarif } from "../report/sarif.mjs";
|
|
41
47
|
import { formatReport } from "../report/text.mjs";
|
|
42
48
|
import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
|
|
43
|
-
import { evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
|
|
49
|
+
import { applySuppressionTable, evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
|
|
50
|
+
import { buildReachability } from "../rules/reachability.mjs";
|
|
44
51
|
import { orphanedNotDependOnTags, unmatchedConstraintRows } from "../rules/tags.mjs";
|
|
45
52
|
import { judgeTsconfigPaths } from "../tsconfig-paths.mjs";
|
|
46
|
-
import { verdictFor } from "../verdict.mjs";
|
|
47
|
-
import { listTrackedFiles } from "../workspace.mjs";
|
|
53
|
+
import { coverageComplete, verdictFor } from "../verdict.mjs";
|
|
54
|
+
import { listTrackedFiles, listUntrackedFiles } from "../workspace.mjs";
|
|
48
55
|
|
|
49
56
|
/**
|
|
50
57
|
* A total order over violations, so a report's byte sequence is an invariant
|
|
@@ -184,10 +191,21 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
|
|
|
184
191
|
* readers: a test drives the real analysis, the real rules and the real
|
|
185
192
|
* report over a fixture tree, and pins the exact `file:line:column` a
|
|
186
193
|
* developer would act on, without an Nx installation or a git repository.
|
|
194
|
+
* `listUntracked` is the third git seam, the one the tracked universe is
|
|
195
|
+
* audited against (#675): it answers with the worktree files the universe
|
|
196
|
+
* left out (`../workspace.mjs`'s `listUntrackedFiles`). It is injected
|
|
197
|
+
* separately rather than derived from `listFiles` because the two are only
|
|
198
|
+
* paired when BOTH are git's — a caller that injected a tracked universe has
|
|
199
|
+
* already decided what the whole tree is, and asking git what else exists
|
|
200
|
+
* would answer a different tree than the one `listFiles` named. That is why
|
|
201
|
+
* the default below resolves the real listing only when `listFiles` is also
|
|
202
|
+
* the real one, and treats an injected universe as the whole story: its
|
|
203
|
+
* untracked complement is empty by construction, not by claim.
|
|
187
204
|
*
|
|
188
205
|
* @param {{format: string, config: string|null, paths: string[],
|
|
189
206
|
* evidenceOut?: string|null}} options
|
|
190
|
-
* @param {{cwd: string, readGraph?: Function, listFiles?: Function
|
|
207
|
+
* @param {{cwd: string, readGraph?: Function, listFiles?: Function,
|
|
208
|
+
* listUntracked?: Function}} context
|
|
191
209
|
* @returns {Promise<{report: string, violations: number, declaredEdgeFindings: number,
|
|
192
210
|
* goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number,
|
|
193
211
|
* intentUnresolved: number, intentUnresolvedDecisionRefs: number, fitnessFail: number,
|
|
@@ -195,7 +213,10 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
|
|
|
195
213
|
* customRuleEvidence: {rule: string, bytes: Uint8Array}[], customRulesDeclared: boolean,
|
|
196
214
|
* analyzed: number, unchecked: number, blindSpots: number, waived?: number}>}
|
|
197
215
|
*/
|
|
198
|
-
export async function check(
|
|
216
|
+
export async function check(
|
|
217
|
+
options,
|
|
218
|
+
{ cwd, readGraph, listFiles = listTrackedFiles, listUntracked },
|
|
219
|
+
) {
|
|
199
220
|
const commandContext = resolveCommandContext(
|
|
200
221
|
{ cwd, paths: options.paths },
|
|
201
222
|
{ readGraph, listFiles },
|
|
@@ -435,6 +456,41 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
435
456
|
}
|
|
436
457
|
}
|
|
437
458
|
|
|
459
|
+
// The markdown document track, keyed by presence like every fold above it:
|
|
460
|
+
// a policy that declares no `markdown` block reaches nothing here and hears
|
|
461
|
+
// nothing anywhere — no edges, no failures, no envelope key — so a
|
|
462
|
+
// config-absent run is byte-identical to one this fold never existed for
|
|
463
|
+
// (`../../../../AGENTS.md`, "a change to what is reported on an unchanged
|
|
464
|
+
// workspace is a breaking change"). A policy that DOES declare one has its
|
|
465
|
+
// documents read (`../analysis/markdown.mjs`'s `foldMarkdownTrack`), the
|
|
466
|
+
// resolved pairings folded into the graph the way the declared manifest
|
|
467
|
+
// track's edges already are (`mergeDeclaredEdges`, the same dedup key), and
|
|
468
|
+
// every whole-file failure the read earned pushed into the same `failures`
|
|
469
|
+
// funnel the analysis uses — an unresolvable marker is an `unchecked` file,
|
|
470
|
+
// never a clean one.
|
|
471
|
+
//
|
|
472
|
+
// The fold runs BEFORE the walk below on purpose: a document pairing is a
|
|
473
|
+
// project-to-project dependency claim, and from here on this run's graph
|
|
474
|
+
// carries it exactly as it carries a csproj's `ProjectReference` — the tag
|
|
475
|
+
// rows, the circularity walk and the reachability behind them all read one
|
|
476
|
+
// graph, so a pairing cannot be legal here and violating in `context`.
|
|
477
|
+
// What it does not join is the twelve non-tag import-site rules: those need
|
|
478
|
+
// a specifier, a file and a resolution, and a document marker has none of
|
|
479
|
+
// them — it is judged as the EDGE it drew, the same 3-of-15 limit
|
|
480
|
+
// `declaredEdgeViolationsForCheck` documents for `implicit` edges.
|
|
481
|
+
let markdownTrack = null;
|
|
482
|
+
if (config !== null && config.markdown !== undefined) {
|
|
483
|
+
markdownTrack = foldMarkdownTrack({
|
|
484
|
+
tracked,
|
|
485
|
+
owned: commandContext.owned,
|
|
486
|
+
readFile: (file) => workspace.readFile(file),
|
|
487
|
+
workspace,
|
|
488
|
+
markdown: config.markdown,
|
|
489
|
+
});
|
|
490
|
+
mergeDeclaredEdges(graph, markdownTrack.edges);
|
|
491
|
+
failures.push(...markdownTrack.failures);
|
|
492
|
+
}
|
|
493
|
+
|
|
438
494
|
// Both faces of one walk: the run's verdict, and the raw superset it was
|
|
439
495
|
// picked from — every candidate up to each site's surviving group, including
|
|
440
496
|
// the verdicts the suppression table removed to get there. `evaluate` alone
|
|
@@ -444,8 +500,66 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
444
500
|
// the gate's waiver-expiry judgement below and the engine's are the same
|
|
445
501
|
// judgement, not two reads of the clock a boundary instant could split.
|
|
446
502
|
const now = referenceTime();
|
|
447
|
-
const { violations: judged, rawViolations } = evaluateRun(imports, graph, {
|
|
448
|
-
|
|
503
|
+
const { violations: judged, rawViolations: judgedRaw } = evaluateRun(imports, graph, {
|
|
504
|
+
...config,
|
|
505
|
+
now,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// The document pairings' own verdicts, judged by the machinery that already
|
|
509
|
+
// existed: `judgeEdge` — the same function `declaredEdgeViolationsForCheck`
|
|
510
|
+
// runs `implicit` edges through, so a doc pairing and a declared manifest
|
|
511
|
+
// edge can never disagree about the same boundary — over the graph the fold
|
|
512
|
+
// above already joined, with reachability built once for the whole claim
|
|
513
|
+
// list. The same empty-table exit `declaredEdgeViolationsForCheck` takes: a
|
|
514
|
+
// workspace declaring no `depConstraints` has opted out of tag enforcement
|
|
515
|
+
// entirely, and folding `projectWithoutTagsCannotHaveDependencies` onto
|
|
516
|
+
// every marker anyway would flag an opted-out workspace for a reason its
|
|
517
|
+
// import sites are never flagged for. Each verdict is then reshaped into the
|
|
518
|
+
// exact `Violation` record `violationOf` builds — position and specifier
|
|
519
|
+
// from the MARKER (the line a reader edits is the document's), project and
|
|
520
|
+
// constraint from the edge — so suppression, waiver annotation, sorting and
|
|
521
|
+
// the SARIF face all treat it as the ordinary violation it is.
|
|
522
|
+
/** @type {object[]} */
|
|
523
|
+
const markdownRaw = [];
|
|
524
|
+
if (config !== null && markdownTrack !== null && config.depConstraints.length > 0) {
|
|
525
|
+
const reachability = buildReachability(graph);
|
|
526
|
+
for (const claim of markdownTrack.claims) {
|
|
527
|
+
for (const verdict of judgeEdge(
|
|
528
|
+
{ source: claim.source, target: claim.target },
|
|
529
|
+
graph.nodes,
|
|
530
|
+
graph.dependencies,
|
|
531
|
+
config.depConstraints,
|
|
532
|
+
reachability,
|
|
533
|
+
)) {
|
|
534
|
+
markdownRaw.push({
|
|
535
|
+
sourceFile: claim.file,
|
|
536
|
+
line: claim.line,
|
|
537
|
+
column: claim.column,
|
|
538
|
+
specifier: claim.name,
|
|
539
|
+
kind: claim.type,
|
|
540
|
+
messageId: verdict.messageId,
|
|
541
|
+
message: verdict.message,
|
|
542
|
+
sourceProject: verdict.source,
|
|
543
|
+
targetProject: verdict.target,
|
|
544
|
+
constraint: verdict.constraint,
|
|
545
|
+
data: verdict.data,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// The same table, the same behaviour: a suppression row whose glob covers a
|
|
551
|
+
// document removes that document's verdict (or waives it, with the same
|
|
552
|
+
// expiry evidence an import-site waiver carries), and the RAW records join
|
|
553
|
+
// `rawViolations` below so a row covering only document verdicts still
|
|
554
|
+
// counts as alive — a table that is doing its job must not read as dead
|
|
555
|
+
// because the violations it removes live in a different fold.
|
|
556
|
+
const markdownViolations =
|
|
557
|
+
config !== null && markdownTrack !== null
|
|
558
|
+
? applySuppressionTable(config.suppressions, markdownRaw, now)
|
|
559
|
+
: [];
|
|
560
|
+
|
|
561
|
+
const rawViolations = [...judgedRaw, ...markdownRaw];
|
|
562
|
+
const violations = sortViolations([...judged, ...markdownViolations]);
|
|
449
563
|
// An ACTIVE waiver keeps the violation it accepts in the findings list,
|
|
450
564
|
// marked `waivedBy` — the run is still non-zero (waiving does not flip
|
|
451
565
|
// exit 1 → 0), and this count is the additive "accepted violations" number
|
|
@@ -686,7 +800,10 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
686
800
|
config !== null &&
|
|
687
801
|
options.paths.length === 0 &&
|
|
688
802
|
failures.length === 0 &&
|
|
689
|
-
(config.suppressions.length > 0 ||
|
|
803
|
+
(config.suppressions.length > 0 ||
|
|
804
|
+
config.depConstraints.length > 0 ||
|
|
805
|
+
coverageRows.length > 0 ||
|
|
806
|
+
config.markdown !== undefined)
|
|
690
807
|
) {
|
|
691
808
|
const deadRows = [];
|
|
692
809
|
// The third dead table: a `coverage.unowned` row matching no unowned file
|
|
@@ -737,6 +854,34 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
737
854
|
);
|
|
738
855
|
}
|
|
739
856
|
}
|
|
857
|
+
// The document track's two dead tables, measured against what the fold
|
|
858
|
+
// actually selected (`markdownTrack.includeCounts`/`rowMatches` — the
|
|
859
|
+
// same counts the fold's own selection is built from, not a re-derivation
|
|
860
|
+
// that could disagree): an include glob matching no tracked document, and
|
|
861
|
+
// a marker row matching no line in any included document, both govern
|
|
862
|
+
// nothing while reading as enforced — every pairing the workspace meant
|
|
863
|
+
// to declare goes unjudged and the run stays green. A whole-file failure
|
|
864
|
+
// above already disabled this gate, so reaching here with a row that
|
|
865
|
+
// matched nothing means the tree genuinely has no such line.
|
|
866
|
+
if (markdownTrack !== null && config.markdown !== undefined) {
|
|
867
|
+
markdownTrack.includeCounts.forEach((count, index) => {
|
|
868
|
+
if (count > 0) return;
|
|
869
|
+
deadRows.push(
|
|
870
|
+
`markdown.include[${index}]: '${config.markdown.include[index]}' matches no tracked ` +
|
|
871
|
+
`document — the document track reads nothing, so every marker row below it governs ` +
|
|
872
|
+
`nothing while reading as enforced. Either the path was never right, or the ` +
|
|
873
|
+
`documents are not tracked`,
|
|
874
|
+
);
|
|
875
|
+
});
|
|
876
|
+
markdownTrack.rowMatches.forEach((count, index) => {
|
|
877
|
+
if (count > 0) return;
|
|
878
|
+
deadRows.push(
|
|
879
|
+
`markdown.markers[${index}]: the pattern matches no line in any included document — ` +
|
|
880
|
+
`this row extracts no pairing, so it enforces nothing while reading as enforced. ` +
|
|
881
|
+
`Either the documents do not carry the marker, or the pattern was never right`,
|
|
882
|
+
);
|
|
883
|
+
});
|
|
884
|
+
}
|
|
740
885
|
if (deadRows.length > 0) {
|
|
741
886
|
throw new Error(
|
|
742
887
|
`archkeep: ${policySource ?? "the boundary config"} describes a workspace that does not ` +
|
|
@@ -791,6 +936,45 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
791
936
|
...(unconstrainedImportNote === null ? [] : [unconstrainedImportNote]),
|
|
792
937
|
];
|
|
793
938
|
|
|
939
|
+
// The tracked-universe boundary, audited (#675): every file a project owns,
|
|
940
|
+
// that an analyzer could judge, and that this run never read because git
|
|
941
|
+
// does not track it yet. The universe above is `listFiles(root)` —
|
|
942
|
+
// `git ls-files` verbatim — so a file that was never `git add`-ed never
|
|
943
|
+
// entered it, and before this audit a run over such a tree printed the same
|
|
944
|
+
// clean verdict as a run over the whole tree, byte for byte, with nothing
|
|
945
|
+
// anywhere naming what the universe had left out. `untrackedOwnedFiles`
|
|
946
|
+
// (`./context.mjs`) narrows the complement to the files whose absence can
|
|
947
|
+
// change what the verdict claims; the ownership and language tests are the
|
|
948
|
+
// two this module already runs over the tracked list, reused rather than
|
|
949
|
+
// re-derived.
|
|
950
|
+
//
|
|
951
|
+
// The verdict posture is the `unowned-files` bargain, and the completeness
|
|
952
|
+
// law decides it: the three no-verdict axes (`verdictFor`'s `unchecked`,
|
|
953
|
+
// `blindSpots`, `analyzed`) are all about files the universe CONTAINED —
|
|
954
|
+
// a whole-file failure, an unjudged site, a run that judged nothing —
|
|
955
|
+
// and the precedent for withholding (zero analysis, #619/#620/#634) is a
|
|
956
|
+
// verdict that was VACUOUS, not one that was scoped. This run judged every
|
|
957
|
+
// file its universe held, so the verdict over that universe stands — exit
|
|
958
|
+
// code and `coverage.complete` unchanged, exactly as the `coverageGaps`
|
|
959
|
+
// channel's contract states (`../../../../docs/reference/json-output.md`:
|
|
960
|
+
// "no kind changes `complete`, `status`, or the exit code") — but the clean
|
|
961
|
+
// verdict over a partial universe is now distinguishable from a clean
|
|
962
|
+
// verdict over the whole one, in both faces, with the full list a parser
|
|
963
|
+
// can act on. What is forbidden here is not exit 0; it is silence.
|
|
964
|
+
//
|
|
965
|
+
// Workspace-wide on purpose, the same posture `unownedGap` holds: a
|
|
966
|
+
// `check <path>` run must not be able to hide an unread file elsewhere in
|
|
967
|
+
// the tree by naming a path that excludes it. And resolved through the
|
|
968
|
+
// `listUntracked` seam's conditional default — an injected universe is the
|
|
969
|
+
// whole story (see this function's doc), so the audit asks git only when
|
|
970
|
+
// git also built the universe.
|
|
971
|
+
const untrackedListing =
|
|
972
|
+
listUntracked ?? (listFiles === listTrackedFiles ? listUntrackedFiles : null);
|
|
973
|
+
const untrackedOwned = untrackedOwnedFiles({
|
|
974
|
+
untracked: untrackedListing === null ? [] : untrackedListing(root),
|
|
975
|
+
projects: commandContext.workspace.projects,
|
|
976
|
+
});
|
|
977
|
+
|
|
794
978
|
// A polyglot coverage gap: the Nx graph carries no polyglot edges because
|
|
795
979
|
// the plugin is not registered, but polyglot manifests exist under project
|
|
796
980
|
// roots. The checker still judged every import it found — this is not a
|
|
@@ -864,6 +1048,13 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
864
1048
|
...(unsupportedLanguageFiles.length > 0
|
|
865
1049
|
? [{ kind: "unsupported-language", files: [...unsupportedLanguageFiles].sort() }]
|
|
866
1050
|
: []),
|
|
1051
|
+
// The universe audit above (#675): project-owned, analyzable files the
|
|
1052
|
+
// worktree holds that this run never read, because the universe is the
|
|
1053
|
+
// tracked set. Contributed only when the list is non-empty, so a tree
|
|
1054
|
+
// with nothing beyond its index reports exactly the bytes it reported
|
|
1055
|
+
// before; sorted already, by the one function that built it, so the row's
|
|
1056
|
+
// bytes cannot vary with git's worktree-traversal order (E-F10).
|
|
1057
|
+
...(untrackedOwned.length > 0 ? [{ kind: "untracked-files", files: untrackedOwned }] : []),
|
|
867
1058
|
];
|
|
868
1059
|
|
|
869
1060
|
// One verdict computation for both faces: the JSON envelope spreads it and
|
|
@@ -913,8 +1104,14 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
913
1104
|
// Complete means the run judged everything in scope: no
|
|
914
1105
|
// whole-file failure (unchecked), no unresolvable site (#595),
|
|
915
1106
|
// and at least one file analyzed (#599 — a run that judged
|
|
916
|
-
// nothing has no verdict to claim).
|
|
917
|
-
|
|
1107
|
+
// nothing has no verdict to claim). Read from the one predicate
|
|
1108
|
+
// `verdictFor`'s decision face reads, so this field and the
|
|
1109
|
+
// envelope's `decision.coverageComplete` are one derivation —
|
|
1110
|
+
// the counts are this command's (the go.work/tsconfig failures
|
|
1111
|
+
// pushed above and the accepted files withdrawn above make the
|
|
1112
|
+
// universe wider than `commandContext.analysis`), the law is
|
|
1113
|
+
// not.
|
|
1114
|
+
complete: coverageComplete({ unchecked, blindSpotCount, analyzed }),
|
|
918
1115
|
projects: Object.keys(graph.nodes).length,
|
|
919
1116
|
analyzedFiles: analyzed,
|
|
920
1117
|
imports: imports.length,
|
|
@@ -965,6 +1162,31 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
965
1162
|
judged: declaredEdges.judged,
|
|
966
1163
|
findings: declaredEdges.findings,
|
|
967
1164
|
},
|
|
1165
|
+
// The document track is a policy DECLARATION, so it takes the
|
|
1166
|
+
// same omitted-key-not-null discipline the intent/fitness/
|
|
1167
|
+
// customRules blocks below state: a workspace whose policy
|
|
1168
|
+
// declares no `markdown` block gets no key at all, and its
|
|
1169
|
+
// envelope is byte-identical to the one it got before this
|
|
1170
|
+
// section existed. The findings themselves are NOT restated
|
|
1171
|
+
// here — they are members of `violations` above (sorted with,
|
|
1172
|
+
// suppressible by, and counted into the verdict exactly like
|
|
1173
|
+
// every import-site violation), so an array here would be one
|
|
1174
|
+
// fact counted in two places; the block states what the track
|
|
1175
|
+
// DID: how many documents the globs selected, how many markers
|
|
1176
|
+
// they carried, where each resolution went.
|
|
1177
|
+
...(markdownTrack === null
|
|
1178
|
+
? {}
|
|
1179
|
+
: {
|
|
1180
|
+
markdown: {
|
|
1181
|
+
checked: true,
|
|
1182
|
+
documents: markdownTrack.documents,
|
|
1183
|
+
judged: markdownTrack.judged,
|
|
1184
|
+
resolved: markdownTrack.resolved,
|
|
1185
|
+
...(markdownTrack.selfPaired > 0
|
|
1186
|
+
? { selfPaired: markdownTrack.selfPaired }
|
|
1187
|
+
: {}),
|
|
1188
|
+
},
|
|
1189
|
+
}),
|
|
968
1190
|
// Intent is a governance DECLARATION, absent when the workspace
|
|
969
1191
|
// chose not to make one: the key is omitted, never written as
|
|
970
1192
|
// null — the design contract `docs/reference/json-output.md` will
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
-
import { judgeEdge } from "
|
|
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, " +
|
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
|
|