@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
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
import { globComplexityError, projectPatternError, safeMatchesGlob } from "../../rules/match.mjs";
|
|
47
47
|
import { resolveOptions } from "../../options.mjs";
|
|
48
48
|
import { findBoundaryConfigViolations, policyKeyViolations } from "../../config.mjs";
|
|
49
|
+
import { describe, isPlainObject, isStringArray } from "../../values.mjs";
|
|
49
50
|
|
|
50
51
|
/** The file this provider treats as a workspace root marker and its model. */
|
|
51
52
|
export const ARCHKEEP_MODEL_FILE = "archkeep.json";
|
|
@@ -259,21 +260,6 @@ export const DEFAULT_INFER_EXCLUDE = Object.freeze([
|
|
|
259
260
|
"**/__fixtures__/**",
|
|
260
261
|
]);
|
|
261
262
|
|
|
262
|
-
/** @type {(value: unknown) => value is Record<string, unknown>} */
|
|
263
|
-
const isPlainObject = (value) =>
|
|
264
|
-
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
265
|
-
|
|
266
|
-
/** @type {(value: unknown) => value is string[]} */
|
|
267
|
-
const isStringArray = (value) =>
|
|
268
|
-
Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
269
|
-
|
|
270
|
-
/** A value's type, for an error message that shows what was actually there. */
|
|
271
|
-
function describe(value) {
|
|
272
|
-
if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
|
|
273
|
-
if (value === null) return "null";
|
|
274
|
-
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
263
|
/**
|
|
278
264
|
* A non-empty-string list's problems, each message naming the entry's own
|
|
279
265
|
* index. `patternError`, when given, is the same-shaped check
|
|
@@ -590,7 +576,7 @@ export function findNativeModelViolations(raw) {
|
|
|
590
576
|
// `coverage.exempt` is where a native tree records an unowned-file
|
|
591
577
|
// acceptance, and two channels for one decision on one tree is how
|
|
592
578
|
// copies drift (the same posture as the `.moon`-beside-`archkeep.json`
|
|
593
|
-
// refusal in
|
|
579
|
+
// refusal in `../model-gate.mjs`'s `requireSingleProjectModel`).
|
|
594
580
|
// The file-dialect spelling of the same mistake — a native tree whose
|
|
595
581
|
// boundaryConfig FILE declares `coverage` — is refused by
|
|
596
582
|
// `../../commands/policy.mjs`'s `resolvePolicy`, which is the first
|
package/src/report/README.md
CHANGED
|
@@ -42,13 +42,19 @@ dependencies)` for projects with no edges, and a coverage claim above the
|
|
|
42
42
|
id, and each transition classified (architecture / policy / provider /
|
|
43
43
|
code drift / unchanged) with its changes and disclosure notes. Renders the
|
|
44
44
|
same payload `json.mjs` wraps; decides nothing.
|
|
45
|
-
- `evidence.mjs` —
|
|
46
|
-
verdict
|
|
47
|
-
the
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
- `evidence.mjs` — a re-export, not a home: `buildDecision` lives in
|
|
46
|
+
`../governance/verdict.mjs`, beside the four-state vocabulary it enforces
|
|
47
|
+
(#650). Holding it here had made the core verdict module (`../verdict.mjs`)
|
|
48
|
+
import the presentation layer. The path stays for the command layer —
|
|
49
|
+
`src/commands/*` build a decision while composing the payload they render —
|
|
50
|
+
and the builder itself turns a command's verdict counts into the `decision`
|
|
51
|
+
the envelope optionally carries, enforcing the five evidence invariants in
|
|
52
|
+
code: `pass` requires complete coverage, `fail` requires a finding,
|
|
53
|
+
`unknown` requires a reason, `not_applicable` requires
|
|
54
|
+
`notApplicableReason`, and a could-not-determine run is never a `pass`. It
|
|
55
|
+
decides nothing about whether a finding IS one; it throws when the verdict
|
|
56
|
+
and its evidence disagree. `evidence.test.mjs` pins the re-export by
|
|
57
|
+
identity, so a second implementation cannot quietly replace the path.
|
|
52
58
|
|
|
53
59
|
`json.mjs` is not a formatter in that sense — it does not turn violations into
|
|
54
60
|
output. `jsonEnvelope` wraps whatever result object a command already computed
|
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/rules/index.mjs
CHANGED
|
@@ -815,6 +815,36 @@ function annotatedByTable(suppressions, violation, now) {
|
|
|
815
815
|
return violation;
|
|
816
816
|
}
|
|
817
817
|
|
|
818
|
+
/**
|
|
819
|
+
* The suppression table applied to verdicts that did not come from
|
|
820
|
+
* `evaluateRun`'s site walk — suppressing rows remove, active waivers mark
|
|
821
|
+
* `waivedBy`, expired ones re-assert with `evidence`, exactly as the site walk
|
|
822
|
+
* applies them, because a verdict must ride the same table whichever walk
|
|
823
|
+
* produced it.
|
|
824
|
+
*
|
|
825
|
+
* The one caller is `./commands/check.mjs`'s markdown document track: its
|
|
826
|
+
* edges are judged per edge through `./edge-constraints.mjs`'s `judgeEdge` —
|
|
827
|
+
* they have no import site for `candidateGroupsFor` to walk, so there is no
|
|
828
|
+
* group chain, only a flat list — but a suppression row that names a document
|
|
829
|
+
* must silence the same verdict here it would silence on an import site, and
|
|
830
|
+
* a waiver over one must annotate it the same way. Unlike the site walk there
|
|
831
|
+
* is no second candidate group to fall through to: an edge's verdicts are all
|
|
832
|
+
* reported together, so a row that removes some leaves the rest standing
|
|
833
|
+
* rather than promoting anything.
|
|
834
|
+
*
|
|
835
|
+
* @param {object[]} suppressions The validated `boundarySuppressions` table.
|
|
836
|
+
* @param {object[]} violations The unfiltered verdicts, in walk order.
|
|
837
|
+
* @param {string} [now] Reference instant for waiver expiry; defaults to the
|
|
838
|
+
* shared governance clock, as `evaluateRun` does.
|
|
839
|
+
* @returns {object[]} The survivors, in input order, annotations applied.
|
|
840
|
+
*/
|
|
841
|
+
export function applySuppressionTable(suppressions, violations, now = referenceTime()) {
|
|
842
|
+
if (suppressions.length === 0) return violations;
|
|
843
|
+
return violations
|
|
844
|
+
.filter((violation) => !removedByTable(suppressions, violation, now))
|
|
845
|
+
.map((violation) => annotatedByTable(suppressions, violation, now));
|
|
846
|
+
}
|
|
847
|
+
|
|
818
848
|
/**
|
|
819
849
|
* One run of the engine over every import site: the judged verdict per site
|
|
820
850
|
* plus the raw superset it was picked from.
|
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
|
+
}
|