@goodbones/cli 0.1.0-beta.2 → 0.1.0-beta.20

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.
Files changed (41) hide show
  1. package/build/dts/campaigns/host.d.ts +51 -0
  2. package/build/dts/campaigns/host.d.ts.map +1 -0
  3. package/build/dts/campaigns/live.d.ts +3 -0
  4. package/build/dts/campaigns/live.d.ts.map +1 -0
  5. package/build/dts/campaigns/none.d.ts +7 -0
  6. package/build/dts/campaigns/none.d.ts.map +1 -0
  7. package/build/dts/campaigns/verbs.d.ts +6 -0
  8. package/build/dts/campaigns/verbs.d.ts.map +1 -0
  9. package/build/dts/config-loader.d.ts +10 -3
  10. package/build/dts/config-loader.d.ts.map +1 -1
  11. package/build/dts/infer.d.ts +34 -0
  12. package/build/dts/infer.d.ts.map +1 -0
  13. package/build/dts/output.d.ts +9 -0
  14. package/build/dts/output.d.ts.map +1 -0
  15. package/build/dts/run.d.ts +67 -8
  16. package/build/dts/run.d.ts.map +1 -1
  17. package/build/esm/campaigns/host.js +2 -0
  18. package/build/esm/campaigns/host.js.map +1 -0
  19. package/build/esm/campaigns/live.js +106 -0
  20. package/build/esm/campaigns/live.js.map +1 -0
  21. package/build/esm/campaigns/none.js +34 -0
  22. package/build/esm/campaigns/none.js.map +1 -0
  23. package/build/esm/campaigns/verbs.js +489 -0
  24. package/build/esm/campaigns/verbs.js.map +1 -0
  25. package/build/esm/config-loader.js +66 -10
  26. package/build/esm/config-loader.js.map +1 -1
  27. package/build/esm/infer.js +378 -0
  28. package/build/esm/infer.js.map +1 -0
  29. package/build/esm/output.js +8 -0
  30. package/build/esm/output.js.map +1 -0
  31. package/build/esm/run.js +555 -78
  32. package/build/esm/run.js.map +1 -1
  33. package/package.json +19 -5
  34. package/src/campaigns/host.ts +120 -0
  35. package/src/campaigns/live.ts +136 -0
  36. package/src/campaigns/none.ts +47 -0
  37. package/src/campaigns/verbs.ts +620 -0
  38. package/src/config-loader.ts +81 -11
  39. package/src/infer.ts +511 -0
  40. package/src/output.ts +18 -0
  41. package/src/run.ts +870 -103
package/build/esm/run.js CHANGED
@@ -1,34 +1,54 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
3
  import * as path from "node:path";
3
- import { baselineOf, coverageOf, coverageShortfalls, decodeBaseline, decodeManifest, EMPTY_BASELINE, evaluateGraph, evaluateMemberSite, evaluateSelectedBindings, evaluateSelectedEdge, evaluateStructure, evaluateSurface, exportRulesSelecting, findManifestFile, fingerprintOf, formatManifestYaml, formatMessage, fractionsOf, hasGraphRules, listSourceFiles, MANIFEST_FILENAMES, MANIFEST_SCHEMA_ID, memberRulesSelecting, readManifestFile, requiredSiblingsOf, rulesSelecting, serializeBaseline, staleEntriesOf, surfaceRulesSelecting, unbaselined, } from "@goodbones/core";
4
+ import { baselineOf, breaches, CONFORMANCE_MEASURES, countedFiles, coverageOf, cyclesIn, decodeBaseline, decodeManifest, EMPTY_BASELINE, evaluateGraph, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, expandAliases, exportRulesSelecting, findManifestFile, fingerprintOf, formatManifestYaml, formatMessage, fractionsOf, globsOf, globToRegExp, hasGraphRules, heightOf, listSourceFiles, makeBaselineFilter, MANIFEST_FILENAMES, MANIFEST_SCHEMA_ID, memberRulesSelecting, readManifestFile, requiredSiblingsOf, residueOf, rulesSelecting, serializeBaseline, slackOf, SNAPSHOT_VERSION, staleEntriesOf, surfaceRulesSelecting, vacancyOf, } from "@goodbones/core";
4
5
  import * as Effect from "effect/Effect";
5
6
  import * as Result from "effect/Result";
6
- import { loadPolicyFromFile } from "./config-loader.js";
7
+ import { loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
7
8
  import { buildGraph } from "./graph.js";
9
+ import { infer } from "./infer.js";
10
+ import { count, fail, report } from "./output.js";
8
11
  import { sourceFactsOf } from "./source-facts.js";
9
- const fail = (message) => ({ _tag: "CliFailure", message });
10
- export const collectFindings = (policy, roots) => {
11
- const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
12
+ export const collectFindings = (policy, roots, options = {}) => {
13
+ // A campaign may widen the walk past the packs' extensions; a file only a
14
+ // campaign asked for is seen by the campaigns and by no other family.
15
+ const walked = listSourceFiles(policy.repoRoot, roots, policy.languages, policy.campaigns.widenedExtensions(policy));
16
+ const known = new Set(policy.languages.flatMap((one) => one.extensions));
17
+ const files = walked.filter((file) => known.has(path.extname(file)));
12
18
  const violations = [];
13
19
  const unresolved = [];
14
- // Each file is parsed at most once, whether the per-file families or the
15
- // graph pass asks first.
20
+ const edges = [];
21
+ // Each file is read and parsed at most once, whether the per-file
22
+ // families, the graph pass or a campaign asks first.
23
+ const texts = new Map();
24
+ const textOf = (file) => {
25
+ const cached = texts.get(file);
26
+ if (cached !== undefined)
27
+ return cached;
28
+ const text = readFileSync(path.join(policy.repoRoot, file), "utf8");
29
+ texts.set(file, text);
30
+ return text;
31
+ };
16
32
  const parsed = new Map();
17
33
  const factsOf = (file) => {
18
34
  const cached = parsed.get(file);
19
35
  if (cached !== undefined)
20
36
  return cached;
21
- const facts = sourceFactsOf(policy.repoRoot, file, policy.extractor);
37
+ const facts = policy.extractor.factsOf(file, textOf(file));
22
38
  parsed.set(file, facts);
23
39
  return facts;
24
40
  };
25
41
  // The graph is the whole repository resolved at once — the one question no
26
42
  // per-file adapter can ask — and is built only when a rule needs it.
27
- if (hasGraphRules(policy.graph)) {
28
- for (const violation of evaluateGraph(policy.graph, buildGraph(files, policy.resolver, factsOf))) {
43
+ const graph = options.graph === true || hasGraphRules(policy.graph)
44
+ ? buildGraph(files, policy.resolver, factsOf)
45
+ : null;
46
+ if (graph !== null && hasGraphRules(policy.graph)) {
47
+ for (const violation of evaluateGraph(policy.graph, graph))
29
48
  violations.push(violation);
30
- }
31
49
  }
50
+ // The campaigns, over every walked file, through the same caches.
51
+ const campaigns = policy.campaigns.evaluate(policy, roots, walked, { textOf, factsOf });
32
52
  for (const file of files) {
33
53
  for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
34
54
  violations.push(violation);
@@ -54,17 +74,23 @@ export const collectFindings = (policy, roots) => {
54
74
  }
55
75
  for (const specifier of facts.specifiers) {
56
76
  const edge = { importer: file, specifier };
57
- const imported = evaluateSelectedEdge(selectedImports, policy.resolver, edge);
58
- if (Result.isFailure(imported)) {
59
- if (policy.config.resolve.unresolved === "off")
60
- continue;
61
- if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier)))
77
+ // A file no import rule selects never needs its imports resolved, which
78
+ // is what keeps resolution off the hot path for the bulk of the repo.
79
+ if (selectedImports.length > 0) {
80
+ const resolved = policy.resolver.resolve(file, specifier);
81
+ if (Result.isFailure(resolved)) {
82
+ if (policy.config.resolve.unresolved === "off")
83
+ continue;
84
+ if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier)))
85
+ continue;
86
+ unresolved.push({ file, specifier, detail: resolved.failure.detail });
62
87
  continue;
63
- unresolved.push(`${file} → ${specifier} (${imported.failure.detail})`);
64
- continue;
88
+ }
89
+ edges.push({ importer: file, target: resolved.success });
90
+ for (const violation of evaluateResolvedEdge(selectedImports, file, resolved.success)) {
91
+ violations.push(violation);
92
+ }
65
93
  }
66
- for (const violation of imported.success)
67
- violations.push(violation);
68
94
  const bound = facts.bindings.get(specifier) ?? [];
69
95
  const exported = evaluateSelectedBindings(selectedExports, policy.resolver, {
70
96
  ...edge,
@@ -76,7 +102,7 @@ export const collectFindings = (policy, roots) => {
76
102
  }
77
103
  }
78
104
  }
79
- return { violations, unresolved, files: files.length };
105
+ return { violations, campaigns, unresolved, files: files.length, edges, graph };
80
106
  };
81
107
  const baselinePathOf = (policy) => policy.config.baseline === undefined
82
108
  ? null
@@ -92,65 +118,422 @@ const readBaseline = (policy) => {
92
118
  return EMPTY_BASELINE;
93
119
  }
94
120
  };
95
- const report = (lines) => Effect.sync(() => {
96
- for (const line of lines)
97
- process.stdout.write(`${line}\n`);
98
- });
99
121
  const describe = (violation) => ` ${violation.file}\n ${formatMessage(violation)}`;
100
- export const check = (policy, roots) => Effect.gen(function* () {
101
- const findings = collectFindings(policy, roots);
122
+ const COVERAGE_FAMILIES = [
123
+ "imports",
124
+ "structure",
125
+ "members",
126
+ "surface",
127
+ "graph",
128
+ ];
129
+ const sha256Of = (file) => {
130
+ try {
131
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
132
+ }
133
+ catch {
134
+ return "";
135
+ }
136
+ };
137
+ export const checkReport = (policy, roots, manifestPath) => reportOf(policy, roots, manifestPath, collectFindings(policy, roots)).report;
138
+ // The patterns the policy's `limits.outside` names, alias-expanded, each a
139
+ // subtree.
140
+ const outsideOf = (policy) => globsOf(policy.config.limits?.outside ?? []).map((glob) => globToRegExp(expandAliases(glob, policy.config.aliases ?? {})));
141
+ const measuresOf = (policy, files, edges) => {
142
+ // Slack is measured over the walked files as well as the edges: an
143
+ // allowlist that selects no file is vacant, and its entries are reported as
144
+ // that rather than as lines nobody needs.
145
+ const { concentration, slack } = slackOf(policy.importRules, edges, files);
146
+ return {
147
+ // Residue is a count of files, and counts the ones the limits count. A
148
+ // node is live or vacant by every file under it, counted or not.
149
+ residue: residueOf(policy, countedFiles(files, outsideOf(policy))),
150
+ vacant: vacancyOf(policy.importRules, files),
151
+ slack,
152
+ concentration,
153
+ };
154
+ };
155
+ // A fragment entry is concentrated when it is used at fewer than half the
156
+ // nodes granted it — the text report's threshold, and the ceiling's.
157
+ const isConcentrated = (one) => one.usedAt * 2 < one.of;
158
+ const countsOf = (measures) => ({
159
+ residue: measures.residue.files.length,
160
+ vacant: measures.vacant.length,
161
+ slack: measures.slack.length,
162
+ concentration: measures.concentration.filter(isConcentrated).length,
163
+ });
164
+ const reportOf = (policy, roots, manifestPath, findings) => {
102
165
  const baseline = readBaseline(policy);
103
- const reportable = unbaselined(baseline, findings.violations);
104
166
  const stale = staleEntriesOf(baseline, findings.violations);
105
- yield* report(reportable.map(describe));
106
- yield* report(findings.unresolved.map((one) => ` unresolved: ${one}`));
107
- const carried = findings.violations.length - reportable.length;
108
- yield* report([
167
+ const { isBaselined } = makeBaselineFilter(baseline);
168
+ const violations = [
169
+ ...findings.violations.map((violation) => ({
170
+ ...violation,
171
+ fingerprint: fingerprintOf(violation),
172
+ baselined: isBaselined(violation),
173
+ ledgered: false,
174
+ })),
175
+ // The hits that count: those whose objective is in window for the
176
+ // sector they fall in.
177
+ ...policy.campaigns.hitsOf(policy, findings.campaigns).map((hit) => ({
178
+ ...hit.violation,
179
+ fingerprint: fingerprintOf(hit.violation),
180
+ baselined: false,
181
+ ledgered: hit.ledgered,
182
+ objective: hit.objective,
183
+ sector: hit.sector,
184
+ entry: hit.entry,
185
+ })),
186
+ ];
187
+ const campaigns = policy.campaigns.reportsOf(policy, findings.campaigns);
188
+ // The floors. A policy states how much of the tree it reaches, per
189
+ // family; falling under is a policy that quietly stopped covering files.
190
+ // Both are held over the files the limits count: what `limits.outside`
191
+ // names is judged by every rule and left out of these numbers.
192
+ const floors = policy.config.limits?.coverage ?? {};
193
+ const unreached = policy.config.limits?.unreached ?? {};
194
+ const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
195
+ const counted = countedFiles(files, outsideOf(policy));
196
+ const found = coverageOf(policy, counted);
197
+ const covered = (family) => family === "structure" ? found.structure.enumerated : found[family].covered;
198
+ const coverage = Object.fromEntries(COVERAGE_FAMILIES.map((family) => {
199
+ const floor = floors[family];
200
+ const ceiling = unreached[family];
201
+ return [
202
+ family,
203
+ {
204
+ covered: covered(family),
205
+ total: found.files,
206
+ ...(floor === undefined ? {} : { floor }),
207
+ ...(ceiling === undefined ? {} : { ceiling }),
208
+ },
209
+ ];
210
+ }));
211
+ const shortfalls = shortfallsOf(coverage);
212
+ const overruns = overrunsOf(coverage);
213
+ // The ceilings. What no family reaches, what no file is under and what
214
+ // nothing imports through are each a count the policy may hold itself
215
+ // to; rising over one is a manifest that quietly widened.
216
+ const ceilings = policy.config.limits?.conformance ?? {};
217
+ const measures = measuresOf(policy, files, findings.edges);
218
+ const counts = countsOf(measures);
219
+ const conformance = Object.fromEntries(CONFORMANCE_MEASURES.map((measure) => {
220
+ const ceiling = ceilings[measure];
221
+ return [measure, { count: counts[measure], ...(ceiling === undefined ? {} : { ceiling }) }];
222
+ }));
223
+ const excesses = excessesOf(conformance);
224
+ const reportable = violations.filter((one) => !one.baselined && !one.ledgered).length;
225
+ const report = {
226
+ version: 1,
227
+ files: findings.files,
228
+ outside: files.length - counted.length,
229
+ roots,
230
+ ok: reportable === 0 &&
231
+ findings.unresolved.length === 0 &&
232
+ stale.length === 0 &&
233
+ shortfalls.length === 0 &&
234
+ overruns.length === 0 &&
235
+ excesses.length === 0 &&
236
+ policy.campaigns.failuresOf(campaigns).length === 0,
237
+ manifest: {
238
+ path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
239
+ sha256: sha256Of(manifestPath),
240
+ },
241
+ violations,
242
+ unresolved: findings.unresolved,
243
+ stale,
244
+ coverage,
245
+ conformance,
246
+ adoption: {
247
+ unrestricted: policy.adoption.unrestricted,
248
+ partial: policy.adoption.partial,
249
+ },
250
+ campaigns,
251
+ };
252
+ return { report, measures, files };
253
+ };
254
+ const shortfallsOf = (coverage) => COVERAGE_FAMILIES.flatMap((family) => {
255
+ const { covered, floor, total } = coverage[family];
256
+ const actual = total === 0 ? 1 : covered / total;
257
+ return floor === undefined || !breaches({ direction: "up", limit: floor, tolerance: 0 }, actual)
258
+ ? []
259
+ : [{ family, actual, floor }];
260
+ });
261
+ const overrunsOf = (coverage) => COVERAGE_FAMILIES.flatMap((family) => {
262
+ const { ceiling, covered, total } = coverage[family];
263
+ const unreached = total - covered;
264
+ return ceiling === undefined ||
265
+ !breaches({ direction: "down", limit: ceiling, tolerance: 0 }, unreached)
266
+ ? []
267
+ : [{ family, unreached, ceiling }];
268
+ });
269
+ const excessesOf = (conformance) => CONFORMANCE_MEASURES.flatMap((measure) => {
270
+ const { ceiling, count } = conformance[measure];
271
+ return ceiling === undefined ||
272
+ !breaches({ direction: "down", limit: ceiling, tolerance: 0 }, count)
273
+ ? []
274
+ : [{ measure, count, ceiling }];
275
+ });
276
+ const failureOf = (policy, report, shortfalls) => {
277
+ if (report.stale.length > 0)
278
+ return fail("stale baseline entries");
279
+ const [campaignFailure] = policy.campaigns.failuresOf(report.campaigns);
280
+ if (campaignFailure !== undefined)
281
+ return fail(campaignFailure);
282
+ if (shortfalls.length > 0)
283
+ return fail("coverage below floor");
284
+ if (overrunsOf(report.coverage).length > 0)
285
+ return fail("unreached above ceiling");
286
+ if (excessesOf(report.conformance).length > 0)
287
+ return fail("conformance above ceiling");
288
+ if (report.ok)
289
+ return null;
290
+ return fail("architecture violations");
291
+ };
292
+ // What each measure counts, as the failure names it.
293
+ const MEASURE_NOUNS = {
294
+ residue: ["file no family reaches", "files no family reaches"],
295
+ vacant: ["node no file is under", "nodes no file is under"],
296
+ slack: ["allowance nothing imports through", "allowances nothing imports through"],
297
+ concentration: [
298
+ "allowance used at fewer than half the nodes granted",
299
+ "allowances used at fewer than half the nodes granted",
300
+ ],
301
+ };
302
+ const describeExcess = (one) => {
303
+ const [singular, plural] = MEASURE_NOUNS[one.measure];
304
+ return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
305
+ };
306
+ const renderCampaigns = (policy, report) => policy.campaigns.renderReports(report.campaigns, report.violations.flatMap((one) => one.kind === "campaign" &&
307
+ one.objective !== undefined &&
308
+ one.sector !== undefined &&
309
+ one.entry !== undefined
310
+ ? [
311
+ {
312
+ violation: one,
313
+ objective: one.objective,
314
+ sector: one.sector,
315
+ entry: one.entry,
316
+ ledgered: one.ledgered,
317
+ },
318
+ ]
319
+ : []));
320
+ const renderText = (policy, report) => {
321
+ const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
322
+ const carried = report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
323
+ const shortfalls = shortfallsOf(report.coverage);
324
+ const overruns = overrunsOf(report.coverage);
325
+ const excesses = excessesOf(report.conformance);
326
+ return [
327
+ ...reportable.map(describe),
328
+ ...report.unresolved.map((one) => ` unresolved: ${one.file} → ${one.specifier} (${one.detail})`),
109
329
  "",
110
- `${String(findings.files)} files, ${String(reportable.length)} violations` +
330
+ `${String(report.files)} files, ${String(reportable.length)} violations` +
111
331
  (carried > 0 ? `, ${String(carried)} carried by the baseline` : ""),
112
- ]);
113
- if (stale.length > 0) {
114
332
  // The ratchet: a fixed violation must leave the baseline, or the floor
115
333
  // never rises and the file stops describing anything real.
116
- yield* report([
117
- "",
118
- `${String(stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
119
- ...stale.map((entry) => ` ${entry}`),
120
- "",
121
- " architecture baseline # rewrites the file from what still fires",
122
- ]);
123
- return yield* Effect.fail(fail("stale baseline entries"));
124
- }
125
- // The floors. A policy states how much of the tree it reaches, per
126
- // family; falling under is a policy that quietly stopped covering files.
127
- const floors = policy.config.limits?.coverage;
128
- const shortfalls = floors === undefined
129
- ? []
130
- : coverageShortfalls(coverageOf(policy, listSourceFiles(policy.repoRoot, roots, policy.languages)), floors);
131
- if (shortfalls.length > 0) {
132
- yield* report([
133
- "",
134
- "coverage is below the floor the policy states for itself:",
135
- ...shortfalls.map((one) => ` ${one.family}: ${percent(one.actual)} covered, floor ${percent(one.floor)}`),
136
- "",
137
- " architecture coverage # which files no rule reaches",
138
- ]);
139
- return yield* Effect.fail(fail("coverage below floor"));
140
- }
141
- if (reportable.length > 0 || findings.unresolved.length > 0) {
142
- return yield* Effect.fail(fail("architecture violations"));
143
- }
334
+ ...(report.stale.length === 0
335
+ ? []
336
+ : [
337
+ "",
338
+ `${String(report.stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
339
+ ...report.stale.map((entry) => ` ${entry}`),
340
+ "",
341
+ " architecture baseline # rewrites the file from what still fires",
342
+ ]),
343
+ ...(shortfalls.length === 0
344
+ ? []
345
+ : [
346
+ "",
347
+ "coverage is below the floor the policy states for itself:",
348
+ ...shortfalls.map((one) => ` ${one.family}: ${percent(one.actual)} covered, floor ${percent(one.floor)}`),
349
+ "",
350
+ " architecture coverage # which files no rule reaches",
351
+ ]),
352
+ ...(overruns.length === 0
353
+ ? []
354
+ : [
355
+ "",
356
+ "more files are unreached than the ceiling the policy states for itself:",
357
+ ...overruns.map((one) => ` ${one.family}: ${count(one.unreached, "file")} unreached, ceiling ${String(one.ceiling)}`),
358
+ "",
359
+ " architecture coverage # which files no rule reaches",
360
+ ]),
361
+ ...(excesses.length === 0
362
+ ? []
363
+ : [
364
+ "",
365
+ "conformance is above the ceiling the policy states for itself:",
366
+ ...excesses.map(describeExcess),
367
+ "",
368
+ " architecture conformance # which files, nodes and allowances",
369
+ ]),
370
+ ...renderCampaigns(policy, report),
371
+ ];
372
+ };
373
+ export const check = (policy, roots, options) => Effect.gen(function* () {
374
+ const report_ = checkReport(policy, roots, options.manifestPath);
375
+ // JSON is one object on stdout and nothing else there; the failure, when
376
+ // there is one, is a sentence on stderr and the exit code, as in text.
377
+ yield* report(options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(policy, report_));
378
+ const failure = failureOf(policy, report_, shortfallsOf(report_.coverage));
379
+ if (failure !== null)
380
+ return yield* Effect.fail(failure);
144
381
  });
145
382
  const percent = (fraction) => `${String(Math.floor(fraction * 100))}%`;
383
+ // The conformance snapshot: `check`'s report grown with what no family
384
+ // reaches, what the allowlists permit and nothing uses, the cycle count and
385
+ // the size of the debt — the whole distance between the tree and the
386
+ // manifest, as one document another run can be compared against. Its shape
387
+ // is the core's `Snapshot`, and the schema published beside the manifest's.
388
+ export const snapshotOf = (policy, roots, manifestPath) => {
389
+ const findings = collectFindings(policy, roots, { graph: true });
390
+ const { files, measures, report: report_ } = reportOf(policy, roots, manifestPath, findings);
391
+ const graph = findings.graph ?? { files, edges: new Map() };
392
+ const heights = heightOf(graph);
393
+ // Leaf edges first. A violation names a target when it is about an edge;
394
+ // the cost of fixing it is roughly how much of the graph stands beneath
395
+ // that target, so the ones nearest the ground come first and a reader
396
+ // starting at the top of the list is starting where a fix stays local.
397
+ // Ties keep the fingerprint order, so the list is the same on every run.
398
+ const heightOfViolation = (one) => heights.get(one.subject ?? "") ?? heights.get(one.file) ?? 0;
399
+ const violations = [...report_.violations].sort((left, right) => {
400
+ const byHeight = heightOfViolation(left) - heightOfViolation(right);
401
+ return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
402
+ });
403
+ const campaigns = policy.campaigns.snapshotOf(policy, findings.campaigns);
404
+ return {
405
+ version: SNAPSHOT_VERSION,
406
+ manifest: report_.manifest,
407
+ roots: report_.roots,
408
+ files: report_.files,
409
+ outside: report_.outside,
410
+ ok: report_.ok,
411
+ coverage: report_.coverage,
412
+ conformance: report_.conformance,
413
+ residue: measures.residue,
414
+ vacant: measures.vacant,
415
+ violations,
416
+ unresolved: report_.unresolved,
417
+ stale: report_.stale,
418
+ baseline: { size: readBaseline(policy).entries.length },
419
+ cycles: cyclesIn(graph).length,
420
+ slack: measures.slack,
421
+ concentration: measures.concentration,
422
+ adoption: report_.adoption,
423
+ campaigns,
424
+ };
425
+ };
426
+ // The unreached ceiling beside a family's row, and the ratchet's nudge when
427
+ // the count has fallen under it: a ceiling is lowered by hand.
428
+ const unreachedMark = (covered, total, ceiling) => {
429
+ if (ceiling === undefined)
430
+ return "";
431
+ const left = total - covered;
432
+ if (left > ceiling)
433
+ return ` ${String(left)} unreached > ${String(ceiling)} ✗`;
434
+ return ` ${String(left)} unreached ≤ ${String(ceiling)} ✓${left < ceiling ? `, lower it to ${String(left)}` : ""}`;
435
+ };
436
+ const outsideNote = (outside) => outside === 0 ? "" : `, ${count(outside, "file")} outside the limits`;
437
+ const renderSnapshot = (policy, snapshot) => {
438
+ const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
439
+ const carried = snapshot.violations.length - reportable.length;
440
+ const row = (family) => {
441
+ const { ceiling, covered, floor, total } = snapshot.coverage[family];
442
+ const fraction = total === 0 ? 1 : covered / total;
443
+ const mark = floor === undefined
444
+ ? ""
445
+ : fraction >= floor
446
+ ? ` ≥ ${percent(floor)} ✓`
447
+ : ` < ${percent(floor)} ✗`;
448
+ return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(total)} ${percent(fraction).padStart(4)}${mark}${unreachedMark(covered, total, ceiling)}`;
449
+ };
450
+ const section = (title, lines) => [
451
+ "",
452
+ title,
453
+ ...lines,
454
+ ];
455
+ const vacantWidth = Math.max(0, ...snapshot.vacant.map((one) => one.node.length));
456
+ // The document carries every partly-used fragment entry; the text shows
457
+ // the ones concentrated enough to read as a per-file rule written wide.
458
+ const concentrated = snapshot.concentration.filter(isConcentrated);
459
+ // The ceiling beside each measure that has one, and the ratchet's nudge
460
+ // when the count has fallen under it: a ceiling is lowered by hand.
461
+ const ceilingMark = (measure) => {
462
+ const { ceiling, count: actual } = snapshot.conformance[measure];
463
+ if (ceiling === undefined)
464
+ return "";
465
+ if (actual > ceiling)
466
+ return ` > ${String(ceiling)} ✗`;
467
+ return ` ≤ ${String(ceiling)} ✓${actual < ceiling ? `, lower it to ${String(actual)}` : ""}`;
468
+ };
469
+ return [
470
+ `${String(snapshot.files)} files under ${snapshot.roots.join(", ")}, against ${snapshot.manifest.path}${outsideNote(snapshot.outside)}`,
471
+ ...section("coverage", COVERAGE_FAMILIES.map(row)),
472
+ ...section(`residue: ${count(snapshot.residue.files.length, "file")} no family reaches` +
473
+ (snapshot.residue.folders.length === 0
474
+ ? ""
475
+ : `, ${count(snapshot.residue.folders.length, "folder")} wholly`) +
476
+ ceilingMark("residue"), [
477
+ ...snapshot.residue.folders.map((folder) => ` ${folder}/`),
478
+ ...snapshot.residue.files
479
+ .filter((file) => !snapshot.residue.folders.some((folder) => file.startsWith(`${folder}/`)))
480
+ .map((file) => ` ${file}`),
481
+ ]),
482
+ ...section(`vacant: ${count(snapshot.vacant.length, "node")} ${snapshot.vacant.length === 1 ? "selects" : "select"} no file` +
483
+ ceilingMark("vacant"), snapshot.vacant.map((one) => ` ${one.node.padEnd(vacantWidth)} ${count(one.allowances, "allowance")}`)),
484
+ ...section(`violations: ${count(reportable.length, "reportable")}` +
485
+ (carried > 0 ? `, ${String(carried)} carried by the baseline or a ledger` : "") +
486
+ (snapshot.stale.length > 0
487
+ ? `, ${count(snapshot.stale.length, "stale entry", "stale entries")}`
488
+ : "") +
489
+ (reportable.length > 0 ? " — nearest the ground first" : ""), reportable.map(describe)),
490
+ ...(snapshot.unresolved.length === 0
491
+ ? []
492
+ : section(`unresolved: ${count(snapshot.unresolved.length, "import")} no rule can police`, snapshot.unresolved.map((one) => ` ${one.file} → ${one.specifier} (${one.detail})`))),
493
+ ...section(`slack: ${count(snapshot.slack.length, "allowance")} nothing imports through` +
494
+ ceilingMark("slack"), snapshot.slack.map((one) => ` ${one.node}: ${one.kind} ${JSON.stringify(one.entry)}` +
495
+ (one.of === undefined ? "" : ` (via use, at ${count(one.of, "node")})`))),
496
+ ...(concentrated.length === 0 && snapshot.conformance.concentration.ceiling === undefined
497
+ ? []
498
+ : section(`concentrated: ${count(concentrated.length, "allowance")} used at fewer than half the nodes granted` +
499
+ ceilingMark("concentration"), concentrated.map((one) => ` ${one.fragment}: ${one.kind} ${JSON.stringify(one.entry)} used at ${String(one.usedAt)} of ${count(one.of, "node")}`))),
500
+ ...(snapshot.campaigns.length === 0
501
+ ? []
502
+ : section(`campaigns: ${count(snapshot.campaigns.length, "campaign")}` +
503
+ (snapshot.campaigns.some((one) => one.stalled)
504
+ ? `, ${count(snapshot.campaigns.filter((one) => one.stalled).length, "stalled", "stalled")}`
505
+ : "") +
506
+ (snapshot.campaigns.some((one) => one.complete && one.ledgered)
507
+ ? `, ${count(snapshot.campaigns.filter((one) => one.complete && one.ledgered).length, "complete", "complete")}`
508
+ : ""), policy.campaigns.renderRows(snapshot.campaigns))),
509
+ "",
510
+ `cycles: ${String(snapshot.cycles)}`,
511
+ `baseline: ${count(snapshot.baseline.size, "entry", "entries")}`,
512
+ `adoption: ${count(snapshot.adoption.unrestricted.length, "unrestricted tier")}, ${count(snapshot.adoption.partial.length, "partial tier")}`,
513
+ ];
514
+ };
515
+ // The report of the tree against the manifest. Unlike `check`, it never
516
+ // fails: it is a measurement, and the manifest it measures against may be one
517
+ // the tree was never expected to satisfy yet — `--against` names a target.
518
+ // `ok` in the document says what `check` would have done.
519
+ export const conformance = (policy, roots, options) => Effect.gen(function* () {
520
+ const snapshot = snapshotOf(policy, roots, options.manifestPath);
521
+ yield* report(options.format === "json"
522
+ ? [JSON.stringify(snapshot, null, 2)]
523
+ : renderSnapshot(policy, snapshot));
524
+ });
146
525
  // How much of the tree the policy reaches. A probe proves a rule can fire;
147
526
  // this is whether the files are there to fire on. Reported per family, with the
148
527
  // adoption backlog — the tiers that said "not tightened yet" — beneath it.
149
528
  export const coverage = (policy, roots) => Effect.gen(function* () {
150
- const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
529
+ // The limits are held over the files they count, so that is what the
530
+ // rows are over: what `limits.outside` names is said, and left out.
531
+ const walked = listSourceFiles(policy.repoRoot, roots, policy.languages);
532
+ const files = countedFiles(walked, outsideOf(policy));
151
533
  const found = coverageOf(policy, files);
152
534
  const fractions = fractionsOf(found);
153
535
  const floors = policy.config.limits?.coverage ?? {};
536
+ const ceilings = policy.config.limits?.unreached ?? {};
154
537
  const row = (family, covered, note) => {
155
538
  const floor = floors[family];
156
539
  const mark = floor === undefined
@@ -158,10 +541,10 @@ export const coverage = (policy, roots) => Effect.gen(function* () {
158
541
  : fractions[family] >= floor
159
542
  ? ` ≥ ${percent(floor)} ✓`
160
543
  : ` < ${percent(floor)} ✗`;
161
- return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(found.files)} ${percent(fractions[family]).padStart(4)} ${note}${mark}`;
544
+ return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(found.files)} ${percent(fractions[family]).padStart(4)} ${note}${mark}${unreachedMark(covered, found.files, ceilings[family])}`;
162
545
  };
163
546
  yield* report([
164
- `${String(found.files)} files under ${roots.join(", ")}`,
547
+ `${String(walked.length)} files under ${roots.join(", ")}${outsideNote(walked.length - files.length)}`,
165
548
  "",
166
549
  row("imports", found.imports.covered, "under an import allowlist"),
167
550
  row("structure", found.structure.enumerated, `in an enumerated folder (${String(found.structure.open)} in an open one, ${String(found.structure.total - found.structure.enumerated - found.structure.open)} in none)`),
@@ -190,7 +573,7 @@ export const writeBaseline = (policy, roots) => Effect.gen(function* () {
190
573
  });
191
574
  // The question a tree config makes harder to answer than a flat one: given a
192
575
  // file, what governs it? A flat config you grep; a tree you have to walk.
193
- export const explain = (policy, file) => Effect.gen(function* () {
576
+ export const explain = (policy, file, roots = ["packages"]) => Effect.gen(function* () {
194
577
  const relative = path.relative(policy.repoRoot, path.resolve(policy.repoRoot, file));
195
578
  const selected = rulesSelecting(policy.importRules, relative);
196
579
  // An allowlist rule names no `to` — it fires when the target matches none of
@@ -204,7 +587,7 @@ export const explain = (policy, file) => Effect.gen(function* () {
204
587
  const governing = policy.structure.folders.filter((rule) => rule.folder.some((pattern) => pattern.test(path.dirname(relative))));
205
588
  const naming = policy.structure.naming.filter((rule) => rule.file.some((pattern) => pattern.test(relative)) &&
206
589
  !rule.fileNot.some((pattern) => pattern.test(relative)));
207
- const firstSentence = (message) => `${message.split(". ")[0] ?? message}.`;
590
+ const firstSentence = (message) => `${(message.split(". ")[0] ?? message).replace(/\.$/, "")}.`;
208
591
  const named = (rule) => ` ${rule.name} — ${firstSentence(rule.message)}`;
209
592
  // The families beyond imports and structure: which rules of each speak to
210
593
  // this file at all. What they are evaluated against is `facts`' answer.
@@ -222,6 +605,9 @@ export const explain = (policy, file) => Effect.gen(function* () {
222
605
  .map((rule) => `${named(rule)} (reach)`),
223
606
  ];
224
607
  const section = (title, lines) => lines.length === 0 ? [] : ["", title, ...lines];
608
+ // The campaigns selecting the file, evaluated over their files only
609
+ // when one does: a sector's phase is derived from all of them.
610
+ const campaignLines = policy.campaigns.explainLines(policy, relative, () => collectFindings(policy, roots).campaigns);
225
611
  yield* report([
226
612
  relative,
227
613
  "",
@@ -251,6 +637,7 @@ export const explain = (policy, file) => Effect.gen(function* () {
251
637
  ...section(" vocabulary (members):", vocabulary.map(named)),
252
638
  ...section(" may export (surface):", surface.map(named)),
253
639
  ...section(" graph:", graph),
640
+ ...section(" campaigns:", campaignLines),
254
641
  ]);
255
642
  });
256
643
  // The other half of `explain`. `explain` says which rules select a file; this
@@ -329,12 +716,35 @@ resolve:
329
716
  baseline: .architecture-baseline.json
330
717
 
331
718
  # Ceilings on how many tiers may say "not tightened yet". At zero, raising one
332
- # is a line in this file a reviewer sees.
719
+ # is a line in this file a reviewer sees. The same block takes coverage floors
720
+ # and ceilings on what \`architecture conformance\` measures, once there are
721
+ # numbers to write.
333
722
  # https://dataquail.github.io/goodbones/architecture-rules/enforcement/adoption/
334
723
  limits:
335
724
  unrestricted: 0
336
725
  partial: 0
337
726
 
727
+ # Refactors the repository is running, each an object: objectives (a
728
+ # detector with a ledger under \`.architecture-campaigns/\` of every place the
729
+ # pattern still occurs), over sectors the code births through a perimeter,
730
+ # through phases toward an end. Fill one in, then
731
+ # \`architecture objectives clear <id>\` to write its ledgers.
732
+ # https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
733
+ # campaigns:
734
+ # js-to-ts:
735
+ # why: The strict tsconfig cannot land while any src file is JavaScript.
736
+ # how: Rename to .ts, add types at the module boundary, leave the body alone.
737
+ # scope: { path: "src/**", extensions: [.js, .jsx] }
738
+ # perimeter: file
739
+ # objectives:
740
+ # is-ts:
741
+ # intent: A JavaScript file is one the strict compiler never checks.
742
+ # holdout: file
743
+ # match: { path: { file: "\\\\.(js|jsx)$" } }
744
+ # probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
745
+ # staleAfter: 14d
746
+ # onComplete: remove
747
+
338
748
  # The repository. One open root, reaching itself and the runtime; run
339
749
  # \`architecture check\` to see what else it reaches, and write that down here.
340
750
  # https://dataquail.github.io/goodbones/architecture-rules/manifest/imports/
@@ -403,45 +813,112 @@ export const migrate = (repoRoot, configFilename) => Effect.gen(function* () {
403
813
  `Then delete ${path.basename(from)}: a repository with two manifests is refused.`,
404
814
  ]);
405
815
  });
816
+ // The campaign commands, answered by the family the host was composed with:
817
+ // `campaigns` alone is the status table, `status --changed` the nudge,
818
+ // `attest` and `note` write a sector's record, `history` replays the ledgers;
819
+ // `objectives clear` and `objectives concede` write the ledgers. Without
820
+ // `@goodbones/campaigns` installed, each says what to install.
821
+ const verbContext = (policy, defaultRoots, argv, configFilename) => ({
822
+ policy,
823
+ defaultRoots,
824
+ argv,
825
+ evaluate: (roots) => collectFindings(policy, roots).campaigns,
826
+ reload: (repoRoot) => loadPolicyFromFile(repoRoot, configFilename),
827
+ manifestPath: manifestPathOf(policy.repoRoot, configFilename),
828
+ });
829
+ export const objectives = (policy, defaultRoots, argv) => policy.campaigns.objectives(verbContext(policy, defaultRoots, argv));
830
+ export const campaigns = (policy, defaultRoots, argv, configFilename) => policy.campaigns.campaigns(verbContext(policy, defaultRoots, argv, configFilename));
831
+ // The commands that judge a campaign, and so ask its report source.
832
+ const READS_REPORTS = new Set([
833
+ "check",
834
+ "conformance",
835
+ "baseline",
836
+ "campaigns",
837
+ "objectives",
838
+ "explain",
839
+ ]);
406
840
  export const run = (repoRoot, argv,
407
841
  // From ARCHITECTURE_CONFIG. Absent, the manifest is discovered by name.
408
842
  configFilename) => Effect.gen(function* () {
409
843
  const [command = "check", ...rest] = argv;
410
- // The two commands that write a manifest rather than read one.
844
+ // The three commands that write a manifest rather than read one.
411
845
  if (command === "init")
412
846
  return yield* init(repoRoot);
413
847
  if (command === "migrate")
414
848
  return yield* migrate(repoRoot, configFilename);
849
+ if (command === "infer") {
850
+ yield* infer(repoRoot, rest, configFilename);
851
+ return;
852
+ }
853
+ // `--against <file>` measures the tree against a manifest other than the
854
+ // repository's own — the target the team is moving toward. Only
855
+ // `conformance` takes it: a `check` against a manifest nobody is held to
856
+ // yet would fail for no one's benefit.
857
+ const againstAt = rest.indexOf("--against");
858
+ const against = againstAt === -1 ? undefined : rest[againstAt + 1];
859
+ if (againstAt !== -1 && (against === undefined || against.startsWith("--"))) {
860
+ return yield* Effect.fail(fail("--against needs a manifest path"));
861
+ }
862
+ if (against !== undefined && command !== "conformance") {
863
+ return yield* Effect.fail(fail(`--against is a \`conformance\` flag; ${command} does not take it`));
864
+ }
865
+ const manifestFilename = against ?? configFilename;
415
866
  const policy = yield* Effect.tryPromise({
416
- try: () => loadPolicyFromFile(repoRoot, configFilename),
867
+ try: () => loadPolicyFromFile(repoRoot, manifestFilename),
417
868
  catch: (cause) => fail(String(cause)),
418
869
  });
419
870
  yield* Effect.sync(() => {
420
871
  for (const notice of policy.notices)
421
872
  process.stderr.write(`deprecated: ${notice}\n`);
422
873
  });
423
- const roots = rest.length > 0 ? rest : ["packages"];
874
+ // Every `report` a campaign names is read now, before any file asks:
875
+ // a term's several commands run at once rather than one after another
876
+ // the first time a campaign selects a file. What cannot be read is kept
877
+ // for the campaign to report; a report that does not parse is refused.
878
+ if (READS_REPORTS.has(command)) {
879
+ yield* Effect.tryPromise({
880
+ try: () => policy.campaigns.readReports(policy),
881
+ catch: (cause) => fail(String(cause)),
882
+ });
883
+ }
884
+ const json = rest.includes("--json");
885
+ const positional = rest.filter((argument, index) => argument !== "--json" &&
886
+ argument !== "--against" &&
887
+ (againstAt === -1 || index !== againstAt + 1));
888
+ const roots = positional.length > 0 ? positional : ["packages"];
424
889
  switch (command) {
890
+ case "campaigns":
891
+ return yield* campaigns(policy, ["packages"], rest, configFilename);
892
+ case "objectives":
893
+ return yield* objectives(policy, ["packages"], rest);
425
894
  case "check":
426
- return yield* check(policy, roots);
895
+ return yield* check(policy, roots, {
896
+ format: json ? "json" : "text",
897
+ manifestPath: manifestPathOf(repoRoot, configFilename),
898
+ });
899
+ case "conformance":
900
+ return yield* conformance(policy, roots, {
901
+ format: json ? "json" : "text",
902
+ manifestPath: manifestPathOf(repoRoot, manifestFilename),
903
+ });
427
904
  case "baseline":
428
905
  return yield* writeBaseline(policy, roots);
429
906
  case "explain": {
430
- const [file] = rest;
907
+ const [file, ...explainRoots] = positional;
431
908
  if (file === undefined)
432
909
  return yield* Effect.fail(fail("explain needs a file path"));
433
- return yield* explain(policy, file);
910
+ return yield* explain(policy, file, explainRoots.length > 0 ? explainRoots : ["packages"]);
434
911
  }
435
912
  case "coverage":
436
913
  return yield* coverage(policy, roots);
437
914
  case "facts": {
438
- const [file] = rest.filter((argument) => argument !== "--json");
915
+ const [file] = positional;
439
916
  if (file === undefined)
440
917
  return yield* Effect.fail(fail("facts needs a file path"));
441
- return yield* facts(policy, file, rest.includes("--json") ? "json" : "text");
918
+ return yield* facts(policy, file, json ? "json" : "text");
442
919
  }
443
920
  default:
444
- return yield* Effect.fail(fail(`unknown command "${command}". Try: check | baseline | coverage | explain <file> | facts <file> [--json] | init | migrate`));
921
+ return yield* Effect.fail(fail(`unknown command "${command}". Try: check [--json] | conformance [--json] [--against <manifest>] | baseline | campaigns [status --changed | attest | note | history] | objectives [clear | concede] | coverage | explain <file> | facts <file> [--json] | init | infer | migrate`));
445
922
  }
446
923
  });
447
924
  export const fingerprint = fingerprintOf;