@goodbones/cli 0.1.0-beta.1 → 0.1.0-beta.11

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/build/esm/run.js CHANGED
@@ -1,34 +1,55 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
3
  import * as path from "node:path";
3
- import { baselineOf, coverageOf, coverageShortfalls, decodeBaseline, EMPTY_BASELINE, evaluateGraph, evaluateMemberSite, evaluateSelectedBindings, evaluateSelectedEdge, evaluateStructure, evaluateSurface, exportRulesSelecting, fingerprintOf, formatMessage, fractionsOf, hasGraphRules, listSourceFiles, memberRulesSelecting, requiredSiblingsOf, rulesSelecting, serializeBaseline, staleEntriesOf, surfaceRulesSelecting, unbaselined, } from "@goodbones/core";
4
+ import { attest, authorOf, baseSideAt, campaignFailuresOf, campaignReportsOf, campaignsOf, campaignsSelecting, clear, concede, evaluateCampaigns, explainCampaignLines, explainObjective, historyOf, hitsInWindow, ledgeredFilter, note, nudgeOf, readDiff, renderCampaignReports, renderCampaignRows, renderHistory, renderNudge, reportSpecsOf, snapshotCampaignsOf, widenedExtensions, } from "@goodbones/campaigns";
5
+ import { baselineOf, CONFORMANCE_MEASURES, coverageOf, cyclesIn, decodeBaseline, decodeManifest, EMPTY_BASELINE, evaluateGraph, evaluateMemberSite, evaluateResolvedEdge, evaluateSelectedBindings, evaluateStructure, evaluateSurface, exportRulesSelecting, findManifestFile, fingerprintOf, formatManifestYaml, formatMessage, fractionsOf, 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
6
  import * as Effect from "effect/Effect";
5
7
  import * as Result from "effect/Result";
6
- import { loadPolicyFromFile } from "./config-loader.js";
8
+ import { loadPolicyFromFile, manifestPathOf } from "./config-loader.js";
7
9
  import { buildGraph } from "./graph.js";
10
+ import { infer } from "./infer.js";
8
11
  import { sourceFactsOf } from "./source-facts.js";
9
12
  const fail = (message) => ({ _tag: "CliFailure", message });
10
- export const collectFindings = (policy, roots) => {
11
- const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
13
+ export const collectFindings = (policy, roots, options = {}) => {
14
+ // A campaign may widen the walk past the packs' extensions; a file only a
15
+ // campaign asked for is seen by the campaigns and by no other family.
16
+ const walked = listSourceFiles(policy.repoRoot, roots, policy.languages, widenedExtensions(policy));
17
+ const known = new Set(policy.languages.flatMap((one) => one.extensions));
18
+ const files = walked.filter((file) => known.has(path.extname(file)));
12
19
  const violations = [];
13
20
  const unresolved = [];
14
- // Each file is parsed at most once, whether the per-file families or the
15
- // graph pass asks first.
21
+ const edges = [];
22
+ // Each file is read and parsed at most once, whether the per-file
23
+ // families, the graph pass or a campaign asks first.
24
+ const texts = new Map();
25
+ const textOf = (file) => {
26
+ const cached = texts.get(file);
27
+ if (cached !== undefined)
28
+ return cached;
29
+ const text = readFileSync(path.join(policy.repoRoot, file), "utf8");
30
+ texts.set(file, text);
31
+ return text;
32
+ };
16
33
  const parsed = new Map();
17
34
  const factsOf = (file) => {
18
35
  const cached = parsed.get(file);
19
36
  if (cached !== undefined)
20
37
  return cached;
21
- const facts = sourceFactsOf(policy.repoRoot, file, policy.extractor);
38
+ const facts = policy.extractor.factsOf(file, textOf(file));
22
39
  parsed.set(file, facts);
23
40
  return facts;
24
41
  };
25
42
  // The graph is the whole repository resolved at once — the one question no
26
43
  // 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))) {
44
+ const graph = options.graph === true || hasGraphRules(policy.graph)
45
+ ? buildGraph(files, policy.resolver, factsOf)
46
+ : null;
47
+ if (graph !== null && hasGraphRules(policy.graph)) {
48
+ for (const violation of evaluateGraph(policy.graph, graph))
29
49
  violations.push(violation);
30
- }
31
50
  }
51
+ // The campaigns, over every walked file, through the same caches.
52
+ const campaigns = evaluateCampaigns(policy, roots, walked, { textOf, factsOf });
32
53
  for (const file of files) {
33
54
  for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
34
55
  violations.push(violation);
@@ -54,17 +75,23 @@ export const collectFindings = (policy, roots) => {
54
75
  }
55
76
  for (const specifier of facts.specifiers) {
56
77
  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")
78
+ // A file no import rule selects never needs its imports resolved, which
79
+ // is what keeps resolution off the hot path for the bulk of the repo.
80
+ if (selectedImports.length > 0) {
81
+ const resolved = policy.resolver.resolve(file, specifier);
82
+ if (Result.isFailure(resolved)) {
83
+ if (policy.config.resolve.unresolved === "off")
84
+ continue;
85
+ if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier)))
86
+ continue;
87
+ unresolved.push({ file, specifier, detail: resolved.failure.detail });
60
88
  continue;
61
- if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier)))
62
- continue;
63
- unresolved.push(`${file} → ${specifier} (${imported.failure.detail})`);
64
- continue;
89
+ }
90
+ edges.push({ importer: file, target: resolved.success });
91
+ for (const violation of evaluateResolvedEdge(selectedImports, file, resolved.success)) {
92
+ violations.push(violation);
93
+ }
65
94
  }
66
- for (const violation of imported.success)
67
- violations.push(violation);
68
95
  const bound = facts.bindings.get(specifier) ?? [];
69
96
  const exported = evaluateSelectedBindings(selectedExports, policy.resolver, {
70
97
  ...edge,
@@ -76,7 +103,7 @@ export const collectFindings = (policy, roots) => {
76
103
  }
77
104
  }
78
105
  }
79
- return { violations, unresolved, files: files.length };
106
+ return { violations, campaigns, unresolved, files: files.length, edges, graph };
80
107
  };
81
108
  const baselinePathOf = (policy) => policy.config.baseline === undefined
82
109
  ? null
@@ -97,52 +124,358 @@ const report = (lines) => Effect.sync(() => {
97
124
  process.stdout.write(`${line}\n`);
98
125
  });
99
126
  const describe = (violation) => ` ${violation.file}\n ${formatMessage(violation)}`;
100
- export const check = (policy, roots) => Effect.gen(function* () {
101
- const findings = collectFindings(policy, roots);
127
+ const COVERAGE_FAMILIES = [
128
+ "imports",
129
+ "structure",
130
+ "members",
131
+ "surface",
132
+ "graph",
133
+ ];
134
+ const sha256Of = (file) => {
135
+ try {
136
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
137
+ }
138
+ catch {
139
+ return "";
140
+ }
141
+ };
142
+ export const checkReport = (policy, roots, manifestPath) => reportOf(policy, roots, manifestPath, collectFindings(policy, roots)).report;
143
+ const measuresOf = (policy, files, edges) => {
144
+ // Slack is measured over the walked files as well as the edges: an
145
+ // allowlist that selects no file is vacant, and its entries are reported as
146
+ // that rather than as lines nobody needs.
147
+ const { concentration, slack } = slackOf(policy.importRules, edges, files);
148
+ return {
149
+ residue: residueOf(policy, files),
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 isLedgered = ledgeredFilter(policy, findings.campaigns);
169
+ const violations = [
170
+ ...findings.violations.map((violation) => ({
171
+ ...violation,
172
+ fingerprint: fingerprintOf(violation),
173
+ baselined: isBaselined(violation),
174
+ ledgered: false,
175
+ })),
176
+ // The hits that count: those whose objective is in window for the
177
+ // sector they fall in.
178
+ ...findings.campaigns.flatMap((evaluation) => hitsInWindow(evaluation).map((hit) => ({
179
+ ...hit.violation,
180
+ fingerprint: fingerprintOf(hit.violation),
181
+ baselined: false,
182
+ ledgered: isLedgered(hit),
183
+ objective: hit.objective,
184
+ sector: hit.sector,
185
+ entry: hit.entry,
186
+ }))),
187
+ ];
188
+ const campaigns = campaignReportsOf(policy, findings.campaigns);
189
+ // The floors. A policy states how much of the tree it reaches, per
190
+ // family; falling under is a policy that quietly stopped covering files.
191
+ const floors = policy.config.limits?.coverage ?? {};
192
+ const files = listSourceFiles(policy.repoRoot, roots, policy.languages);
193
+ const found = coverageOf(policy, files);
194
+ const covered = (family) => family === "structure" ? found.structure.enumerated : found[family].covered;
195
+ const coverage = Object.fromEntries(COVERAGE_FAMILIES.map((family) => {
196
+ const floor = floors[family];
197
+ return [
198
+ family,
199
+ {
200
+ covered: covered(family),
201
+ total: found.files,
202
+ ...(floor === undefined ? {} : { floor }),
203
+ },
204
+ ];
205
+ }));
206
+ const shortfalls = shortfallsOf(coverage);
207
+ // The ceilings. What no family reaches, what no file is under and what
208
+ // nothing imports through are each a count the policy may hold itself
209
+ // to; rising over one is a manifest that quietly widened.
210
+ const ceilings = policy.config.limits?.conformance ?? {};
211
+ const measures = measuresOf(policy, files, findings.edges);
212
+ const counts = countsOf(measures);
213
+ const conformance = Object.fromEntries(CONFORMANCE_MEASURES.map((measure) => {
214
+ const ceiling = ceilings[measure];
215
+ return [measure, { count: counts[measure], ...(ceiling === undefined ? {} : { ceiling }) }];
216
+ }));
217
+ const excesses = excessesOf(conformance);
218
+ const reportable = violations.filter((one) => !one.baselined && !one.ledgered).length;
219
+ const report = {
220
+ version: 1,
221
+ files: findings.files,
222
+ roots,
223
+ ok: reportable === 0 &&
224
+ findings.unresolved.length === 0 &&
225
+ stale.length === 0 &&
226
+ shortfalls.length === 0 &&
227
+ excesses.length === 0 &&
228
+ campaignFailuresOf(campaigns).length === 0,
229
+ manifest: {
230
+ path: path.relative(policy.repoRoot, manifestPath).replaceAll(path.sep, "/"),
231
+ sha256: sha256Of(manifestPath),
232
+ },
233
+ violations,
234
+ unresolved: findings.unresolved,
235
+ stale,
236
+ coverage,
237
+ conformance,
238
+ adoption: {
239
+ unrestricted: policy.adoption.unrestricted,
240
+ partial: policy.adoption.partial,
241
+ },
242
+ campaigns,
243
+ };
244
+ return { report, measures, files };
245
+ };
246
+ const shortfallsOf = (coverage) => COVERAGE_FAMILIES.flatMap((family) => {
247
+ const { covered, floor, total } = coverage[family];
248
+ const actual = total === 0 ? 1 : covered / total;
249
+ return floor === undefined || actual >= floor ? [] : [{ family, actual, floor }];
250
+ });
251
+ const excessesOf = (conformance) => CONFORMANCE_MEASURES.flatMap((measure) => {
252
+ const { ceiling, count } = conformance[measure];
253
+ return ceiling === undefined || count <= ceiling ? [] : [{ measure, count, ceiling }];
254
+ });
255
+ const failureOf = (report, shortfalls) => {
256
+ if (report.stale.length > 0)
257
+ return fail("stale baseline entries");
258
+ const [campaignFailure] = campaignFailuresOf(report.campaigns);
259
+ if (campaignFailure !== undefined)
260
+ return fail(campaignFailure);
261
+ if (shortfalls.length > 0)
262
+ return fail("coverage below floor");
263
+ if (excessesOf(report.conformance).length > 0)
264
+ return fail("conformance above ceiling");
265
+ if (report.ok)
266
+ return null;
267
+ return fail("architecture violations");
268
+ };
269
+ // What each measure counts, as the failure names it.
270
+ const MEASURE_NOUNS = {
271
+ residue: ["file no family reaches", "files no family reaches"],
272
+ vacant: ["node no file is under", "nodes no file is under"],
273
+ slack: ["allowance nothing imports through", "allowances nothing imports through"],
274
+ concentration: [
275
+ "allowance used at fewer than half the nodes granted",
276
+ "allowances used at fewer than half the nodes granted",
277
+ ],
278
+ };
279
+ const describeExcess = (one) => {
280
+ const [singular, plural] = MEASURE_NOUNS[one.measure];
281
+ return ` ${one.measure}: ${count(one.count, singular, plural)}, ceiling ${String(one.ceiling)}`;
282
+ };
283
+ const renderCampaigns = (report) => renderCampaignReports(report.campaigns, report.violations.flatMap((one) => one.kind === "campaign" &&
284
+ one.objective !== undefined &&
285
+ one.sector !== undefined &&
286
+ one.entry !== undefined
287
+ ? [
288
+ {
289
+ violation: one,
290
+ objective: one.objective,
291
+ sector: one.sector,
292
+ entry: one.entry,
293
+ ledgered: one.ledgered,
294
+ },
295
+ ]
296
+ : []));
297
+ const renderText = (report) => {
298
+ const reportable = report.violations.filter((one) => one.kind !== "campaign" && !one.baselined);
299
+ const carried = report.violations.filter((one) => one.kind !== "campaign").length - reportable.length;
300
+ const shortfalls = shortfallsOf(report.coverage);
301
+ const excesses = excessesOf(report.conformance);
302
+ return [
303
+ ...reportable.map(describe),
304
+ ...report.unresolved.map((one) => ` unresolved: ${one.file} → ${one.specifier} (${one.detail})`),
109
305
  "",
110
- `${String(findings.files)} files, ${String(reportable.length)} violations` +
306
+ `${String(report.files)} files, ${String(reportable.length)} violations` +
111
307
  (carried > 0 ? `, ${String(carried)} carried by the baseline` : ""),
112
- ]);
113
- if (stale.length > 0) {
114
308
  // The ratchet: a fixed violation must leave the baseline, or the floor
115
309
  // 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
- }
310
+ ...(report.stale.length === 0
311
+ ? []
312
+ : [
313
+ "",
314
+ `${String(report.stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
315
+ ...report.stale.map((entry) => ` ${entry}`),
316
+ "",
317
+ " architecture baseline # rewrites the file from what still fires",
318
+ ]),
319
+ ...(shortfalls.length === 0
320
+ ? []
321
+ : [
322
+ "",
323
+ "coverage is below the floor the policy states for itself:",
324
+ ...shortfalls.map((one) => ` ${one.family}: ${percent(one.actual)} covered, floor ${percent(one.floor)}`),
325
+ "",
326
+ " architecture coverage # which files no rule reaches",
327
+ ]),
328
+ ...(excesses.length === 0
329
+ ? []
330
+ : [
331
+ "",
332
+ "conformance is above the ceiling the policy states for itself:",
333
+ ...excesses.map(describeExcess),
334
+ "",
335
+ " architecture conformance # which files, nodes and allowances",
336
+ ]),
337
+ ...renderCampaigns(report),
338
+ ];
339
+ };
340
+ export const check = (policy, roots, options) => Effect.gen(function* () {
341
+ const report_ = checkReport(policy, roots, options.manifestPath);
342
+ // JSON is one object on stdout and nothing else there; the failure, when
343
+ // there is one, is a sentence on stderr and the exit code, as in text.
344
+ yield* report(options.format === "json" ? [JSON.stringify(report_, null, 2)] : renderText(report_));
345
+ const failure = failureOf(report_, shortfallsOf(report_.coverage));
346
+ if (failure !== null)
347
+ return yield* Effect.fail(failure);
144
348
  });
145
349
  const percent = (fraction) => `${String(Math.floor(fraction * 100))}%`;
350
+ const count = (n, noun, plural = `${noun}s`) => `${String(n)} ${n === 1 ? noun : plural}`;
351
+ // The conformance snapshot: `check`'s report grown with what no family
352
+ // reaches, what the allowlists permit and nothing uses, the cycle count and
353
+ // the size of the debt — the whole distance between the tree and the
354
+ // manifest, as one document another run can be compared against. Its shape
355
+ // is the core's `Snapshot`, and the schema published beside the manifest's.
356
+ export const snapshotOf = (policy, roots, manifestPath) => {
357
+ const findings = collectFindings(policy, roots, { graph: true });
358
+ const { files, measures, report: report_ } = reportOf(policy, roots, manifestPath, findings);
359
+ const graph = findings.graph ?? { files, edges: new Map() };
360
+ const heights = heightOf(graph);
361
+ // Leaf edges first. A violation names a target when it is about an edge;
362
+ // the cost of fixing it is roughly how much of the graph stands beneath
363
+ // that target, so the ones nearest the ground come first and a reader
364
+ // starting at the top of the list is starting where a fix stays local.
365
+ // Ties keep the fingerprint order, so the list is the same on every run.
366
+ const heightOfViolation = (one) => heights.get(one.subject ?? "") ?? heights.get(one.file) ?? 0;
367
+ const violations = [...report_.violations].sort((left, right) => {
368
+ const byHeight = heightOfViolation(left) - heightOfViolation(right);
369
+ return byHeight !== 0 ? byHeight : left.fingerprint.localeCompare(right.fingerprint);
370
+ });
371
+ const campaigns = snapshotCampaignsOf(policy, findings.campaigns);
372
+ return {
373
+ version: SNAPSHOT_VERSION,
374
+ manifest: report_.manifest,
375
+ roots: report_.roots,
376
+ files: report_.files,
377
+ ok: report_.ok,
378
+ coverage: report_.coverage,
379
+ conformance: report_.conformance,
380
+ residue: measures.residue,
381
+ vacant: measures.vacant,
382
+ violations,
383
+ unresolved: report_.unresolved,
384
+ stale: report_.stale,
385
+ baseline: { size: readBaseline(policy).entries.length },
386
+ cycles: cyclesIn(graph).length,
387
+ slack: measures.slack,
388
+ concentration: measures.concentration,
389
+ adoption: report_.adoption,
390
+ campaigns,
391
+ };
392
+ };
393
+ const renderSnapshot = (snapshot) => {
394
+ const reportable = snapshot.violations.filter((one) => !one.baselined && !one.ledgered);
395
+ const carried = snapshot.violations.length - reportable.length;
396
+ const row = (family) => {
397
+ const { covered, floor, total } = snapshot.coverage[family];
398
+ const fraction = total === 0 ? 1 : covered / total;
399
+ const mark = floor === undefined
400
+ ? ""
401
+ : fraction >= floor
402
+ ? ` ≥ ${percent(floor)} ✓`
403
+ : ` < ${percent(floor)} ✗`;
404
+ return ` ${family.padEnd(10)} ${String(covered).padStart(5)}/${String(total)} ${percent(fraction).padStart(4)}${mark}`;
405
+ };
406
+ const section = (title, lines) => [
407
+ "",
408
+ title,
409
+ ...lines,
410
+ ];
411
+ const vacantWidth = Math.max(0, ...snapshot.vacant.map((one) => one.node.length));
412
+ // The document carries every partly-used fragment entry; the text shows
413
+ // the ones concentrated enough to read as a per-file rule written wide.
414
+ const concentrated = snapshot.concentration.filter(isConcentrated);
415
+ // The ceiling beside each measure that has one, and the ratchet's nudge
416
+ // when the count has fallen under it: a ceiling is lowered by hand.
417
+ const ceilingMark = (measure) => {
418
+ const { ceiling, count: actual } = snapshot.conformance[measure];
419
+ if (ceiling === undefined)
420
+ return "";
421
+ if (actual > ceiling)
422
+ return ` > ${String(ceiling)} ✗`;
423
+ return ` ≤ ${String(ceiling)} ✓${actual < ceiling ? `, lower it to ${String(actual)}` : ""}`;
424
+ };
425
+ return [
426
+ `${String(snapshot.files)} files under ${snapshot.roots.join(", ")}, against ${snapshot.manifest.path}`,
427
+ ...section("coverage", COVERAGE_FAMILIES.map(row)),
428
+ ...section(`residue: ${count(snapshot.residue.files.length, "file")} no family reaches` +
429
+ (snapshot.residue.folders.length === 0
430
+ ? ""
431
+ : `, ${count(snapshot.residue.folders.length, "folder")} wholly`) +
432
+ ceilingMark("residue"), [
433
+ ...snapshot.residue.folders.map((folder) => ` ${folder}/`),
434
+ ...snapshot.residue.files
435
+ .filter((file) => !snapshot.residue.folders.some((folder) => file.startsWith(`${folder}/`)))
436
+ .map((file) => ` ${file}`),
437
+ ]),
438
+ ...section(`vacant: ${count(snapshot.vacant.length, "node")} ${snapshot.vacant.length === 1 ? "selects" : "select"} no file` +
439
+ ceilingMark("vacant"), snapshot.vacant.map((one) => ` ${one.node.padEnd(vacantWidth)} ${count(one.allowances, "allowance")}`)),
440
+ ...section(`violations: ${count(reportable.length, "reportable")}` +
441
+ (carried > 0 ? `, ${String(carried)} carried by the baseline or a ledger` : "") +
442
+ (snapshot.stale.length > 0
443
+ ? `, ${count(snapshot.stale.length, "stale entry", "stale entries")}`
444
+ : "") +
445
+ (reportable.length > 0 ? " — nearest the ground first" : ""), reportable.map(describe)),
446
+ ...(snapshot.unresolved.length === 0
447
+ ? []
448
+ : section(`unresolved: ${count(snapshot.unresolved.length, "import")} no rule can police`, snapshot.unresolved.map((one) => ` ${one.file} → ${one.specifier} (${one.detail})`))),
449
+ ...section(`slack: ${count(snapshot.slack.length, "allowance")} nothing imports through` +
450
+ ceilingMark("slack"), snapshot.slack.map((one) => ` ${one.node}: ${one.kind} ${JSON.stringify(one.entry)}` +
451
+ (one.of === undefined ? "" : ` (via use, at ${count(one.of, "node")})`))),
452
+ ...(concentrated.length === 0 && snapshot.conformance.concentration.ceiling === undefined
453
+ ? []
454
+ : section(`concentrated: ${count(concentrated.length, "allowance")} used at fewer than half the nodes granted` +
455
+ ceilingMark("concentration"), concentrated.map((one) => ` ${one.fragment}: ${one.kind} ${JSON.stringify(one.entry)} used at ${String(one.usedAt)} of ${count(one.of, "node")}`))),
456
+ ...(snapshot.campaigns.length === 0
457
+ ? []
458
+ : section(`campaigns: ${count(snapshot.campaigns.length, "campaign")}` +
459
+ (snapshot.campaigns.some((one) => one.stalled)
460
+ ? `, ${count(snapshot.campaigns.filter((one) => one.stalled).length, "stalled", "stalled")}`
461
+ : "") +
462
+ (snapshot.campaigns.some((one) => one.complete && one.ledgered)
463
+ ? `, ${count(snapshot.campaigns.filter((one) => one.complete && one.ledgered).length, "complete", "complete")}`
464
+ : ""), renderCampaignRows(snapshot.campaigns))),
465
+ "",
466
+ `cycles: ${String(snapshot.cycles)}`,
467
+ `baseline: ${count(snapshot.baseline.size, "entry", "entries")}`,
468
+ `adoption: ${count(snapshot.adoption.unrestricted.length, "unrestricted tier")}, ${count(snapshot.adoption.partial.length, "partial tier")}`,
469
+ ];
470
+ };
471
+ // The report of the tree against the manifest. Unlike `check`, it never
472
+ // fails: it is a measurement, and the manifest it measures against may be one
473
+ // the tree was never expected to satisfy yet — `--against` names a target.
474
+ // `ok` in the document says what `check` would have done.
475
+ export const conformance = (policy, roots, options) => Effect.gen(function* () {
476
+ const snapshot = snapshotOf(policy, roots, options.manifestPath);
477
+ yield* report(options.format === "json" ? [JSON.stringify(snapshot, null, 2)] : renderSnapshot(snapshot));
478
+ });
146
479
  // How much of the tree the policy reaches. A probe proves a rule can fire;
147
480
  // this is whether the files are there to fire on. Reported per family, with the
148
481
  // adoption backlog — the tiers that said "not tightened yet" — beneath it.
@@ -190,7 +523,7 @@ export const writeBaseline = (policy, roots) => Effect.gen(function* () {
190
523
  });
191
524
  // The question a tree config makes harder to answer than a flat one: given a
192
525
  // file, what governs it? A flat config you grep; a tree you have to walk.
193
- export const explain = (policy, file) => Effect.gen(function* () {
526
+ export const explain = (policy, file, roots = ["packages"]) => Effect.gen(function* () {
194
527
  const relative = path.relative(policy.repoRoot, path.resolve(policy.repoRoot, file));
195
528
  const selected = rulesSelecting(policy.importRules, relative);
196
529
  // An allowlist rule names no `to` — it fires when the target matches none of
@@ -204,7 +537,7 @@ export const explain = (policy, file) => Effect.gen(function* () {
204
537
  const governing = policy.structure.folders.filter((rule) => rule.folder.some((pattern) => pattern.test(path.dirname(relative))));
205
538
  const naming = policy.structure.naming.filter((rule) => rule.file.some((pattern) => pattern.test(relative)) &&
206
539
  !rule.fileNot.some((pattern) => pattern.test(relative)));
207
- const firstSentence = (message) => `${message.split(". ")[0] ?? message}.`;
540
+ const firstSentence = (message) => `${(message.split(". ")[0] ?? message).replace(/\.$/, "")}.`;
208
541
  const named = (rule) => ` ${rule.name} — ${firstSentence(rule.message)}`;
209
542
  // The families beyond imports and structure: which rules of each speak to
210
543
  // this file at all. What they are evaluated against is `facts`' answer.
@@ -222,6 +555,43 @@ export const explain = (policy, file) => Effect.gen(function* () {
222
555
  .map((rule) => `${named(rule)} (reach)`),
223
556
  ];
224
557
  const section = (title, lines) => lines.length === 0 ? [] : ["", title, ...lines];
558
+ // Each campaign selecting the file: the sector the file is in, its
559
+ // phase and definedness, the objectives in window that fire on it and
560
+ // the nearest remaining holdouts — which needs the campaign evaluated
561
+ // over its files, since a sector's phase is derived from all of them —
562
+ // then each objective's truth table: one line per leaf term and what
563
+ // it answered here, so a detector that "should fire" and does not shows
564
+ // which term is not saying what its author thinks.
565
+ const selectedCampaigns = campaignsSelecting(campaignsOf(policy).campaignRules, relative);
566
+ const evaluations = selectedCampaigns.length === 0 ? [] : collectFindings(policy, roots).campaigns;
567
+ const campaignLines = selectedCampaigns.flatMap((rule) => {
568
+ const at = path.join(policy.repoRoot, relative);
569
+ const text = existsSync(at) ? readFileSync(at, "utf8") : "";
570
+ const input = {
571
+ file: relative,
572
+ text,
573
+ facts: policy.extractor.factsOf(relative, text),
574
+ resolver: policy.resolver,
575
+ fileSystem: policy.fileSystem,
576
+ syntax: policy.syntax.parse(relative, text),
577
+ functions: campaignsOf(policy).functions,
578
+ reports: campaignsOf(policy).reports,
579
+ };
580
+ const evaluation = evaluations.find((one) => one.rule.id === rule.id);
581
+ return [
582
+ ...(evaluation === undefined ? [] : explainCampaignLines(policy, evaluation, relative)),
583
+ ...rule.objectives.flatMap((objective) => {
584
+ const table = explainObjective(objective, input);
585
+ if (table.length === 0)
586
+ return [];
587
+ const fired = table.length > 0 && table.every((line) => line.answer);
588
+ return [
589
+ ` ${objective.name} — ${firstSentence(objective.why ?? objective.message)} (${objective.holdout}; ${fired ? "fires here" : "no hit"})`,
590
+ ...table.map((line) => ` ${line.answer ? "✓" : "✗"} ${line.term}${line.count === undefined ? "" : ` (${String(line.count)})`}`),
591
+ ];
592
+ }),
593
+ ];
594
+ });
225
595
  yield* report([
226
596
  relative,
227
597
  "",
@@ -251,6 +621,7 @@ export const explain = (policy, file) => Effect.gen(function* () {
251
621
  ...section(" vocabulary (members):", vocabulary.map(named)),
252
622
  ...section(" may export (surface):", surface.map(named)),
253
623
  ...section(" graph:", graph),
624
+ ...section(" campaigns:", campaignLines),
254
625
  ]);
255
626
  });
256
627
  // The other half of `explain`. `explain` says which rules select a file; this
@@ -299,38 +670,580 @@ export const facts = (policy, file, format = "text") => Effect.gen(function* ()
299
670
  ...read.exportSites.map((site) => ` ${site.kind.padEnd(9)} ${site.name} (${site.reexport ? "re-export" : site.declares})`),
300
671
  ]);
301
672
  });
302
- export const run = (repoRoot, argv) => Effect.gen(function* () {
673
+ const SCHEMA_HEADER = `# yaml-language-server: $schema=${MANIFEST_SCHEMA_ID}\n`;
674
+ // A first manifest: one open root that reaches itself, the ceilings at zero,
675
+ // and a comment per section naming the page that explains it. Tight enough to
676
+ // fire on the first external import — which is the moment the author learns
677
+ // where the allowlist is — and small enough to read in one sitting.
678
+ const STARTER_MANIFEST = `${SCHEMA_HEADER}#
679
+ # The architecture policy: one manifest of this repository.
680
+ # https://dataquail.github.io/goodbones/architecture-rules/manifest/
681
+ #
682
+ # A key ending in \`/\` is a folder; anything else is a file. The default is
683
+ # tight: a folder admits only the children it lists, and a file may import only
684
+ # what it or an ancestor allows. Laxity is opted into, by name, at the node that
685
+ # wants it. Quote every glob — \`*\` and \`@\` mean something else to YAML bare.
686
+
687
+ # How an import specifier becomes a file. Every pattern below is matched
688
+ # against a resolved path, so this is what makes the rest mean anything.
689
+ # https://dataquail.github.io/goodbones/architecture-rules/enforcement/resolution/
690
+ resolve:
691
+ scopes:
692
+ - files: ""
693
+ language: typescript
694
+ options: { tsconfig: tsconfig.json }
695
+ unresolved: error
696
+
697
+ # Violations this repository is carrying while it adopts the policy, keyed by
698
+ # fingerprint. Written by \`architecture baseline\`; the floor only rises.
699
+ # https://dataquail.github.io/goodbones/architecture-rules/enforcement/baseline/
700
+ baseline: .architecture-baseline.json
701
+
702
+ # Ceilings on how many tiers may say "not tightened yet". At zero, raising one
703
+ # is a line in this file a reviewer sees. The same block takes coverage floors
704
+ # and ceilings on what \`architecture conformance\` measures, once there are
705
+ # numbers to write.
706
+ # https://dataquail.github.io/goodbones/architecture-rules/enforcement/adoption/
707
+ limits:
708
+ unrestricted: 0
709
+ partial: 0
710
+
711
+ # Refactors the repository is running, each an object: objectives (a
712
+ # detector with a ledger under \`.architecture-campaigns/\` of every place the
713
+ # pattern still occurs), over sectors the code births through a perimeter,
714
+ # through phases toward an end. Fill one in, then
715
+ # \`architecture objectives clear <id>\` to write its ledgers.
716
+ # https://dataquail.github.io/goodbones/architecture-rules/manifest/campaigns/
717
+ # campaigns:
718
+ # js-to-ts:
719
+ # why: The strict tsconfig cannot land while any src file is JavaScript.
720
+ # how: Rename to .ts, add types at the module boundary, leave the body alone.
721
+ # scope: { path: "src/**", extensions: [.js, .jsx] }
722
+ # perimeter: file
723
+ # objectives:
724
+ # is-ts:
725
+ # holdout: file
726
+ # match: { path: { file: "\\\\.(js|jsx)$" } }
727
+ # probes: { fires: [{ path: src/legacy/util.js }], ignores: [{ path: src/util.ts }] }
728
+ # staleAfter: 14d
729
+ # onComplete: remove
730
+
731
+ # The repository. One open root, reaching itself and the runtime; run
732
+ # \`architecture check\` to see what else it reaches, and write that down here.
733
+ # https://dataquail.github.io/goodbones/architecture-rules/manifest/imports/
734
+ tree:
735
+ "src/":
736
+ message: "src/ is the whole program. Nothing in it is layered yet."
737
+ layout: open
738
+ imports:
739
+ message: "This import is not on the allowlist."
740
+ allow: ["src/**", "node:**"]
741
+ # npm packages this tier may reach, by name.
742
+ external: []
743
+ children: {}
744
+ `;
745
+ // A starter manifest, for a repository that has none.
746
+ export const init = (repoRoot) => Effect.gen(function* () {
747
+ const present = MANIFEST_FILENAMES.filter((name) => existsSync(path.resolve(repoRoot, name)));
748
+ if (present.length > 0) {
749
+ return yield* Effect.fail(fail(`${present.join(", ")} already exists. \`init\` writes a starter manifest for a ` +
750
+ `repository that has none, and does not overwrite one.`));
751
+ }
752
+ yield* Effect.sync(() => {
753
+ writeFileSync(path.resolve(repoRoot, "architecture.yaml"), STARTER_MANIFEST);
754
+ });
755
+ yield* report([
756
+ "wrote architecture.yaml.",
757
+ "",
758
+ " architecture check # what src/ reaches today; add it to the allowlist by name",
759
+ " architecture coverage # how much of the tree the policy reaches",
760
+ ]);
761
+ });
762
+ // The same manifest as a data file. Nothing is hoisted into `defs` — which
763
+ // subtrees are worth naming is the author's call — and comments do not
764
+ // survive, since no tool carries them across; the report says so.
765
+ export const migrate = (repoRoot, configFilename) => Effect.gen(function* () {
766
+ const from = yield* Effect.try({
767
+ try: () => configFilename === undefined
768
+ ? findManifestFile(repoRoot)
769
+ : path.resolve(repoRoot, configFilename),
770
+ catch: (cause) => fail(String(cause)),
771
+ });
772
+ if (![".mjs", ".js", ".cjs"].includes(path.extname(from))) {
773
+ return yield* Effect.fail(fail(`${path.basename(from)} is already a data file. \`migrate\` reads a JavaScript ` +
774
+ `manifest and writes the same policy as architecture.yaml.`));
775
+ }
776
+ const to = path.resolve(repoRoot, "architecture.yaml");
777
+ if (existsSync(to)) {
778
+ return yield* Effect.fail(fail("architecture.yaml already exists; `migrate` does not overwrite it."));
779
+ }
780
+ const read = yield* Effect.tryPromise({
781
+ try: () => readManifestFile(from),
782
+ catch: (cause) => fail(String(cause)),
783
+ });
784
+ // Written only if it decodes: a manifest that does not load as a module
785
+ // is not going to load as YAML either, and the error names why.
786
+ const decoded = decodeManifest(from, read.manifest);
787
+ if (Result.isFailure(decoded))
788
+ return yield* Effect.fail(fail(decoded.failure.message));
789
+ yield* Effect.sync(() => {
790
+ writeFileSync(to, `${SCHEMA_HEADER}\n${formatManifestYaml(read.manifest)}`);
791
+ });
792
+ yield* report([
793
+ `wrote architecture.yaml from ${path.basename(from)}.`,
794
+ "",
795
+ "Comments were not carried over; port the ones worth keeping by hand.",
796
+ `Then delete ${path.basename(from)}: a repository with two manifests is refused.`,
797
+ ]);
798
+ });
799
+ // The campaign commands. `campaigns` alone is the status table; `status
800
+ // --changed` is the nudge; `attest` and `note` write a sector's record;
801
+ // `history` replays the ledgers' git history. The ledgers themselves are
802
+ // written by `objectives clear` (the ledger reconciled with the code
803
+ // wherever that is not a regression) and `objectives concede` (the one way
804
+ // a holdout is added by hand, with a reason).
805
+ const flagOf = (argv, flag) => {
806
+ const at = argv.indexOf(flag);
807
+ const value = at === -1 ? undefined : argv[at + 1];
808
+ return value === undefined || value.startsWith("--") ? undefined : value;
809
+ };
810
+ const CAMPAIGN_SUBCOMMANDS = ["status", "attest", "note", "history", "clear", "concede"];
811
+ const OBJECTIVE_SUBCOMMANDS = ["clear", "concede"];
812
+ const VALUE_FLAGS = [
813
+ "--reason",
814
+ "--by",
815
+ "--holdouts",
816
+ "--entries",
817
+ "--sector",
818
+ "--campaign",
819
+ "--evidence",
820
+ "--base",
821
+ "--since",
822
+ "--hotfix",
823
+ ];
824
+ // The verbs the family shipped with, refused by name: each has a new name
825
+ // or no place.
826
+ const RETIRED = {
827
+ init: "`campaigns init` is gone: `objectives clear <campaign>` writes a first ledger, recording each sector's initial.",
828
+ prune: "`campaigns prune` is now `objectives clear`.",
829
+ allow: "`campaigns allow` is now `objectives concede`.",
830
+ };
831
+ // The positionals after the subcommand, with the value flags and their
832
+ // values stepped over: whatever is left names the roots to walk, as it
833
+ // does for every other command.
834
+ const positionalsOf = (argv) => {
835
+ const positional = [];
836
+ for (let at = 0; at < argv.length; at += 1) {
837
+ const one = argv[at] ?? "";
838
+ if (VALUE_FLAGS.includes(one)) {
839
+ at += 1;
840
+ continue;
841
+ }
842
+ if (!one.startsWith("--"))
843
+ positional.push(one);
844
+ }
845
+ return positional;
846
+ };
847
+ // `campaigns [status | attest <sector> <phase> | note <sector> "<text>" |
848
+ // history [<campaign>]] [roots…]`: the subcommand and its arguments come
849
+ // first.
850
+ export const campaignArgsOf = (argv, ids) => {
851
+ const positional = positionalsOf(argv);
852
+ const [first, ...rest] = positional;
853
+ if (first === undefined)
854
+ return { subcommand: undefined, args: [], roots: [] };
855
+ if (first in RETIRED)
856
+ return { subcommand: first, args: [], roots: rest };
857
+ if (!CAMPAIGN_SUBCOMMANDS.includes(first)) {
858
+ return { subcommand: undefined, args: [], roots: positional };
859
+ }
860
+ switch (first) {
861
+ case "attest":
862
+ return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
863
+ case "note":
864
+ return { subcommand: first, args: rest.slice(0, 2), roots: rest.slice(2) };
865
+ case "history": {
866
+ const [second] = rest;
867
+ return second !== undefined && ids.includes(second)
868
+ ? { subcommand: first, args: [second], roots: rest.slice(1) }
869
+ : { subcommand: first, args: [], roots: rest };
870
+ }
871
+ case "clear":
872
+ case "concede": {
873
+ const [second] = rest;
874
+ return second !== undefined && ids.includes(second.split("/")[0] ?? "")
875
+ ? { subcommand: first, args: [second], roots: rest.slice(1) }
876
+ : { subcommand: first, args: [], roots: rest };
877
+ }
878
+ default:
879
+ return { subcommand: first, args: [], roots: rest };
880
+ }
881
+ };
882
+ // `objectives [clear [<campaign>[/<objective>]] | concede <campaign>[/<objective>]
883
+ // --reason <text>] [roots…]`.
884
+ export const objectiveArgsOf = (argv, ids) => {
885
+ const positional = positionalsOf(argv);
886
+ const [first, second, ...rest] = positional;
887
+ if (first === undefined || !OBJECTIVE_SUBCOMMANDS.includes(first)) {
888
+ return { subcommand: first, target: null, roots: positional.slice(1) };
889
+ }
890
+ const [campaign = "", objective] = (second ?? "").split("/");
891
+ const named = second !== undefined && ids.includes(campaign);
892
+ return {
893
+ subcommand: first,
894
+ target: named ? { campaign, objective: objective ?? null } : null,
895
+ roots: named ? rest : second === undefined ? [] : [second, ...rest],
896
+ };
897
+ };
898
+ // The one campaign, when there is one, else the one `--campaign` names.
899
+ const campaignFor = (policy, argv) => {
900
+ const named = flagOf(argv, "--campaign");
901
+ if (named !== undefined) {
902
+ const found = campaignsOf(policy).campaignRules.find((rule) => rule.id === named);
903
+ return found === undefined
904
+ ? Result.fail(`no campaign is named "${named}"`)
905
+ : Result.succeed(found);
906
+ }
907
+ const [only] = campaignsOf(policy).campaignRules;
908
+ if (campaignsOf(policy).campaignRules.length === 1 && only !== undefined)
909
+ return Result.succeed(only);
910
+ return Result.fail(`this policy declares ${String(campaignsOf(policy).campaignRules.length)} campaigns; say which with --campaign <id>.`);
911
+ };
912
+ const objectiveFor = (rule, objective) => {
913
+ if (objective !== null) {
914
+ return rule.objectives.some((one) => one.id === objective)
915
+ ? Result.succeed(objective)
916
+ : Result.fail(`no objective of ${rule.id} is named "${objective}"`);
917
+ }
918
+ const [only] = rule.objectives;
919
+ if (rule.objectives.length === 1 && only !== undefined)
920
+ return Result.succeed(only.id);
921
+ return Result.fail(`campaign ${rule.id} declares ${String(rule.objectives.length)} objectives; say which as ${rule.id}/<objective>.`);
922
+ };
923
+ export const objectives = (policy, defaultRoots, argv) => Effect.gen(function* () {
924
+ const parsed = objectiveArgsOf(argv, campaignsOf(policy).campaignRules.map((rule) => rule.id));
925
+ const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
926
+ if (campaignsOf(policy).campaignRules.length === 0) {
927
+ return yield* report(["this policy declares no campaigns."]);
928
+ }
929
+ const evaluations = () => collectFindings(policy, roots).campaigns;
930
+ switch (parsed.subcommand) {
931
+ case "clear": {
932
+ const targets = parsed.target === null
933
+ ? campaignsOf(policy).campaignRules
934
+ : campaignsOf(policy).campaignRules.filter((rule) => rule.id === parsed.target?.campaign);
935
+ const by = authorOf(flagOf(argv, "--by")) ?? "unknown";
936
+ const all = evaluations();
937
+ const lines = [];
938
+ for (const rule of targets) {
939
+ const evaluation = all.find((one) => one.rule.id === rule.id);
940
+ if (evaluation === undefined)
941
+ continue;
942
+ const only = parsed.target?.objective ?? null;
943
+ if (only !== null && !rule.objectives.some((one) => one.id === only)) {
944
+ return yield* Effect.fail(fail(`no objective of ${rule.id} is named "${only}"`));
945
+ }
946
+ const outcomes = yield* Effect.try({
947
+ try: () => clear(policy, evaluation, only, by),
948
+ catch: (cause) => fail(String(cause)),
949
+ });
950
+ for (const outcome of outcomes) {
951
+ const parts = [
952
+ ...(outcome.entered.length > 0
953
+ ? [
954
+ `${count(outcome.entered.length, "sector")} entered (${outcome.entered.join(", ")})`,
955
+ ]
956
+ : []),
957
+ ...(outcome.cleared > 0 ? [`${count(outcome.cleared, "holdout")} cleared`] : []),
958
+ ...(outcome.rewritten > 0
959
+ ? [`${count(outcome.rewritten, "holdout")} rewritten`]
960
+ : []),
961
+ ...(outcome.closed > 0 ? [`${count(outcome.closed, "holdout")} closed`] : []),
962
+ ...(outcome.rebaselined.length > 0
963
+ ? [
964
+ `${count(outcome.rebaselined.length, "sector")} re-baselined (${outcome.rebaselined.join(", ")})`,
965
+ ]
966
+ : []),
967
+ ];
968
+ lines.push(`${outcome.campaign}/${outcome.objective}: ${parts.length === 0 ? "nothing to clear" : parts.join(", ")}; ${count(outcome.left, "holdout")} left.`);
969
+ }
970
+ }
971
+ return yield* report(lines);
972
+ }
973
+ case "concede": {
974
+ if (parsed.target === null) {
975
+ return yield* Effect.fail(fail("objectives concede needs a campaign: `objectives concede <campaign>[/<objective>] --reason <text>`"));
976
+ }
977
+ const rule = campaignsOf(policy).campaignRules.find((one) => one.id === parsed.target?.campaign);
978
+ if (rule === undefined)
979
+ return yield* Effect.fail(fail(`no campaign is named "${parsed.target.campaign}"`));
980
+ const objective = objectiveFor(rule, parsed.target.objective);
981
+ if (Result.isFailure(objective))
982
+ return yield* Effect.fail(fail(objective.failure));
983
+ const reason = flagOf(argv, "--reason");
984
+ if (reason === undefined) {
985
+ return yield* Effect.fail(fail("objectives concede needs --reason <text>: growth is recorded with why, or not at all."));
986
+ }
987
+ const by = authorOf(flagOf(argv, "--by"));
988
+ if (by === null) {
989
+ return yield* Effect.fail(fail("objectives concede needs an author: pass --by <email>, or set git's user.email."));
990
+ }
991
+ const evaluation = evaluations().find((one) => one.rule.id === rule.id);
992
+ if (evaluation === undefined)
993
+ return yield* Effect.fail(fail(`campaign ${rule.id} was not evaluated`));
994
+ // `--holdouts` concedes a subset and refuses the rest: a pull
995
+ // request that legitimately adds one hit while another is an
996
+ // accident.
997
+ const chosen = (flagOf(argv, "--holdouts") ?? flagOf(argv, "--entries"))
998
+ ?.split(",")
999
+ .map((one) => one.trim()) ?? null;
1000
+ const outcome = concede(policy, evaluation, objective.success, chosen, flagOf(argv, "--sector") ?? null, {
1001
+ at: policy.now,
1002
+ by,
1003
+ reason,
1004
+ });
1005
+ if (Result.isFailure(outcome))
1006
+ return yield* Effect.fail(fail(outcome.failure));
1007
+ if (outcome.success.conceded.length === 0) {
1008
+ return yield* report([
1009
+ `${rule.id}/${objective.success}: nothing to concede; every hit is in the ledger.`,
1010
+ ]);
1011
+ }
1012
+ return yield* report([
1013
+ `${rule.id}/${objective.success}: ${count(outcome.success.conceded.length, "holdout")} conceded, recorded by ${by}.`,
1014
+ ...outcome.success.conceded.map((one) => ` ${one.sector} · ${one.entry}`),
1015
+ ...(outcome.success.left.length === 0
1016
+ ? []
1017
+ : [
1018
+ "",
1019
+ `${count(outcome.success.left.length, "hit")} left unrecorded; check still fails on them.`,
1020
+ ]),
1021
+ ]);
1022
+ }
1023
+ default:
1024
+ return yield* Effect.fail(fail(`unknown objectives subcommand "${parsed.subcommand ?? ""}". Try: objectives clear [<campaign>[/<objective>]] | objectives concede <campaign>[/<objective>] --reason <text> [--by <email>] [--sector <name>] [--holdouts a,b]`));
1025
+ }
1026
+ });
1027
+ export const campaigns = (policy, defaultRoots, argv, configFilename) => Effect.gen(function* () {
1028
+ const parsed = campaignArgsOf(argv, campaignsOf(policy).campaignRules.map((rule) => rule.id));
1029
+ const roots = parsed.roots.length > 0 ? parsed.roots : defaultRoots;
1030
+ if (campaignsOf(policy).campaignRules.length === 0) {
1031
+ return yield* report(["this policy declares no campaigns."]);
1032
+ }
1033
+ const json = argv.includes("--json");
1034
+ const retired = parsed.subcommand === undefined ? undefined : RETIRED[parsed.subcommand];
1035
+ if (retired !== undefined)
1036
+ return yield* Effect.fail(fail(retired));
1037
+ switch (parsed.subcommand) {
1038
+ case undefined: {
1039
+ const snapshot = snapshotOf(policy, roots, manifestPathOf(policy.repoRoot, configFilename));
1040
+ return yield* report([
1041
+ `${count(snapshot.campaigns.length, "campaign")} under ${roots.join(", ")}`,
1042
+ "",
1043
+ ...renderCampaignRows(snapshot.campaigns),
1044
+ "",
1045
+ " architecture campaigns status --changed [--base <ref>] [--json] # what a diff touches, and what to do",
1046
+ " architecture objectives clear [<campaign>[/<objective>]] # reconcile the ledgers with the code",
1047
+ ' architecture objectives concede <campaign>[/<objective>] --reason "<why>" # record why a count may rise',
1048
+ ' architecture campaigns attest <sector> <phase> --reason "<why>" [--evidence <url>]',
1049
+ ' architecture campaigns note <sector> "<text>"',
1050
+ " architecture campaigns history [<campaign>] [--since <ref>]",
1051
+ ]);
1052
+ }
1053
+ case "status": {
1054
+ if (!argv.includes("--changed")) {
1055
+ return yield* Effect.fail(fail("campaigns status takes --changed: the nudge is scoped to a diff."));
1056
+ }
1057
+ const base = flagOf(argv, "--base") ?? null;
1058
+ const diff = yield* Effect.try({
1059
+ try: () => readDiff(policy.repoRoot, base),
1060
+ catch: (cause) => fail(`could not read the diff: ${String(cause)}`),
1061
+ });
1062
+ const current = collectFindings(policy, roots).campaigns;
1063
+ const baseSide = base === null
1064
+ ? null
1065
+ : yield* Effect.tryPromise({
1066
+ try: () => baseSideAt(policy, base, roots, (repoRoot) => loadPolicyFromFile(repoRoot, configFilename)),
1067
+ catch: (cause) => fail(`could not evaluate the base tree at ${base}: ${String(cause)}`),
1068
+ });
1069
+ const hotfix = flagOf(argv, "--hotfix") ?? null;
1070
+ const nudge = nudgeOf(policy, current, diff, baseSide, hotfix, hotfix === null ? null : authorOf(flagOf(argv, "--by")));
1071
+ yield* report(json ? [JSON.stringify(nudge, null, 2)] : renderNudge(nudge, policy.now));
1072
+ if (!nudge.ok)
1073
+ return yield* Effect.fail(fail("the diff sends a sector back under its onTouch"));
1074
+ return;
1075
+ }
1076
+ case "attest": {
1077
+ const [sector, phase] = parsed.args;
1078
+ if (sector === undefined || phase === undefined) {
1079
+ return yield* Effect.fail(fail('campaigns attest needs a sector and a phase: `campaigns attest <sector> <phase> --reason "<why>"`'));
1080
+ }
1081
+ const reason = flagOf(argv, "--reason");
1082
+ if (reason === undefined)
1083
+ return yield* Effect.fail(fail("campaigns attest needs --reason <text>."));
1084
+ const by = authorOf(flagOf(argv, "--by"));
1085
+ if (by === null)
1086
+ return yield* Effect.fail(fail("campaigns attest needs an author: pass --by <email>, or set git's user.email."));
1087
+ const rule = campaignFor(policy, argv);
1088
+ if (Result.isFailure(rule))
1089
+ return yield* Effect.fail(fail(rule.failure));
1090
+ const evaluation = collectFindings(policy, roots).campaigns.find((one) => one.rule.id === rule.success.id);
1091
+ if (evaluation === undefined)
1092
+ return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
1093
+ const written = attest(policy, evaluation, sector, phase, {
1094
+ reason,
1095
+ evidence: flagOf(argv, "--evidence"),
1096
+ by,
1097
+ });
1098
+ if (Result.isFailure(written))
1099
+ return yield* Effect.fail(fail(written.failure));
1100
+ return yield* report([
1101
+ `${rule.success.id}: sector ${sector} attested at ${phase} by ${by}, in ${written.success}.`,
1102
+ "Run `objectives clear` to move it on.",
1103
+ ]);
1104
+ }
1105
+ case "note": {
1106
+ const [sector, text] = parsed.args;
1107
+ if (sector === undefined || text === undefined) {
1108
+ return yield* Effect.fail(fail('campaigns note needs a sector and a text: `campaigns note <sector> "<text>"`'));
1109
+ }
1110
+ const by = authorOf(flagOf(argv, "--by"));
1111
+ if (by === null)
1112
+ return yield* Effect.fail(fail("campaigns note needs an author: pass --by <email>, or set git's user.email."));
1113
+ const rule = campaignFor(policy, argv);
1114
+ if (Result.isFailure(rule))
1115
+ return yield* Effect.fail(fail(rule.failure));
1116
+ const evaluation = collectFindings(policy, roots).campaigns.find((one) => one.rule.id === rule.success.id);
1117
+ if (evaluation === undefined)
1118
+ return yield* Effect.fail(fail(`campaign ${rule.success.id} was not evaluated`));
1119
+ const written = note(policy, evaluation, sector, text, by);
1120
+ if (Result.isFailure(written))
1121
+ return yield* Effect.fail(fail(written.failure));
1122
+ return yield* report([
1123
+ `${rule.success.id}: note left on ${sector}, in ${written.success}.`,
1124
+ ]);
1125
+ }
1126
+ case "history": {
1127
+ const [named] = parsed.args;
1128
+ const targets = named === undefined
1129
+ ? campaignsOf(policy).campaignRules
1130
+ : campaignsOf(policy).campaignRules.filter((rule) => rule.id === named);
1131
+ const manifestPath = path
1132
+ .relative(policy.repoRoot, manifestPathOf(policy.repoRoot, configFilename))
1133
+ .replaceAll(path.sep, "/");
1134
+ const lines = [];
1135
+ for (const rule of targets) {
1136
+ const rows = historyOf(policy, rule, flagOf(argv, "--since") ?? null, [manifestPath]);
1137
+ if (json) {
1138
+ lines.push(JSON.stringify({ campaign: rule.id, rows }, null, 2));
1139
+ continue;
1140
+ }
1141
+ if (lines.length > 0)
1142
+ lines.push("");
1143
+ for (const line of renderHistory(rule, rows))
1144
+ lines.push(line);
1145
+ }
1146
+ return yield* report(lines);
1147
+ }
1148
+ case "clear":
1149
+ case "concede":
1150
+ // The ledger verbs answer under `objectives`; accepted here too.
1151
+ return yield* objectives(policy, defaultRoots, argv);
1152
+ default:
1153
+ return yield* Effect.fail(fail(`unknown campaigns subcommand "${parsed.subcommand}". Try: campaigns | campaigns status --changed | campaigns attest <sector> <phase> --reason <text> | campaigns note <sector> "<text>" | campaigns history [<campaign>]`));
1154
+ }
1155
+ });
1156
+ // The commands that judge a campaign, and so ask its report source.
1157
+ const READS_REPORTS = new Set([
1158
+ "check",
1159
+ "conformance",
1160
+ "baseline",
1161
+ "campaigns",
1162
+ "objectives",
1163
+ "explain",
1164
+ ]);
1165
+ export const run = (repoRoot, argv,
1166
+ // From ARCHITECTURE_CONFIG. Absent, the manifest is discovered by name.
1167
+ configFilename) => Effect.gen(function* () {
303
1168
  const [command = "check", ...rest] = argv;
1169
+ // The three commands that write a manifest rather than read one.
1170
+ if (command === "init")
1171
+ return yield* init(repoRoot);
1172
+ if (command === "migrate")
1173
+ return yield* migrate(repoRoot, configFilename);
1174
+ if (command === "infer") {
1175
+ yield* infer(repoRoot, rest, configFilename);
1176
+ return;
1177
+ }
1178
+ // `--against <file>` measures the tree against a manifest other than the
1179
+ // repository's own — the target the team is moving toward. Only
1180
+ // `conformance` takes it: a `check` against a manifest nobody is held to
1181
+ // yet would fail for no one's benefit.
1182
+ const againstAt = rest.indexOf("--against");
1183
+ const against = againstAt === -1 ? undefined : rest[againstAt + 1];
1184
+ if (againstAt !== -1 && (against === undefined || against.startsWith("--"))) {
1185
+ return yield* Effect.fail(fail("--against needs a manifest path"));
1186
+ }
1187
+ if (against !== undefined && command !== "conformance") {
1188
+ return yield* Effect.fail(fail(`--against is a \`conformance\` flag; ${command} does not take it`));
1189
+ }
1190
+ const manifestFilename = against ?? configFilename;
304
1191
  const policy = yield* Effect.tryPromise({
305
- try: () => loadPolicyFromFile(repoRoot),
1192
+ try: () => loadPolicyFromFile(repoRoot, manifestFilename),
306
1193
  catch: (cause) => fail(String(cause)),
307
1194
  });
308
1195
  yield* Effect.sync(() => {
309
1196
  for (const notice of policy.notices)
310
1197
  process.stderr.write(`deprecated: ${notice}\n`);
311
1198
  });
312
- const roots = rest.length > 0 ? rest : ["packages"];
1199
+ // Every `report` a campaign names is read now, before any file asks:
1200
+ // a term's several commands run at once rather than one after another
1201
+ // the first time a campaign selects a file. What cannot be read is kept
1202
+ // for the campaign to report; a report that does not parse is refused.
1203
+ if (READS_REPORTS.has(command)) {
1204
+ yield* Effect.tryPromise({
1205
+ try: () => Promise.all(reportSpecsOf(campaignsOf(policy).campaignRules).map((spec) => campaignsOf(policy).reports.read?.(spec))),
1206
+ catch: (cause) => fail(String(cause)),
1207
+ });
1208
+ }
1209
+ const json = rest.includes("--json");
1210
+ const positional = rest.filter((argument, index) => argument !== "--json" &&
1211
+ argument !== "--against" &&
1212
+ (againstAt === -1 || index !== againstAt + 1));
1213
+ const roots = positional.length > 0 ? positional : ["packages"];
313
1214
  switch (command) {
1215
+ case "campaigns":
1216
+ return yield* campaigns(policy, ["packages"], rest, configFilename);
1217
+ case "objectives":
1218
+ return yield* objectives(policy, ["packages"], rest);
314
1219
  case "check":
315
- return yield* check(policy, roots);
1220
+ return yield* check(policy, roots, {
1221
+ format: json ? "json" : "text",
1222
+ manifestPath: manifestPathOf(repoRoot, configFilename),
1223
+ });
1224
+ case "conformance":
1225
+ return yield* conformance(policy, roots, {
1226
+ format: json ? "json" : "text",
1227
+ manifestPath: manifestPathOf(repoRoot, manifestFilename),
1228
+ });
316
1229
  case "baseline":
317
1230
  return yield* writeBaseline(policy, roots);
318
1231
  case "explain": {
319
- const [file] = rest;
1232
+ const [file, ...explainRoots] = positional;
320
1233
  if (file === undefined)
321
1234
  return yield* Effect.fail(fail("explain needs a file path"));
322
- return yield* explain(policy, file);
1235
+ return yield* explain(policy, file, explainRoots.length > 0 ? explainRoots : ["packages"]);
323
1236
  }
324
1237
  case "coverage":
325
1238
  return yield* coverage(policy, roots);
326
1239
  case "facts": {
327
- const [file] = rest.filter((argument) => argument !== "--json");
1240
+ const [file] = positional;
328
1241
  if (file === undefined)
329
1242
  return yield* Effect.fail(fail("facts needs a file path"));
330
- return yield* facts(policy, file, rest.includes("--json") ? "json" : "text");
1243
+ return yield* facts(policy, file, json ? "json" : "text");
331
1244
  }
332
1245
  default:
333
- return yield* Effect.fail(fail(`unknown command "${command}". Try: check | baseline | coverage | explain <file> | facts <file> [--json]`));
1246
+ 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`));
334
1247
  }
335
1248
  });
336
1249
  export const fingerprint = fingerprintOf;