@ecoma-io/archkeep 0.22.2 → 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/src/report/evidence.mjs
CHANGED
|
@@ -1,170 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* the
|
|
4
|
-
* (`../
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* The shape it produces:
|
|
14
|
-
*
|
|
15
|
-
* {
|
|
16
|
-
* verdict: "pass" | "fail" | "unknown" | "not_applicable",
|
|
17
|
-
* reason?: string, // always present for unknown
|
|
18
|
-
* notApplicableReason?: string, // always present for not_applicable
|
|
19
|
-
* sampleTime?: string // opt-in, never on a deterministic envelope
|
|
20
|
-
* }
|
|
21
|
-
*
|
|
22
|
-
* A caller may pass `reason` for `unknown` — it names WHICH could-not-look
|
|
23
|
-
* condition fired (coverage incomplete, an unresolved intent boundary, a
|
|
24
|
-
* thrown analysis). Without it, `buildDecision` states the generic one. The
|
|
25
|
-
* reason field itself is always present on an `unknown` decision (I3).
|
|
26
|
-
*
|
|
27
|
-
* ## Determinism is the default
|
|
28
|
-
*
|
|
29
|
-
* The envelope this decision rides on is byte-deterministic
|
|
30
|
-
* (`docs/reference/json-output.md`: no timestamp, no random identifier). So
|
|
31
|
-
* `sampleTime` is OPT-IN by construction: a command passes it explicitly when
|
|
32
|
-
* it is an age/count capability (waivers, debt, health — the features
|
|
33
|
-
* `../governance/clock.mjs` serves), and a command whose verdict must stay
|
|
34
|
-
* reproducible over an unchanged tree emits a decision with no time at all.
|
|
35
|
-
* That is how the determinism↔time tension is resolved — the clock is
|
|
36
|
-
* injectable (a test drives the same code with a fixed time), never asserted
|
|
37
|
-
* from the wall clock.
|
|
38
|
-
*
|
|
39
|
-
* ## The invariants, executable
|
|
40
|
-
*
|
|
41
|
-
* I1. `pass` requires complete coverage. A run that could not fully read the
|
|
42
|
-
* tree can never pass — the same refusal `jsonEnvelope` makes for
|
|
43
|
-
* `status: "ok"` over incomplete coverage, at the verdict layer.
|
|
44
|
-
* I2. `fail` requires at least one finding. A failing verdict that names no
|
|
45
|
-
* finding leaves the reader guessing what failed.
|
|
46
|
-
* I3. `unknown` requires a reason. `unknown` is a claim that something could
|
|
47
|
-
* not be determined, and the reader has to be able to tell what.
|
|
48
|
-
* I4. `not_applicable` requires `notApplicableReason`. "Did not apply" and
|
|
49
|
-
* "did not run" are indistinguishable without it.
|
|
50
|
-
* I5. The cardinal rule: a failed analysis or an unresolved question must
|
|
51
|
-
* emit `unknown`, NEVER `pass` — enforced here by I1's first check
|
|
52
|
-
* (pass + not-complete throws) and by every caller choosing `unknown`
|
|
53
|
-
* wherever the run did not reach a verdict.
|
|
54
|
-
*
|
|
55
|
-
* `not_applicable` has no envelope status, so `buildDecision` reaches it only
|
|
56
|
-
* through an explicit `verdict` — the route a Fitness or Waiver capability
|
|
57
|
-
* (a later governance wave) takes. Engine behavior today never passes it:
|
|
58
|
-
* `jsonEnvelope` refuses a `decision.verdict` that contradicts the envelope's
|
|
59
|
-
* `status`, and no status maps to `not_applicable`, so the state is locked
|
|
60
|
-
* out of every envelope this release builds.
|
|
2
|
+
* Re-export only. `buildDecision` lives in `../governance/verdict.mjs`,
|
|
3
|
+
* beside the four-state vocabulary it enforces (#650) — this file used to
|
|
4
|
+
* hold it, which made the core verdict module (`../verdict.mjs`) import the
|
|
5
|
+
* presentation layer, the one import direction the report layer must never
|
|
6
|
+
* own. The path stays for the render-side callers — `src/commands/*` build a
|
|
7
|
+
* decision while composing the payload they render — and importing the
|
|
8
|
+
* governance module directly is equivalent and welcome. What must never
|
|
9
|
+
* reappear here is a second implementation: `evidence.test.mjs` asserts the
|
|
10
|
+
* re-export by identity, so a copy that drifts from the vocabulary's
|
|
11
|
+
* enforcer fails loudly instead of silently disagreeing with it.
|
|
61
12
|
*/
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Builds the `decision` a verdict's counts produce.
|
|
66
|
-
*
|
|
67
|
-
* @param {{
|
|
68
|
-
* verdict?: "pass"|"fail"|"unknown"|"not_applicable",
|
|
69
|
-
* status?: "ok"|"findings"|"no-verdict",
|
|
70
|
-
* coverageComplete: boolean,
|
|
71
|
-
* findings: number,
|
|
72
|
-
* reason?: string|null,
|
|
73
|
-
* notApplicableReason?: string|null,
|
|
74
|
-
* sampleTime?: string
|
|
75
|
-
* }} run
|
|
76
|
-
* @returns {{verdict: string, reason?: string, notApplicableReason?: string,
|
|
77
|
-
* sampleTime?: string}}
|
|
78
|
-
* @throws {Error} on any invariant violation (I1–I4).
|
|
79
|
-
*/
|
|
80
|
-
export function buildDecision(run) {
|
|
81
|
-
if (run.verdict === undefined && run.status === undefined) {
|
|
82
|
-
// No status, no explicit verdict — a builder called with neither is a
|
|
83
|
-
// programming error, not a fact about the workspace.
|
|
84
|
-
throw new Error("archkeep: buildDecision needs either a status or an explicit verdict");
|
|
85
|
-
}
|
|
86
|
-
const verdict = run.verdict ?? verdictForStatus(run.status);
|
|
87
|
-
if (
|
|
88
|
-
run.verdict !== undefined &&
|
|
89
|
-
run.status !== undefined &&
|
|
90
|
-
run.verdict !== verdictForStatus(run.status)
|
|
91
|
-
) {
|
|
92
|
-
throw new Error(
|
|
93
|
-
`archkeep: refusing to build a decision where verdict "${run.verdict}" contradicts status ` +
|
|
94
|
-
`"${run.status}" — status implies ${verdictForStatus(run.status)}, and a decision that ` +
|
|
95
|
-
`disagrees with its own status would make one of the two a lie. ` +
|
|
96
|
-
`This is a bug in the command that built the decision.`,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// The `findings` count is the cardinal evidence number — a non-negative
|
|
101
|
-
// integer that the I1–I5 invariants all rely on. A missing, non-numeric, or
|
|
102
|
-
// negative value would silently falsify every comparison (`undefined > 0` is
|
|
103
|
-
// `false`), producing a clean verdict over a run whose counts were never set
|
|
104
|
-
// or are logically impossible — the exact silent direction this module exists
|
|
105
|
-
// to refuse.
|
|
106
|
-
if (typeof run.findings !== "number" || !Number.isFinite(run.findings) || run.findings < 0) {
|
|
107
|
-
throw new Error(
|
|
108
|
-
`archkeep: refusing to build a decision where findings is ${JSON.stringify(run.findings)} ` +
|
|
109
|
-
`— findings must be a non-negative number, or the verdict invariants cannot be enforced. ` +
|
|
110
|
-
`This is a bug in the command that built the decision.`,
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
if (verdict === "pass") {
|
|
114
|
-
if (run.coverageComplete !== true) {
|
|
115
|
-
throw new Error(
|
|
116
|
-
`archkeep: refusing to emit a "pass" decision over incomplete coverage ` +
|
|
117
|
-
`(coverage.complete: ${run.coverageComplete}) — a run that could not fully read the ` +
|
|
118
|
-
`tree can never pass. This is a bug in the command that built the decision.`,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
if (run.findings > 0) {
|
|
122
|
-
throw new Error(
|
|
123
|
-
`archkeep: refusing to emit a "pass" decision with ${run.findings} finding(s) — ` +
|
|
124
|
-
`"pass" and "fail" cannot both be true of the same run. This is a bug in the command.`,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
return withSampleTime({ verdict }, run.sampleTime);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (verdict === "fail") {
|
|
131
|
-
if (run.findings < 1) {
|
|
132
|
-
throw new Error(
|
|
133
|
-
`archkeep: refusing to emit a "fail" decision with no findings — a failing verdict ` +
|
|
134
|
-
`must name what failed. This is a bug in the command that built the decision.`,
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
return withSampleTime({ verdict }, run.sampleTime);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (verdict === "unknown") {
|
|
141
|
-
const reason =
|
|
142
|
-
run.reason ??
|
|
143
|
-
(run.coverageComplete === true ? "no verdict was reached" : "coverage was incomplete");
|
|
144
|
-
return withSampleTime({ verdict, reason }, run.sampleTime);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// verdict === "not_applicable" (I4).
|
|
148
|
-
if (!run.notApplicableReason) {
|
|
149
|
-
throw new Error(
|
|
150
|
-
`archkeep: refusing to emit a "not_applicable" decision without notApplicableReason — ` +
|
|
151
|
-
`"did not apply" and "did not run" must never be indistinguishable. ` +
|
|
152
|
-
`This is a bug in the command that built the decision.`,
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
return withSampleTime({ verdict, notApplicableReason: run.notApplicableReason }, run.sampleTime);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Adds `sampleTime` to the decision only when the caller opted into time —
|
|
160
|
-
* the determinism rule in this module's header. Absent `sampleTime`, the
|
|
161
|
-
* decision object carries exactly the invariant-bearing fields and nothing
|
|
162
|
-
* more.
|
|
163
|
-
*
|
|
164
|
-
* @param {object} decision
|
|
165
|
-
* @param {string|undefined} sampleTime
|
|
166
|
-
* @returns {object}
|
|
167
|
-
*/
|
|
168
|
-
function withSampleTime(decision, sampleTime) {
|
|
169
|
-
return sampleTime === undefined ? decision : { ...decision, sampleTime };
|
|
170
|
-
}
|
|
13
|
+
export { buildDecision } from "../governance/verdict.mjs";
|
package/src/report/json.mjs
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
* does not opt in), which is what keeps SCHEMA_VERSION at 2: additive,
|
|
38
38
|
* byte-compatible with every consumer that reads the envelope today.
|
|
39
39
|
*/
|
|
40
|
+
import { EXIT_FOR_STATUS } from "../verdict.mjs";
|
|
40
41
|
import { verdictForStatus } from "../governance/verdict.mjs";
|
|
41
42
|
import { createRequire } from "node:module";
|
|
42
43
|
|
|
@@ -56,9 +57,6 @@ const { name: TOOL_NAME, version: TOOL_VERSION } = require("../../package.json")
|
|
|
56
57
|
*/
|
|
57
58
|
export const SCHEMA_VERSION = 2;
|
|
58
59
|
|
|
59
|
-
/** The one `status`↔`exitCode` mapping every command's envelope must agree with. */
|
|
60
|
-
const EXIT_CODE_FOR_STATUS = Object.freeze({ ok: 0, findings: 1, "no-verdict": 3 });
|
|
61
|
-
|
|
62
60
|
/**
|
|
63
61
|
* Builds the envelope, asserting the three invariants that keep it from ever
|
|
64
62
|
* claiming more than a run actually established.
|
|
@@ -86,10 +84,14 @@ export function jsonEnvelope({ command, context, status, exitCode, coverage, res
|
|
|
86
84
|
`workspace being judged.`,
|
|
87
85
|
);
|
|
88
86
|
}
|
|
89
|
-
|
|
87
|
+
// The `status`↔`exitCode` agreement reads `EXIT_FOR_STATUS` (`../verdict.mjs`)
|
|
88
|
+
// — the one status-keyed view of the process's exit-code table — so this
|
|
89
|
+
// assertion and `verdictFor`'s own encoding cannot become two independent
|
|
90
|
+
// copies of the same contract.
|
|
91
|
+
if (EXIT_FOR_STATUS[status] !== exitCode) {
|
|
90
92
|
throw new Error(
|
|
91
93
|
`archkeep: refusing to build a JSON envelope where status "${status}" and exitCode ${exitCode} ` +
|
|
92
|
-
`disagree — status "${status}" must carry exitCode ${
|
|
94
|
+
`disagree — status "${status}" must carry exitCode ${EXIT_FOR_STATUS[status]}. A ` +
|
|
93
95
|
`consumer reading a file written by --output has only these two fields to trust; letting ` +
|
|
94
96
|
`them disagree would make one of them a lie.`,
|
|
95
97
|
);
|
|
@@ -149,8 +151,9 @@ export function jsonEnvelope({ command, context, status, exitCode, coverage, res
|
|
|
149
151
|
`This is a bug in the command that built the envelope.`,
|
|
150
152
|
);
|
|
151
153
|
}
|
|
152
|
-
// I3, enforced again at this boundary — `
|
|
153
|
-
// already guarantees it for the engine
|
|
154
|
+
// I3, enforced again at this boundary — `buildDecision`
|
|
155
|
+
// (`../governance/verdict.mjs`) already guarantees it for the engine
|
|
156
|
+
// path, but a hand-built decision
|
|
154
157
|
// would otherwise ship an "unknown" without the reason I3 requires and
|
|
155
158
|
// only this boundary would ever see the mistake. I2 (findings on "fail")
|
|
156
159
|
// gets no latch here for the same reason I4 gets none: the other
|
package/src/report/sarif.mjs
CHANGED
|
@@ -421,7 +421,7 @@ export function sarifTsconfigPathsResult(finding) {
|
|
|
421
421
|
* `messageId` is one of the same three `depConstraints` ids `sarifResult`
|
|
422
422
|
* already catalogues (`onlyTagsConstraintViolation`,
|
|
423
423
|
* `notTagsConstraintViolation`, `projectWithoutTagsCannotHaveDependencies`) —
|
|
424
|
-
* `judgeEdge` (`../
|
|
424
|
+
* `judgeEdge` (`../rules/edge-constraints.mjs`) reuses the identical
|
|
425
425
|
* tag-matching functions `evaluate()`'s import-site path does, so the rule IS
|
|
426
426
|
* the same rule and needs no second entry in `sarifRules()`; only the
|
|
427
427
|
* `ruleIndex` lookup is shared, via `MESSAGE_IDS` rather than a second table.
|
|
@@ -440,7 +440,7 @@ export function sarifTsconfigPathsResult(finding) {
|
|
|
440
440
|
* function learned the provider, which is a file a Moon tree is refused for
|
|
441
441
|
* carrying at all.
|
|
442
442
|
*
|
|
443
|
-
* @param {object} finding A finding from `../
|
|
443
|
+
* @param {object} finding A finding from `../rules/edge-constraints.mjs`'s
|
|
444
444
|
* `declaredEdgeViolationsForCheck`, extended with `file` — workspace-relative.
|
|
445
445
|
* @returns {object}
|
|
446
446
|
*/
|
|
@@ -705,6 +705,30 @@ export function sarifCoverageGapNotification(gap) {
|
|
|
705
705
|
},
|
|
706
706
|
};
|
|
707
707
|
}
|
|
708
|
+
// Files a project owns that git does not track (#675) — present in the
|
|
709
|
+
// worktree, absent from the `git ls-files` universe this run was built
|
|
710
|
+
// from. Bounded the same way the two unowned arms above bound their own
|
|
711
|
+
// lists, and for the same reason: the count is the fact an uploader acts
|
|
712
|
+
// on, the paths its sample, and the remainder is named rather than dropped.
|
|
713
|
+
if (gap.kind === "untracked-files") {
|
|
714
|
+
const files = gap.files ?? [];
|
|
715
|
+
const shown = files.slice(0, UNOWNED_SAMPLE_LIMIT);
|
|
716
|
+
const remaining = files.length - shown.length;
|
|
717
|
+
const listed =
|
|
718
|
+
shown.length > 0
|
|
719
|
+
? `: ${shown.join(", ")}${remaining > 0 ? `, and ${remaining} more` : ""}`
|
|
720
|
+
: "";
|
|
721
|
+
return {
|
|
722
|
+
level: "warning",
|
|
723
|
+
message: {
|
|
724
|
+
text:
|
|
725
|
+
`${files.length} project-owned file${files.length === 1 ? "" : "s"} ` +
|
|
726
|
+
`${files.length === 1 ? "is" : "are"} not tracked by git — never read by this run, ` +
|
|
727
|
+
`so no boundary verdict in this run covers ` +
|
|
728
|
+
`${files.length === 1 ? "it" : "them"}${listed}`,
|
|
729
|
+
},
|
|
730
|
+
};
|
|
731
|
+
}
|
|
708
732
|
return {
|
|
709
733
|
level: "warning",
|
|
710
734
|
message: {
|
|
@@ -793,8 +817,9 @@ export function sarifDecisionRefNotification(decisionRef) {
|
|
|
793
817
|
* `check` exiting 3 on an intent it could not establish uploaded SARIF
|
|
794
818
|
* byte-identical to a clean run's.
|
|
795
819
|
* - `coverageGaps` — coverage this run knows it did not provide: the polyglot
|
|
796
|
-
* edges nothing in the workspace covers,
|
|
797
|
-
* project owns
|
|
820
|
+
* edges nothing in the workspace covers, the tracked analyzable files no
|
|
821
|
+
* project owns, and the project-owned files git does not track that this
|
|
822
|
+
* run therefore never read (`sarifCoverageGapNotification`).
|
|
798
823
|
* - `unresolvedDecisionRefs` — every citation no ADR, rule, or fitness record
|
|
799
824
|
* answers (`sarifDecisionRefNotification`), sorted, which is the order
|
|
800
825
|
* `../../cli.mjs`'s JSON envelope lists the same set in: two faces of one
|
package/src/report/text.mjs
CHANGED
|
@@ -630,6 +630,7 @@ function formatCoverageGap(gap) {
|
|
|
630
630
|
if (gap.kind === "unregistered-plugin") return formatUnregisteredPluginGap(gap);
|
|
631
631
|
if (gap.kind === "unowned-files") return formatUnownedFilesGap(gap);
|
|
632
632
|
if (gap.kind === "accepted-unowned-files") return formatAcceptedUnownedFilesGap(gap);
|
|
633
|
+
if (gap.kind === "untracked-files") return formatUntrackedFilesGap(gap);
|
|
633
634
|
return `⚠ coverage gap "${gap.kind}" — part of this workspace is outside what this run covered`;
|
|
634
635
|
}
|
|
635
636
|
|
|
@@ -732,6 +733,44 @@ function formatAcceptedUnownedFilesGap(gap) {
|
|
|
732
733
|
);
|
|
733
734
|
}
|
|
734
735
|
|
|
736
|
+
/**
|
|
737
|
+
* The untracked-files gap (#675): files a project owns that git does not
|
|
738
|
+
* track — present in the worktree, analyzable, and absent from the
|
|
739
|
+
* `git ls-files` universe this run was built from, so no analyzer ever read
|
|
740
|
+
* them. They are not failures and they change no exit code: the verdict over
|
|
741
|
+
* the tracked universe stands (`../../commands/check.mjs` states the whole
|
|
742
|
+
* argument beside the row's construction). What this section exists for is
|
|
743
|
+
* the difference between "checked, and clean" and "checked the tracked tree,
|
|
744
|
+
* and clean" — before this section existed those two printed the same bytes.
|
|
745
|
+
*
|
|
746
|
+
* The count leads and the paths follow, bounded by the same sample limit the
|
|
747
|
+
* two unowned gaps above print — the identical shape of list, so the
|
|
748
|
+
* identical bound (`UNOWNED_SAMPLE_LIMIT`, whose rule this face owns once).
|
|
749
|
+
*
|
|
750
|
+
* @param {{files?: string[]}} gap
|
|
751
|
+
* @returns {string}
|
|
752
|
+
*/
|
|
753
|
+
function formatUntrackedFilesGap(gap) {
|
|
754
|
+
// Read as defensively as its siblings: `./check.mjs` contributes this entry
|
|
755
|
+
// only when the list is non-empty, but `formatCoverageGaps` is exported and
|
|
756
|
+
// a heading with nothing under it reads as a truncation, not as "none".
|
|
757
|
+
const files = gap.files ?? [];
|
|
758
|
+
const count = files.length;
|
|
759
|
+
const shown = files.slice(0, UNOWNED_SAMPLE_LIMIT);
|
|
760
|
+
const remaining = count - shown.length;
|
|
761
|
+
const lines = shown.map((file) => `${CONTINUED}${file}`);
|
|
762
|
+
if (remaining > 0) {
|
|
763
|
+
lines.push(`${CONTINUED}… and ${remaining} more — the full list is in --format json`);
|
|
764
|
+
}
|
|
765
|
+
const them = count === 1 ? "it" : "them";
|
|
766
|
+
return (
|
|
767
|
+
`⚠ ${count} project-owned file${count === 1 ? "" : "s"} ${count === 1 ? "is" : "are"} ` +
|
|
768
|
+
`not tracked by git — never read by this run, so no boundary verdict here covers ${them}\n` +
|
|
769
|
+
`${lines.join("\n")}\n` +
|
|
770
|
+
`${DETAIL}git add ${them} so the next run reads ${them}, or let git ignore ${them}`
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
735
774
|
/**
|
|
736
775
|
* The policy-identity line — which law this run enforced — rendered FIRST,
|
|
737
776
|
* ahead of every verdict below it: a reader has to know WHICH law produced a
|
package/src/rules/README.md
CHANGED
|
@@ -36,15 +36,24 @@ weaker analyzer. That purity is what lets the CLI and the language server share
|
|
|
36
36
|
one verdict, and what lets all fifteen rules be driven from fixtures with no
|
|
37
37
|
workspace at all.
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
|
39
|
+
`edge-constraints.mjs` is this layer's second judgment surface, and deliberately
|
|
40
|
+
not a rule: it judges GRAPH EDGES against the `depConstraints` table — the
|
|
41
|
+
`implicit`-typed declarations `evaluate()` structurally cannot reach, because
|
|
42
|
+
they have no import site behind them — reusing this directory's tag matching and
|
|
43
|
+
reachability rather than re-deriving them. It reads records and nothing else,
|
|
44
|
+
like everything here; `src/commands/` and `src/lsp/diagnose.mjs` both reach it
|
|
45
|
+
as a downward import.
|
|
46
|
+
|
|
47
|
+
| module | what it owns |
|
|
48
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
49
|
+
| `index.mjs` | the pipeline, in upstream's order; `allow`; the `Violation` record |
|
|
50
|
+
| `tags.mjs` | only / not / empty-only, and which constraints a source is held to |
|
|
51
|
+
| `topology.mjs` | circular, self-circular, apps, e2e, buildable, lazy, transitive |
|
|
52
|
+
| `specifiers.mjs` | relative-across, relative-externals, banned and nested-banned externals |
|
|
53
|
+
| `messages.mjs` | the fifteen message templates, copied verbatim from upstream |
|
|
54
|
+
| `match.mjs` | Nx's three pattern dialects (none of them minimatch), plus the brace-expansion cap in front of `path.posix.matchesGlob` |
|
|
55
|
+
| `reachability.mjs` | who reaches whom: the cycle check and the transitive tag check share it |
|
|
56
|
+
| `edge-constraints.mjs` | graph-edge judgment: `judgeEdge` against the constraint table, the `implicit`-edge pass `check` shares, and the diff/impact fold |
|
|
48
57
|
|
|
49
58
|
## Read these before changing anything here
|
|
50
59
|
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* Edge-constraint analysis: which boundary-rule violations an edge introduces
|
|
3
3
|
* or removes, and which constraint rows govern a given edge.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* tags (not on npm imports,
|
|
5
|
+
* The rules layer's counterpart to `./index.mjs`'s import-site judgment:
|
|
6
|
+
* `evaluate` judges import sites, and the structural commands (`diff`,
|
|
7
|
+
* `impact`) operate on graph edges — not on import sites — so they cannot call
|
|
8
|
+
* `evaluate` directly. Instead, this module provides a narrower function that
|
|
9
|
+
* judges a single edge against the `depConstraints` table, which is the part
|
|
10
|
+
* of the boundary rules that depends only on project tags (not on npm imports,
|
|
11
|
+
* circular dependencies, lazy loading, etc.).
|
|
11
12
|
*
|
|
12
13
|
* `check` is a third caller, through `declaredEdgeViolationsForCheck` below —
|
|
13
14
|
* not for every edge, only the ones `evaluate()` structurally cannot reach: an
|
|
@@ -16,7 +17,9 @@
|
|
|
16
17
|
* `evaluate()` to iterate. Without this, `check` could report a clean tree
|
|
17
18
|
* while `context`/`impact` showed the exact same edge as a tag violation — the
|
|
18
19
|
* "empty result is a claim, not a shrug" invariant (`../../../AGENTS.md`)
|
|
19
|
-
* broken by omission rather than by a wrong answer.
|
|
20
|
+
* broken by omission rather than by a wrong answer. The language server rides
|
|
21
|
+
* the same function (`../lsp/diagnose.mjs`), so an editor paints the same
|
|
22
|
+
* declared-edge verdict `check` exits 1 over.
|
|
20
23
|
*
|
|
21
24
|
* ## What it checks and what it does not
|
|
22
25
|
*
|
|
@@ -34,23 +37,26 @@
|
|
|
34
37
|
* edge with no import site cannot gain one by being judged from `check` rather
|
|
35
38
|
* than `impact`. A consumer who needs the full verdict should run `check`.
|
|
36
39
|
*
|
|
37
|
-
* ## Why
|
|
40
|
+
* ## Why a separate module, and not part of `./tags.mjs` or `./index.mjs`
|
|
38
41
|
*
|
|
39
42
|
* `src/rules/` judges import sites; this module judges graph edges. They share
|
|
40
43
|
* the tag-matching functions (`findConstraintsFor`, `onlyTagsViolation`,
|
|
41
44
|
* `notTagsViolation`, `emptyOnlyTagsViolation`) but the input is different
|
|
42
|
-
* enough that merging them would
|
|
45
|
+
* enough that merging them would put two input contracts in one module — the
|
|
46
|
+
* boundary this file exists to keep legible. A sibling module in the same
|
|
47
|
+
* layer, reached by `commands/` and `lsp/` as a downward import, keeps the
|
|
48
|
+
* shared tag matching in one place without blurring the two judgments.
|
|
43
49
|
*/
|
|
44
50
|
|
|
45
51
|
import { edgeEvolutionIdentity } from "../governance/evolution-event.mjs";
|
|
46
|
-
import { renderMessage } from "
|
|
47
|
-
import { buildReachability } from "
|
|
52
|
+
import { renderMessage } from "./messages.mjs";
|
|
53
|
+
import { buildReachability } from "./reachability.mjs";
|
|
48
54
|
import {
|
|
49
55
|
emptyOnlyTagsViolation,
|
|
50
56
|
findConstraintsFor,
|
|
51
57
|
notTagsViolation,
|
|
52
58
|
onlyTagsViolation,
|
|
53
|
-
} from "
|
|
59
|
+
} from "./tags.mjs";
|
|
54
60
|
|
|
55
61
|
/**
|
|
56
62
|
* Judges a single edge against the `depConstraints` table.
|
package/src/values.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value side of a refusal, shared by every layer that validates: the two
|
|
3
|
+
* shape guards a validator refuses with, and the describer that names the
|
|
4
|
+
* value a refusal is about.
|
|
5
|
+
*
|
|
6
|
+
* **They live at the floor of the package because every layer above needs
|
|
7
|
+
* them and none of them may reach up to get them.** The one pre-existing home
|
|
8
|
+
* for a shared guard was `./custom-rules/values.mjs`, but `custom-rules/`
|
|
9
|
+
* sits above its own dependencies — `./custom-rules/host.mjs` imports
|
|
10
|
+
* `./config.mjs` and `./governance/verdict.mjs` — so a validator in
|
|
11
|
+
* `src/config.mjs` or `src/governance/` importing the guard from there would
|
|
12
|
+
* point a foundational module up into a directory that depends back on it.
|
|
13
|
+
* The definitions are here instead, `custom-rules/values.mjs` re-exports the
|
|
14
|
+
* guard so its own importers keep their spelling, and the layer direction
|
|
15
|
+
* stays one-way.
|
|
16
|
+
*
|
|
17
|
+
* **Two describers deliberately live elsewhere**, and this module is not the
|
|
18
|
+
* place to finish the job:
|
|
19
|
+
*
|
|
20
|
+
* - `./commands/delta-classify.mjs` and `./commands/delta-snapshot.mjs`
|
|
21
|
+
* render a refused primitive BARELY — `typeof` alone, no JSON dump —
|
|
22
|
+
* because their refusals name shapes, not contents.
|
|
23
|
+
* - `./governance/clock.mjs` omits the array branch; its only call site
|
|
24
|
+
* checks `typeof` before describing, so an array is never rendered.
|
|
25
|
+
*
|
|
26
|
+
* Each is pinned by its own test file, and converging any of them onto the
|
|
27
|
+
* rendering below would change the sentence an unchanged workspace is told —
|
|
28
|
+
* a semantic change, not a cleanup.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** @type {(value: unknown) => value is Record<string, any>} */
|
|
32
|
+
export const isPlainObject = (value) =>
|
|
33
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
|
|
35
|
+
/** @type {(value: unknown) => value is string[]} */
|
|
36
|
+
export const isStringArray = (value) =>
|
|
37
|
+
Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A value's type, for an error message that shows what was actually there.
|
|
41
|
+
*
|
|
42
|
+
* @param {unknown} value
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function describe(value) {
|
|
46
|
+
if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
|
|
47
|
+
if (value === null) return "null";
|
|
48
|
+
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
49
|
+
}
|
package/src/verdict.mjs
CHANGED
|
@@ -8,9 +8,15 @@
|
|
|
8
8
|
* `../cli.mjs`'s `runCheck` takes the process's exit code from the same call.
|
|
9
9
|
* `../cli.mjs` re-exports `EXIT` under its own name, so every importer that
|
|
10
10
|
* already reads it from there keeps working.
|
|
11
|
+
*
|
|
12
|
+
* `EXIT` is the one place a status→exit-code number is written. The
|
|
13
|
+
* status-keyed view of it, `EXIT_FOR_STATUS`, is derived from `EXIT` and is
|
|
14
|
+
* what the envelope's consistency check (`./report/json.mjs`) asserts against,
|
|
15
|
+
* so a consumer-facing status and the process's own exit code can never drift
|
|
16
|
+
* into two encodings of one contract.
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
|
-
import { buildDecision } from "./
|
|
19
|
+
import { buildDecision } from "./governance/verdict.mjs";
|
|
14
20
|
|
|
15
21
|
export const EXIT = Object.freeze({
|
|
16
22
|
ok: 0,
|
|
@@ -18,6 +24,20 @@ export const EXIT = Object.freeze({
|
|
|
18
24
|
usage: 2,
|
|
19
25
|
error: 3,
|
|
20
26
|
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The envelope `status`→`exitCode` view of `EXIT` — the one mapping
|
|
30
|
+
* `jsonEnvelope` asserts every command's envelope against. Derived from
|
|
31
|
+
* `EXIT` rather than restated, so the numbers are written exactly once;
|
|
32
|
+
* `usage` has no status because a usage error never reaches an envelope.
|
|
33
|
+
*
|
|
34
|
+
* @type {Readonly<Record<"ok"|"findings"|"no-verdict", 0|1|3>>}
|
|
35
|
+
*/
|
|
36
|
+
export const EXIT_FOR_STATUS = Object.freeze({
|
|
37
|
+
ok: EXIT.ok,
|
|
38
|
+
findings: EXIT.violations,
|
|
39
|
+
"no-verdict": EXIT.error,
|
|
40
|
+
});
|
|
21
41
|
/**
|
|
22
42
|
* The coverage clauses of a no-verdict reason, spelled once — the strings
|
|
23
43
|
* `verdictFor` joins into `decision.reason` and `check`'s text report renders
|
|
@@ -57,7 +77,7 @@ export function coverageIncompleteReasons({ unchecked, blindSpots, analyzed }) {
|
|
|
57
77
|
* "checked, and fine".
|
|
58
78
|
*
|
|
59
79
|
* The `decision` is the canonical 4-state verb of the same verdict
|
|
60
|
-
* (`./
|
|
80
|
+
* (`./governance/verdict.mjs`), built from the same counts so the envelope's
|
|
61
81
|
* `status` and its `decision.verdict` cannot disagree: `ok`→`pass`,
|
|
62
82
|
* `findings`→`fail`, `no-verdict`→`unknown`. `buildDecision` throws on any
|
|
63
83
|
* invariant the counts violate (a `pass` over incomplete coverage, a `fail`
|
|
@@ -103,7 +123,7 @@ export function verdictFor({
|
|
|
103
123
|
) {
|
|
104
124
|
return {
|
|
105
125
|
status: "findings",
|
|
106
|
-
exitCode:
|
|
126
|
+
exitCode: EXIT_FOR_STATUS.findings,
|
|
107
127
|
reasons: coverageReasons,
|
|
108
128
|
decision: buildDecision({
|
|
109
129
|
status: "findings",
|
|
@@ -164,7 +184,7 @@ export function verdictFor({
|
|
|
164
184
|
].filter(Boolean);
|
|
165
185
|
return {
|
|
166
186
|
status: "no-verdict",
|
|
167
|
-
exitCode:
|
|
187
|
+
exitCode: EXIT_FOR_STATUS["no-verdict"],
|
|
168
188
|
reasons,
|
|
169
189
|
decision: buildDecision({
|
|
170
190
|
status: "no-verdict",
|
|
@@ -176,7 +196,7 @@ export function verdictFor({
|
|
|
176
196
|
}
|
|
177
197
|
return {
|
|
178
198
|
status: "ok",
|
|
179
|
-
exitCode:
|
|
199
|
+
exitCode: EXIT_FOR_STATUS.ok,
|
|
180
200
|
reasons: [],
|
|
181
201
|
decision: buildDecision({
|
|
182
202
|
status: "ok",
|
package/src/workspace.mjs
CHANGED
|
@@ -177,6 +177,35 @@ export function listTrackedFiles(workspaceRoot, { run = runProcess } = {}) {
|
|
|
177
177
|
return out.split("\0").filter((path) => path !== "");
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Every file present in the worktree that git does NOT track, workspace-
|
|
182
|
+
* relative — the complement of `listTrackedFiles` above, and the answer to
|
|
183
|
+
* the only question that pair can ask: what exists in the tree the tracked
|
|
184
|
+
* universe was cut from, but never entered it (#675).
|
|
185
|
+
*
|
|
186
|
+
* `--exclude-standard` is what keeps this answer a git answer and not a walk
|
|
187
|
+
* of our own: ignored files — build outputs, dependency installs, anything a
|
|
188
|
+
* `.gitignore`, `.git/info/exclude` or `core.excludesFile` names — are not
|
|
189
|
+
* part of the workspace's population, exactly as for the tracked list. The
|
|
190
|
+
* header's argument for `git ls-files` over a tree walk applies here with one
|
|
191
|
+
* more clause: walking the tree for the untracked half would need those ignore
|
|
192
|
+
* rules reimplemented, and the copy would drift from `.gitignore` the first
|
|
193
|
+
* time a build directory was added.
|
|
194
|
+
*
|
|
195
|
+
* The order is git's worktree-traversal order, not a sorted order — every
|
|
196
|
+
* consumer of this list sorts before it renders (a file list in a report must
|
|
197
|
+
* not vary with git's traversal), the same discipline
|
|
198
|
+
* `../commands/check.mjs`'s `sortViolations` states for the tracked list.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} workspaceRoot
|
|
201
|
+
* @param {{ run?: typeof runProcess }} [io]
|
|
202
|
+
* @returns {string[]}
|
|
203
|
+
*/
|
|
204
|
+
export function listUntrackedFiles(workspaceRoot, { run = runProcess } = {}) {
|
|
205
|
+
const out = run("git", ["ls-files", "--others", "--exclude-standard", "-z"], workspaceRoot);
|
|
206
|
+
return out.split("\0").filter((path) => path !== "");
|
|
207
|
+
}
|
|
208
|
+
|
|
180
209
|
/**
|
|
181
210
|
* The `Workspace` the analysis contract defines — `{ root, projects, filesOf,
|
|
182
211
|
* readFile, tsConfig }` — plus the per-project file index it is built from,
|