@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.
@@ -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 clock.
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 = { waivers, covered, expired, stale, suppressions, suppressed };
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 `customRules` is rejected by name. The
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 { depConstraints, moduleBoundaryOptions, boundarySuppressions, fitness, customRules } =
867
- module;
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 and newest — the declared rules
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 distinction between an
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 {
@@ -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: {