@ecoma-io/archkeep 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/cli.mjs +158 -2
- package/package.json +1 -1
- package/src/commands/README.md +19 -1
- package/src/commands/check.mjs +82 -16
- package/src/commands/context.mjs +52 -14
- package/src/commands/coverage-acceptance.mjs +113 -0
- package/src/commands/delta-classify.mjs +502 -0
- package/src/commands/delta-snapshot.mjs +517 -0
- package/src/commands/delta.mjs +481 -0
- package/src/commands/explain.mjs +39 -0
- package/src/commands/policy.mjs +36 -1
- package/src/commands/waivers.mjs +53 -3
- package/src/config.mjs +129 -11
- package/src/lsp/boundary-config.mjs +9 -4
- package/src/providers/native/model.mjs +17 -0
- package/src/report/delta-text.mjs +183 -0
- package/src/report/explain-text.mjs +27 -0
- package/src/report/sarif.mjs +25 -0
- package/src/report/text.mjs +36 -0
- package/src/report/waivers-text.mjs +35 -2
package/src/commands/waivers.mjs
CHANGED
|
@@ -43,6 +43,8 @@ import { referenceTime } from "../governance/clock.mjs";
|
|
|
43
43
|
import { isWaiver, remainingMs, waiverStatus } from "../governance/waiver.mjs";
|
|
44
44
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
45
45
|
import { formatWaiversReport } from "../report/waivers-text.mjs";
|
|
46
|
+
import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
|
|
47
|
+
import { unownedGapWithoutRunConfiguration } from "./context.mjs";
|
|
46
48
|
import { refuseIncompleteGraph } from "./drift.mjs";
|
|
47
49
|
import { resolveProvenance } from "./provenance.mjs";
|
|
48
50
|
import { evaluateRun } from "../rules/index.mjs";
|
|
@@ -144,7 +146,10 @@ export function computeWaivers(suppressions, rawViolations, now = referenceTime(
|
|
|
144
146
|
* by the same three-way call `check` makes — `cli.mjs`'s `runWaivers`
|
|
145
147
|
* resolves `--config` against the working directory exactly as `runCheck`
|
|
146
148
|
* does, so the surface listed is the surface the law actually enforces.
|
|
147
|
-
* @param {{now?: string}} [io] The injected
|
|
149
|
+
* @param {{now?: string, policySource?: string|null}} [io] The injected
|
|
150
|
+
* clock, and — from `cli.mjs`'s `runWaivers` — the workspace-relative path
|
|
151
|
+
* the run's law actually resolved from, so the `coverage.unowned` matching
|
|
152
|
+
* below subtracts the same configuration files `check` subtracts.
|
|
148
153
|
* @returns {Promise<{status: "ok", waivers: object, report: {text: string, json: string}}>}
|
|
149
154
|
* @throws {Error} whenever the run's law is malformed, or the tree has
|
|
150
155
|
* whole-file analysis failures — exit-3 class, the same posture `check` takes
|
|
@@ -155,15 +160,37 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
|
|
|
155
160
|
const now = io.now ?? referenceTime();
|
|
156
161
|
const config = boundaryConfig;
|
|
157
162
|
|
|
163
|
+
// The policy's `coverage.unowned` acceptances, matched through the SAME
|
|
164
|
+
// partition `check` runs (`./coverage-acceptance.mjs`) so the two surfaces
|
|
165
|
+
// cannot disagree about what a row covers. This command is the surface the
|
|
166
|
+
// acceptances' REASONS live on: `check` states the accepted files and
|
|
167
|
+
// points here, this names each row with its reason and current coverage —
|
|
168
|
+
// the same division of labour the suppression table already has between
|
|
169
|
+
// `check`'s accepted-violations section and this command's rows.
|
|
170
|
+
const unownedCoverage = partitionUnownedCoverage({
|
|
171
|
+
rows: config?.coverage?.unowned ?? [],
|
|
172
|
+
unownedGap: unownedGapWithoutRunConfiguration(commandContext.unownedGap, [
|
|
173
|
+
io.policySource ?? null,
|
|
174
|
+
commandContext.options.tsConfig,
|
|
175
|
+
]),
|
|
176
|
+
unclaimedFiles: commandContext.unclaimedGap.files,
|
|
177
|
+
tracked: commandContext.tracked,
|
|
178
|
+
});
|
|
179
|
+
|
|
158
180
|
// A waiver surface over a tree it could not fully read is a lottery ticket,
|
|
159
181
|
// not a surface: a file the analyzer never judged contributes no raw
|
|
160
182
|
// violation, so every waiver that names it reads as stale and the report
|
|
161
183
|
// says "covers nothing" about a finding the run never looked at. Refuse
|
|
162
184
|
// loudly on whole-file failures, the same posture `impact`, `drift`, and
|
|
163
185
|
// `history` take — "could not look" must never read as "looked and found
|
|
164
|
-
// nothing" (`./impact.mjs`'s refusal names the same silence).
|
|
186
|
+
// nothing" (`./impact.mjs`'s refusal names the same silence). A whole-file
|
|
187
|
+
// failure whose file a `coverage.unowned` row accepts is withdrawn first,
|
|
188
|
+
// exactly as `check` withdraws it (`./check.mjs`'s `acceptedUnclaimed`):
|
|
189
|
+
// its state is a recorded acceptance this very report is about to name,
|
|
190
|
+
// not a hole the run failed to look at.
|
|
165
191
|
const notAnalyzed = analysis.failures
|
|
166
192
|
.filter(isWholeFileFailure)
|
|
193
|
+
.filter(({ sourceFile }) => !unownedCoverage.acceptedFiles.has(sourceFile))
|
|
167
194
|
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
168
195
|
|
|
169
196
|
if (notAnalyzed.length > 0) {
|
|
@@ -218,7 +245,30 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
|
|
|
218
245
|
};
|
|
219
246
|
|
|
220
247
|
const context = { root, provider, marker, provenance: resolveProvenance(root) };
|
|
221
|
-
const result = {
|
|
248
|
+
const result = {
|
|
249
|
+
waivers,
|
|
250
|
+
covered,
|
|
251
|
+
expired,
|
|
252
|
+
stale,
|
|
253
|
+
suppressions,
|
|
254
|
+
suppressed,
|
|
255
|
+
// The third surface, present only when the policy declares the channel —
|
|
256
|
+
// the same absent-is-a-decision key discipline `check`'s envelope keeps
|
|
257
|
+
// for `fitness`/`customRules`, so a workspace without the key gets a
|
|
258
|
+
// byte-identical envelope. Each row is the declared acceptance with how
|
|
259
|
+
// many unowned files it currently covers; a `covered: 0` row reads like
|
|
260
|
+
// a stale waiver above — dead weight this command surfaces and `check`
|
|
261
|
+
// refuses (`./check.mjs`'s dead-row block).
|
|
262
|
+
...(config?.coverage === undefined
|
|
263
|
+
? {}
|
|
264
|
+
: {
|
|
265
|
+
unownedAcceptances: unownedCoverage.rows.map(({ path, reason, files }) => ({
|
|
266
|
+
path,
|
|
267
|
+
reason,
|
|
268
|
+
covered: files.length,
|
|
269
|
+
})),
|
|
270
|
+
}),
|
|
271
|
+
};
|
|
222
272
|
|
|
223
273
|
const envelope = jsonEnvelope({
|
|
224
274
|
command: "waivers",
|
package/src/config.mjs
CHANGED
|
@@ -96,7 +96,8 @@
|
|
|
96
96
|
*
|
|
97
97
|
* The `.mjs`/`.js` and `.json` dialects share the same top-level key law: a
|
|
98
98
|
* top-level export (or key) beyond `depConstraints`, `moduleBoundaryOptions`,
|
|
99
|
-
* `boundarySuppressions`, `fitness` and `
|
|
99
|
+
* `boundarySuppressions`, `fitness`, `customRules` and `coverage` is rejected
|
|
100
|
+
* by name. The
|
|
100
101
|
* `.mjs` dialect's tolerance for a helper export used to let a misspelled key
|
|
101
102
|
* (`moduleBoundaryOptions` → `moduleBoundaryOption`) disappear into silence —
|
|
102
103
|
* a typo'd law is a law that is not enforced, the exact silent direction this
|
|
@@ -557,6 +558,94 @@ function suppressionRowViolations(row, index) {
|
|
|
557
558
|
return violations;
|
|
558
559
|
}
|
|
559
560
|
|
|
561
|
+
/**
|
|
562
|
+
* The keys a `coverage.unowned` row may carry. `reason` is not optional, for
|
|
563
|
+
* the same reason it is not optional on a suppression row above and on a
|
|
564
|
+
* native `coverage.exempt` row (`./providers/native/model.mjs`'s
|
|
565
|
+
* `exemptRowViolations`, whose semantics this row copies exactly): an
|
|
566
|
+
* accepted coverage hole with no reason written down is indistinguishable
|
|
567
|
+
* from coverage that quietly stopped being enforced.
|
|
568
|
+
*/
|
|
569
|
+
const COVERAGE_UNOWNED_KEYS = ["path", "reason"];
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* One `coverage.unowned` row's problems, prefixed with its index.
|
|
573
|
+
*
|
|
574
|
+
* @param {unknown} row
|
|
575
|
+
* @param {number} index
|
|
576
|
+
* @returns {string[]}
|
|
577
|
+
*/
|
|
578
|
+
function coverageUnownedRowViolations(row, index) {
|
|
579
|
+
const at = `coverage.unowned[${index}]`;
|
|
580
|
+
if (!isPlainObject(row)) return [`${at}: must be an object, got ${describe(row)}`];
|
|
581
|
+
|
|
582
|
+
const violations = [];
|
|
583
|
+
if (typeof row.path !== "string" || row.path === "") {
|
|
584
|
+
violations.push(
|
|
585
|
+
`${at}.path: must be a non-empty glob over a workspace-relative path, matched with ` +
|
|
586
|
+
`\`path.posix.matchesGlob\`, got ${describe(row.path)}`,
|
|
587
|
+
);
|
|
588
|
+
} else {
|
|
589
|
+
const problem = globComplexityError(row.path);
|
|
590
|
+
if (problem) violations.push(`${at}.path: '${row.path}' ${problem}`);
|
|
591
|
+
}
|
|
592
|
+
if (typeof row.reason !== "string" || row.reason.trim() === "") {
|
|
593
|
+
violations.push(
|
|
594
|
+
`${at}.reason: must be a non-empty string — an accepted coverage hole with no reason ` +
|
|
595
|
+
`written down reads as coverage that is still enforced`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
for (const key of Object.keys(row)) {
|
|
599
|
+
if (!COVERAGE_UNOWNED_KEYS.includes(key)) {
|
|
600
|
+
violations.push(
|
|
601
|
+
`${at}.${key}: not a coverage.unowned field — expected one of ` +
|
|
602
|
+
COVERAGE_UNOWNED_KEYS.join(", "),
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return violations;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Everything wrong with a policy's `coverage` key, as messages; empty when it
|
|
611
|
+
* is well-formed.
|
|
612
|
+
*
|
|
613
|
+
* The key holds exactly one field: `unowned`, an array of `{path, reason}`
|
|
614
|
+
* rows — a recorded acceptance of tracked files no project owns, for the Nx
|
|
615
|
+
* and Moon providers whose project model has no channel of its own for that
|
|
616
|
+
* decision (`../../../docs/reference/policy-schema.md`, "`coverage`"). A row
|
|
617
|
+
* is matched ONLY against files already decided to be unowned, so even a `**`
|
|
618
|
+
* row can never silence a verdict about an owned file — the same guarantee
|
|
619
|
+
* the native provider's `coverage.exempt` states
|
|
620
|
+
* (`./providers/native/coverage.mjs`). An empty `unowned` list is accepted
|
|
621
|
+
* for the reason an empty suppression list is: it accepts nothing, which is
|
|
622
|
+
* the direction that cannot hide anything.
|
|
623
|
+
*
|
|
624
|
+
* @param {unknown} value The parsed `coverage` value.
|
|
625
|
+
* @returns {string[]}
|
|
626
|
+
*/
|
|
627
|
+
function findCoverageViolations(value) {
|
|
628
|
+
if (!isPlainObject(value)) {
|
|
629
|
+
return [`coverage: must be an object carrying an 'unowned' array, got ${describe(value)}`];
|
|
630
|
+
}
|
|
631
|
+
const violations = [];
|
|
632
|
+
if (!Array.isArray(value.unowned)) {
|
|
633
|
+
violations.push(
|
|
634
|
+
`coverage.unowned: must be an array of {path, reason} rows, got ${describe(value.unowned)}`,
|
|
635
|
+
);
|
|
636
|
+
} else {
|
|
637
|
+
value.unowned.forEach((row, index) =>
|
|
638
|
+
violations.push(...coverageUnownedRowViolations(row, index)),
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
for (const key of Object.keys(value)) {
|
|
642
|
+
if (key !== "unowned") {
|
|
643
|
+
violations.push(`coverage.${key}: not a coverage field — expected 'unowned'`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return violations;
|
|
647
|
+
}
|
|
648
|
+
|
|
560
649
|
/**
|
|
561
650
|
* The grammar a custom rule's `name` is written in: lowercase letters and
|
|
562
651
|
* digits, single `-` separators, nothing else.
|
|
@@ -863,8 +952,14 @@ export function findBoundaryConfigViolations(module, io = {}) {
|
|
|
863
952
|
if (!isPlainObject(module)) return [`config: expected a module object, got ${describe(module)}`];
|
|
864
953
|
|
|
865
954
|
const violations = [];
|
|
866
|
-
const {
|
|
867
|
-
|
|
955
|
+
const {
|
|
956
|
+
depConstraints,
|
|
957
|
+
moduleBoundaryOptions,
|
|
958
|
+
boundarySuppressions,
|
|
959
|
+
fitness,
|
|
960
|
+
customRules,
|
|
961
|
+
coverage,
|
|
962
|
+
} = module;
|
|
868
963
|
// F05: the resolution half of the governance block (`row-schema.mjs`'s
|
|
869
964
|
// `io.resolve`) was validator-only until now — no production caller passed
|
|
870
965
|
// one, so a row bound to a fitness rule that does not exist loaded and ran
|
|
@@ -909,6 +1004,16 @@ export function findBoundaryConfigViolations(module, io = {}) {
|
|
|
909
1004
|
violations.push(...findCustomRuleViolations(customRules, io));
|
|
910
1005
|
}
|
|
911
1006
|
|
|
1007
|
+
// The unowned-file acceptances — the sixth top-level law, shaped here and
|
|
1008
|
+
// matched by `./commands/coverage-acceptance.mjs` against files already
|
|
1009
|
+
// decided to be unowned, never against owned ones. Absent means "no
|
|
1010
|
+
// acceptance recorded"; present and malformed is refused loudly, because a
|
|
1011
|
+
// row this reader could not understand is an acceptance that would not
|
|
1012
|
+
// apply while the policy still says it does.
|
|
1013
|
+
if (coverage !== undefined) {
|
|
1014
|
+
violations.push(...findCoverageViolations(coverage));
|
|
1015
|
+
}
|
|
1016
|
+
|
|
912
1017
|
// Absent means "nothing is suppressed", which is the only default that fails
|
|
913
1018
|
// toward reporting — unlike the eight options above, where a missing value
|
|
914
1019
|
// would be a second copy of something ESLint also reads and this module has
|
|
@@ -996,8 +1101,15 @@ export function findBoundaryConfigViolations(module, io = {}) {
|
|
|
996
1101
|
* general "ignore unknown" rule, which is exactly the leniency this file's
|
|
997
1102
|
* header argues a JSON object must not get). `fitness` is the fourth: the
|
|
998
1103
|
* boundary dialect's key for the fitness-functions list, validated as an array
|
|
999
|
-
* of fitness rows. `customRules` is the fifth
|
|
1104
|
+
* of fitness rows. `customRules` is the fifth — the declared rules
|
|
1000
1105
|
* this engine did not write, validated as an array of custom-rule rows.
|
|
1106
|
+
* `coverage` is the sixth and newest: the recorded acceptances of unowned
|
|
1107
|
+
* files on an Nx or Moon workspace (`findCoverageViolations` above owns the
|
|
1108
|
+
* shape). On a native tree the key is refused — `archkeep.json`'s own
|
|
1109
|
+
* `coverage.exempt` is that provider's one channel for the same decision —
|
|
1110
|
+
* and the refusal lives in `./commands/policy.mjs`'s `resolvePolicy` and
|
|
1111
|
+
* `./providers/native/model.mjs`'s inline-policy check, because only they
|
|
1112
|
+
* know which provider is reading.
|
|
1001
1113
|
*
|
|
1002
1114
|
* The name says `.json` and the list binds both file dialects: `loadModulePolicy`
|
|
1003
1115
|
* runs the same check over an ES module's exports, which is what makes a
|
|
@@ -1010,6 +1122,7 @@ const JSON_POLICY_KEYS = [
|
|
|
1010
1122
|
"boundarySuppressions",
|
|
1011
1123
|
"fitness",
|
|
1012
1124
|
"customRules",
|
|
1125
|
+
"coverage",
|
|
1013
1126
|
];
|
|
1014
1127
|
|
|
1015
1128
|
/**
|
|
@@ -1086,7 +1199,7 @@ export function policyKeyViolations(parsed, { allowSchema }) {
|
|
|
1086
1199
|
* inline one.
|
|
1087
1200
|
* @param {string[]} [extraViolations] Violations the caller already found that
|
|
1088
1201
|
* `findBoundaryConfigViolations` does not check on its own.
|
|
1089
|
-
* @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }}
|
|
1202
|
+
* @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }}
|
|
1090
1203
|
* `fitness` and `customRules` are present only when the config declares
|
|
1091
1204
|
* them — a workspace without one carries no key, the same "absent is a
|
|
1092
1205
|
* decision" posture `cli.mjs`'s `check` uses for a missing
|
|
@@ -1109,6 +1222,7 @@ export function policyFrom(parsed, sourceLabel, extraViolations = []) {
|
|
|
1109
1222
|
suppressions: parsed.boundarySuppressions ?? [],
|
|
1110
1223
|
...(parsed.fitness === undefined ? {} : { fitness: parsed.fitness }),
|
|
1111
1224
|
...(parsed.customRules === undefined ? {} : { customRules: parsed.customRules }),
|
|
1225
|
+
...(parsed.coverage === undefined ? {} : { coverage: parsed.coverage }),
|
|
1112
1226
|
};
|
|
1113
1227
|
}
|
|
1114
1228
|
|
|
@@ -1125,7 +1239,7 @@ export function policyFrom(parsed, sourceLabel, extraViolations = []) {
|
|
|
1125
1239
|
* legitimate namespace to share a helper in gave a typo the same silence.
|
|
1126
1240
|
*
|
|
1127
1241
|
* @param {string} path Absolute path of the config file.
|
|
1128
|
-
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
|
|
1242
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }>}
|
|
1129
1243
|
* @throws {Error} when the file is missing, unloadable, or malformed.
|
|
1130
1244
|
*/
|
|
1131
1245
|
async function loadModulePolicy(path) {
|
|
@@ -1151,7 +1265,7 @@ async function loadModulePolicy(path) {
|
|
|
1151
1265
|
* @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
|
|
1152
1266
|
* Injectable read, defaulting to `node:fs/promises`' `readFile` — the only
|
|
1153
1267
|
* code in this function that reaches outside the process.
|
|
1154
|
-
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
|
|
1268
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }>}
|
|
1155
1269
|
* @throws {Error} when the file is missing, unreadable, not valid JSON, or
|
|
1156
1270
|
* malformed — either by `findBoundaryConfigViolations`' rules or by carrying
|
|
1157
1271
|
* a top-level key none of those rules knows about.
|
|
@@ -1198,7 +1312,7 @@ async function loadJsonPolicy(path, { readFile = readFileFromDisk } = {}) {
|
|
|
1198
1312
|
* @param {string} path Absolute path of the config file.
|
|
1199
1313
|
* @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
|
|
1200
1314
|
* Injectable read, used only by the `.json` dialect — see `loadJsonPolicy`.
|
|
1201
|
-
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], notes?: string[] }>}
|
|
1315
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, notes?: string[] }>}
|
|
1202
1316
|
* `suppressions` is `[]` when the config declares none. `notes` is present
|
|
1203
1317
|
* only under the ESLint dialect, and only when `./eslint-config.mjs` has
|
|
1204
1318
|
* something worth telling a reader about which entry it bound — see
|
|
@@ -1250,8 +1364,12 @@ export async function loadBoundaryConfigFile(path, io = {}) {
|
|
|
1250
1364
|
// from (see this module's header). Never populated under this dialect —
|
|
1251
1365
|
// `policyFrom` already resolves that to `[]` since the object above
|
|
1252
1366
|
// states no `boundarySuppressions` key, and it leaves `customRules`
|
|
1253
|
-
// absent for the same reason, the header's own
|
|
1254
|
-
// absent law and an empty one
|
|
1367
|
+
// and `coverage` absent for the same reason, the header's own
|
|
1368
|
+
// distinction between an absent law and an empty one: a flat config's
|
|
1369
|
+
// one rule entry is a constraint table and its options, with nowhere to
|
|
1370
|
+
// name a rule artifact or an unowned-file acceptance
|
|
1371
|
+
// (`../../../docs/reference/policy-schema.md`'s dialect table names both
|
|
1372
|
+
// gaps on the consumer-facing side).
|
|
1255
1373
|
...(note !== undefined ? { notes: [note] } : {}),
|
|
1256
1374
|
};
|
|
1257
1375
|
}
|
|
@@ -1279,7 +1397,7 @@ export async function loadBoundaryConfigFile(path, io = {}) {
|
|
|
1279
1397
|
* misconfigured tool.
|
|
1280
1398
|
* @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
|
|
1281
1399
|
* Forwarded to `loadBoundaryConfigFile` — see there.
|
|
1282
|
-
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], notes?: string[] }>}
|
|
1400
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, notes?: string[] }>}
|
|
1283
1401
|
* @throws {Error} as `loadBoundaryConfigFile`.
|
|
1284
1402
|
*/
|
|
1285
1403
|
export async function loadBoundaryConfig(workspaceRoot, boundaryConfig, io = {}) {
|
|
@@ -62,7 +62,7 @@ import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
|
|
|
62
62
|
*
|
|
63
63
|
* @param {string} path Absolute path of the config file.
|
|
64
64
|
* @param {string|number} revision Busts the ESM module cache across edits.
|
|
65
|
-
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
|
|
65
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }>}
|
|
66
66
|
* @throws {Error} when the file is missing, unloadable, or malformed.
|
|
67
67
|
*/
|
|
68
68
|
async function readModulePolicy(path, revision) {
|
|
@@ -86,7 +86,7 @@ async function readModulePolicy(path, revision) {
|
|
|
86
86
|
* @param {(path: string, encoding: "utf8") => Promise<string>} readFile
|
|
87
87
|
* Injected so a test can drive this without a real file — see
|
|
88
88
|
* `readBoundaryConfig` below.
|
|
89
|
-
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[] }>}
|
|
89
|
+
* @returns {Promise<{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }>}
|
|
90
90
|
* @throws {Error} when the file is missing, unreadable, not valid JSON, or
|
|
91
91
|
* malformed — either by `../config.mjs`'s `findBoundaryConfigViolations` or
|
|
92
92
|
* by carrying a top-level key none of those rules knows about.
|
|
@@ -144,14 +144,19 @@ async function readJsonPolicy(path, readFile) {
|
|
|
144
144
|
* @param {{readFile?: (path: string, encoding: "utf8") => Promise<string>}} [io]
|
|
145
145
|
* Injectable read, used only by the `.json` dialect, defaulting to
|
|
146
146
|
* `node:fs/promises`'s `readFile`.
|
|
147
|
-
* @returns {Promise<{depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[]}>}
|
|
147
|
+
* @returns {Promise<{depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object}>}
|
|
148
148
|
* `suppressions` is `[]` when the config declares none; `fitness` and
|
|
149
149
|
* `customRules` are present only when the policy declares them, the same
|
|
150
150
|
* absent-is-a-decision shape `../config.mjs`'s `policyFrom` returns to
|
|
151
151
|
* every other face. This server READS both and evaluates neither — a
|
|
152
152
|
* fitness function and a custom rule are per-run workspace judgments, not
|
|
153
153
|
* per-file diagnostics — so what it owes them is to load them and to fail
|
|
154
|
-
* loudly on a row it cannot read.
|
|
154
|
+
* loudly on a row it cannot read. `coverage` rides the same bargain one
|
|
155
|
+
* key further: unowned-file acceptance is a whole-run coverage decision
|
|
156
|
+
* (`../commands/coverage-acceptance.mjs`), never a per-file diagnostic, so
|
|
157
|
+
* the server's whole duty to the key is to ACCEPT a config carrying it —
|
|
158
|
+
* the same `policyKeyViolations` the CLI runs — rather than reject in the
|
|
159
|
+
* editor a law `check` enforces.
|
|
155
160
|
* @throws {Error} when `boundaryConfig` names the ESLint flat-config dialect
|
|
156
161
|
* or a legacy `.eslintrc*` file (this reader does not read either yet — see
|
|
157
162
|
* above), or — for a dialect it does read — when the file is missing,
|
|
@@ -520,6 +520,23 @@ export function findNativeModelViolations(raw) {
|
|
|
520
520
|
(message) => `boundaryConfig.${message}`,
|
|
521
521
|
),
|
|
522
522
|
);
|
|
523
|
+
// The policy's `coverage` key is an Nx/Moon channel, refused here BY
|
|
524
|
+
// NAME on the one provider that already has its own: this very file's
|
|
525
|
+
// `coverage.exempt` is where a native tree records an unowned-file
|
|
526
|
+
// acceptance, and two channels for one decision on one tree is how
|
|
527
|
+
// copies drift (the same posture as the `.moon`-beside-`archkeep.json`
|
|
528
|
+
// refusal in `../../commands/context.mjs`'s `requireSingleProjectModel`).
|
|
529
|
+
// The file-dialect spelling of the same mistake — a native tree whose
|
|
530
|
+
// boundaryConfig FILE declares `coverage` — is refused by
|
|
531
|
+
// `../../commands/policy.mjs`'s `resolvePolicy`, which is the first
|
|
532
|
+
// point that knows both the provider and the loaded policy.
|
|
533
|
+
if ("coverage" in raw.boundaryConfig) {
|
|
534
|
+
violations.push(
|
|
535
|
+
`boundaryConfig.coverage: not accepted on a native workspace — this tree records ` +
|
|
536
|
+
`unowned-file acceptances on archkeep.json's own coverage.exempt, and a second ` +
|
|
537
|
+
`channel for the same decision is a copy that will drift. Move the rows there`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
523
540
|
} else if (typeof raw.boundaryConfig !== "string") {
|
|
524
541
|
violations.push(
|
|
525
542
|
`boundaryConfig: must be a string (a filename) or an object (an inline policy), got ` +
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `delta` command: how boundary violations moved
|
|
3
|
+
* between a captured baseline and the current tree, both re-judged under the
|
|
4
|
+
* current law (`../commands/delta.mjs`).
|
|
5
|
+
*
|
|
6
|
+
* Sections render only when they have content — introduced (with waived
|
|
7
|
+
* annotations), resolved, unchanged (with the occurrences-reduced note where
|
|
8
|
+
* one applies), unknown (with the reason each identity could not be stated),
|
|
9
|
+
* and the unresolvable-import block — and the summary line always states what
|
|
10
|
+
* was compared: base and head identity, record and project counts, and the
|
|
11
|
+
* bucket totals. "No introduced violations" is a claim about a comparison the
|
|
12
|
+
* reader can verify, never silence (`../../../../AGENTS.md`).
|
|
13
|
+
*
|
|
14
|
+
* `coverage.notes` — the policy-changed, dirty-tree, and provenance warnings
|
|
15
|
+
* `../commands/delta.mjs` pushes there — fold into the report as their own
|
|
16
|
+
* lines, so a note that rides the JSON envelope also reaches the terminal.
|
|
17
|
+
*
|
|
18
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
19
|
+
* wearing a formatter's name (`./README.md`).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A side's identity for prose: its commit prefix, or an honest absence. */
|
|
23
|
+
function describeOrigin(provenance) {
|
|
24
|
+
if (!provenance || typeof provenance.commit !== "string") return "unverified origin";
|
|
25
|
+
const dirty = provenance.dirty ? ", dirty" : "";
|
|
26
|
+
return `${provenance.commit.slice(0, 8)}${dirty}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One classified violation entry as its report lines. */
|
|
30
|
+
function violationLines(entry) {
|
|
31
|
+
const source = entry.sourceProject ?? "(unattributed)";
|
|
32
|
+
const arrow = entry.targetIsSpecifier ? "⇢" : "→";
|
|
33
|
+
const counts = `${entry.baseCount} at base, ${entry.headCount} at head`;
|
|
34
|
+
const waived = entry.waived === true ? " [waived]" : "";
|
|
35
|
+
const lines = [` ${source} ${arrow} ${entry.target} ${entry.messageId} (${counts})${waived}`];
|
|
36
|
+
if (entry.note !== undefined) lines.push(` ${entry.note}`);
|
|
37
|
+
const sites = entry.headSites.length > 0 ? entry.headSites : entry.baseSites;
|
|
38
|
+
for (const site of sites) {
|
|
39
|
+
lines.push(` at ${site.file}:${site.line}:${site.column}`);
|
|
40
|
+
}
|
|
41
|
+
return lines;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One classified unresolvable-record entry as its report lines. */
|
|
45
|
+
function unresolvableLines(entry) {
|
|
46
|
+
const source = entry.sourceProject ?? "(unattributed)";
|
|
47
|
+
const counts = `${entry.baseCount} at base, ${entry.headCount} at head`;
|
|
48
|
+
const lines = [` ${source} ⇢ ${entry.specifier} (${entry.kind || "unknown kind"}; ${counts})`];
|
|
49
|
+
if (entry.note !== undefined) lines.push(` ${entry.note}`);
|
|
50
|
+
const sites = entry.headSites.length > 0 ? entry.headSites : entry.baseSites;
|
|
51
|
+
for (const site of sites) {
|
|
52
|
+
lines.push(` at ${site.file}:${site.line}:${site.column}`);
|
|
53
|
+
}
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One unknown entry — the reason is the load-bearing half. */
|
|
58
|
+
function unknownLines(entry) {
|
|
59
|
+
return [` ? ${entry.reason}`];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One classification bucket as a section, or nothing when it is empty.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} heading Rendered above the entries, already carrying its count.
|
|
66
|
+
* @param {string[][]} entryLines
|
|
67
|
+
* @returns {string[]}
|
|
68
|
+
*/
|
|
69
|
+
function section(heading, entryLines) {
|
|
70
|
+
if (entryLines.length === 0) return [];
|
|
71
|
+
return [heading, ...entryLines.flat()];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The whole delta report.
|
|
76
|
+
*
|
|
77
|
+
* @param {{delta: object, coverage: object}} input `delta` is
|
|
78
|
+
* `../commands/delta.mjs`'s result payload; `coverage` its coverage block.
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function formatDeltaReport({ delta, coverage }) {
|
|
82
|
+
const sections = [];
|
|
83
|
+
const { baseline, head, summary, violations, unresolvable } = delta;
|
|
84
|
+
|
|
85
|
+
sections.push(
|
|
86
|
+
`baseline ${baseline.path} — ${describeOrigin(baseline.provenance)}, ` +
|
|
87
|
+
`${baseline.records} record${baseline.records === 1 ? "" : "s"}, ` +
|
|
88
|
+
`${baseline.projects} project${baseline.projects === 1 ? "" : "s"}`,
|
|
89
|
+
);
|
|
90
|
+
sections.push(
|
|
91
|
+
`head ${describeOrigin(head.provenance)}, ` +
|
|
92
|
+
`${head.records} record${head.records === 1 ? "" : "s"}, ` +
|
|
93
|
+
`${head.projects} project${head.projects === 1 ? "" : "s"}`,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
for (const note of coverage.notes ?? []) {
|
|
97
|
+
sections.push(`⚠ ${note}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const introducedWord = summary.introduced === 1 ? "violation" : "violations";
|
|
101
|
+
sections.push(
|
|
102
|
+
...section(
|
|
103
|
+
`⚠ ${summary.introduced} introduced ${introducedWord}` +
|
|
104
|
+
(summary.introducedWaived > 0
|
|
105
|
+
? ` (${summary.introducedWaived} waived — reported, not gating)`
|
|
106
|
+
: ""),
|
|
107
|
+
violations.introduced.map(violationLines),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
sections.push(
|
|
111
|
+
...section(
|
|
112
|
+
`✔ ${summary.resolved} resolved ${summary.resolved === 1 ? "violation" : "violations"}`,
|
|
113
|
+
violations.resolved.map(violationLines),
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
sections.push(
|
|
117
|
+
...section(
|
|
118
|
+
`= ${summary.unchanged} unchanged ${summary.unchanged === 1 ? "violation" : "violations"}`,
|
|
119
|
+
violations.unchanged.map(violationLines),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
sections.push(
|
|
123
|
+
...section(
|
|
124
|
+
`? ${summary.unknown} unclassifiable ${summary.unknown === 1 ? "item" : "items"}`,
|
|
125
|
+
violations.unknown.map(unknownLines),
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const unresolvableTotal =
|
|
130
|
+
summary.unresolvable.introduced +
|
|
131
|
+
summary.unresolvable.resolved +
|
|
132
|
+
summary.unresolvable.unchanged +
|
|
133
|
+
summary.unresolvable.unknown;
|
|
134
|
+
if (unresolvableTotal > 0) {
|
|
135
|
+
sections.push(
|
|
136
|
+
`unresolvable imports (carried, never counted as violations — no rule reached a verdict ` +
|
|
137
|
+
`about them)`,
|
|
138
|
+
);
|
|
139
|
+
sections.push(
|
|
140
|
+
...section(
|
|
141
|
+
` + ${summary.unresolvable.introduced} introduced`,
|
|
142
|
+
unresolvable.introduced.map(unresolvableLines),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
sections.push(
|
|
146
|
+
...section(
|
|
147
|
+
` - ${summary.unresolvable.resolved} resolved`,
|
|
148
|
+
unresolvable.resolved.map(unresolvableLines),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
sections.push(
|
|
152
|
+
...section(
|
|
153
|
+
` = ${summary.unresolvable.unchanged} unchanged`,
|
|
154
|
+
unresolvable.unchanged.map(unresolvableLines),
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
sections.push(
|
|
158
|
+
...section(
|
|
159
|
+
` ? ${summary.unresolvable.unknown} unclassifiable`,
|
|
160
|
+
unresolvable.unknown.map(unknownLines),
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The closing claim always states what was compared, so an empty delta is a
|
|
166
|
+
// verifiable statement rather than silence.
|
|
167
|
+
const compared =
|
|
168
|
+
`compared baseline ${describeOrigin(baseline.provenance)} ` +
|
|
169
|
+
`(${baseline.records} record${baseline.records === 1 ? "" : "s"}) against head ` +
|
|
170
|
+
`${describeOrigin(head.provenance)} (${head.records} record${head.records === 1 ? "" : "s"}, ` +
|
|
171
|
+
`${coverage.analyzedFiles} analyzed file${coverage.analyzedFiles === 1 ? "" : "s"}, ` +
|
|
172
|
+
`${coverage.projects} project${coverage.projects === 1 ? "" : "s"})`;
|
|
173
|
+
if (summary.introduced === 0) {
|
|
174
|
+
sections.push(`✔ no introduced violations — ${compared}`);
|
|
175
|
+
} else {
|
|
176
|
+
sections.push(
|
|
177
|
+
`${summary.introduced - summary.introducedWaived} introduced ${introducedWord} not ` +
|
|
178
|
+
`waived — ${compared}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return sections.join("\n");
|
|
183
|
+
}
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* - the constraint row(s) that matched (which rule applied)
|
|
16
16
|
* - the verdict (violation or allowed)
|
|
17
17
|
* - the message, when there is one (what the verdict means in prose)
|
|
18
|
+
* - for a violation: the allowed direction when the governing row states one,
|
|
19
|
+
* and a remediation line that is the author's declared guidance verbatim —
|
|
20
|
+
* or, when none is declared, an explicit pointer at the constraint row and
|
|
21
|
+
* its `decisionRef`, never a fix this renderer composed
|
|
18
22
|
* - coverage information (whether this explanation is complete)
|
|
19
23
|
*
|
|
20
24
|
* This module decides nothing. A formatter that filtered would be a rule
|
|
@@ -131,8 +135,31 @@ export function formatExplainReport({ explanation, coverage }) {
|
|
|
131
135
|
if (v.constraint?.description) {
|
|
132
136
|
sections.push(`${DETAIL}rule ${v.constraint.description}`);
|
|
133
137
|
}
|
|
138
|
+
// The allowed direction, verbatim from the governing row when it
|
|
139
|
+
// states one. A `notDependOnLibsWithTags` row states no allowed list,
|
|
140
|
+
// and computing its complement here would be this renderer inventing
|
|
141
|
+
// a direction the law never wrote — the constraint line above is the
|
|
142
|
+
// honest answer there, so no line is printed.
|
|
143
|
+
if (Array.isArray(v.constraint?.onlyDependOnLibsWithTags)) {
|
|
144
|
+
sections.push(
|
|
145
|
+
`${DETAIL}allowed ${formatTags(v.constraint.onlyDependOnLibsWithTags)}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
// The remediation line is always printed for a violation: the
|
|
149
|
+
// author's declared guidance verbatim, or an explicit pointer at
|
|
150
|
+
// where guidance lives — never a fix this renderer composed, and
|
|
151
|
+
// never silence a reader could mistake for "nothing to consult".
|
|
134
152
|
if (v.constraint?.remediation) {
|
|
135
153
|
sections.push(`${DETAIL}remediation ${v.constraint.remediation}`);
|
|
154
|
+
} else if (v.constraint) {
|
|
155
|
+
const ref = v.constraint.decisionRef
|
|
156
|
+
? ` and its decisionRef ${v.constraint.decisionRef}`
|
|
157
|
+
: "";
|
|
158
|
+
sections.push(`${DETAIL}remediation none declared — consult the constraint row${ref}`);
|
|
159
|
+
} else {
|
|
160
|
+
sections.push(
|
|
161
|
+
`${DETAIL}remediation none declared — no depConstraints row drives this check`,
|
|
162
|
+
);
|
|
136
163
|
}
|
|
137
164
|
}
|
|
138
165
|
} else {
|
package/src/report/sarif.mjs
CHANGED
|
@@ -680,6 +680,31 @@ export function sarifCoverageGapNotification(gap) {
|
|
|
680
680
|
},
|
|
681
681
|
};
|
|
682
682
|
}
|
|
683
|
+
// The accepted counterpart: the same bounded sample, saying the state is a
|
|
684
|
+
// recorded acceptance (`coverage.unowned`) rather than an open question —
|
|
685
|
+
// still a warning, because an accepted hole is still a hole, and this face
|
|
686
|
+
// must not be the one that stops saying so.
|
|
687
|
+
if (gap.kind === "accepted-unowned-files") {
|
|
688
|
+
const files = gap.files ?? [];
|
|
689
|
+
const languages = gap.languages ?? [];
|
|
690
|
+
const spans = languages.length > 0 ? ` (${languages.join(", ")})` : "";
|
|
691
|
+
const shown = files.slice(0, UNOWNED_SAMPLE_LIMIT);
|
|
692
|
+
const remaining = files.length - shown.length;
|
|
693
|
+
const listed =
|
|
694
|
+
shown.length > 0
|
|
695
|
+
? `: ${shown.join(", ")}${remaining > 0 ? `, and ${remaining} more` : ""}`
|
|
696
|
+
: "";
|
|
697
|
+
return {
|
|
698
|
+
level: "warning",
|
|
699
|
+
message: {
|
|
700
|
+
text:
|
|
701
|
+
`${files.length} tracked analyzable file${files.length === 1 ? "" : "s"}${spans} ` +
|
|
702
|
+
`owned by no project — accepted as coverage holes by the policy's coverage.unowned, ` +
|
|
703
|
+
`so no boundary verdict in this run covers ` +
|
|
704
|
+
`${files.length === 1 ? "it" : "them"}${listed}`,
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
}
|
|
683
708
|
return {
|
|
684
709
|
level: "warning",
|
|
685
710
|
message: {
|