archstrict 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.agents/hooks/hooks.json +29 -0
  2. package/.agents/hooks/post-tool-use.mjs +107 -0
  3. package/.agents/hooks/pre-tool-use.mjs +182 -0
  4. package/.agents/mcp/server.mjs +71 -0
  5. package/.agents/plugin.json +19 -0
  6. package/AGENTS.md +69 -0
  7. package/CHANGELOG.md +38 -0
  8. package/README.ja.md +62 -0
  9. package/README.md +63 -2
  10. package/dist/augmentation-cache.js +65 -0
  11. package/dist/check-options.js +40 -0
  12. package/dist/classify.js +148 -0
  13. package/dist/cli.js +239 -0
  14. package/dist/config-pointer.js +251 -0
  15. package/dist/config.js +186 -0
  16. package/dist/edge-cache.js +530 -0
  17. package/dist/mcp-server.js +111 -0
  18. package/dist/module-candidates.js +118 -0
  19. package/dist/module-graph.js +2072 -0
  20. package/dist/project-path.js +59 -0
  21. package/dist/report-error.js +13 -0
  22. package/dist/rules/config-meaning.js +143 -0
  23. package/dist/rules/constraints.js +417 -0
  24. package/dist/rules/cycles.js +257 -0
  25. package/dist/rules/deprecated.js +67 -0
  26. package/dist/rules/empty-rule.js +101 -0
  27. package/dist/rules/moves.js +79 -0
  28. package/dist/rules/must-be-empty.js +52 -0
  29. package/dist/rules/public-surface.js +100 -0
  30. package/dist/rules/type-leak.js +562 -0
  31. package/dist/rules/uncovered.js +75 -0
  32. package/dist/todo-migration.js +112 -0
  33. package/dist/todo-store.js +434 -0
  34. package/dist/type-closure.js +959 -0
  35. package/dist/verbs/agents.js +116 -0
  36. package/dist/verbs/check.js +957 -0
  37. package/dist/verbs/fix.js +170 -0
  38. package/dist/verbs/hotspots.js +261 -0
  39. package/dist/verbs/init.js +522 -0
  40. package/dist/verbs/recommend.js +800 -0
  41. package/dist/verbs/rules.js +188 -0
  42. package/dist/verbs/search.js +109 -0
  43. package/dist/verbs/simulate.js +220 -0
  44. package/dist/verbs/todo.js +163 -0
  45. package/dist/warm-graph.js +82 -0
  46. package/docs/boundary-patterns.md +374 -0
  47. package/docs/calibrated-rules-design.md +124 -0
  48. package/docs/init-singleton-modules.md +128 -0
  49. package/docs/maintenance.md +82 -0
  50. package/docs/releasing.md +55 -0
  51. package/docs/rules-edge-cache.md +50 -0
  52. package/docs/todo-single-file-migration.md +58 -0
  53. package/llms.txt +19 -0
  54. package/package.json +57 -4
  55. package/skills/archstrict/SKILL.md +42 -0
  56. package/skills/archstrict/references/agents-verb.md +39 -0
  57. package/skills/archstrict/references/config.md +107 -0
  58. package/skills/archstrict/references/hook.md +57 -0
  59. package/skills/archstrict/references/path-rules.md +57 -0
  60. package/skills/archstrict/references/patterns.md +883 -0
  61. package/skills/archstrict/references/prove-rules.md +58 -0
  62. package/skills/archstrict/references/rearchitect.md +35 -0
  63. package/skills/archstrict/references/recommend.md +80 -0
  64. package/skills/archstrict/references/rules.md +146 -0
  65. package/skills/archstrict/references/simulate.md +109 -0
@@ -0,0 +1,800 @@
1
+ // Responsibility: propose boundaries from the discovered import graph.
2
+ // Boundary: report data and text only; never write config or judge a module's purpose.
3
+ import { existsSync } from "node:fs";
4
+ import { relative, resolve, sep } from "node:path";
5
+ import { applyTodo, loadConfig, runRules } from "./check.js";
6
+ import { createConfigLocator } from "../config-pointer.js";
7
+ import { fingerprintOf, relativizeForTodo } from "../todo-store.js";
8
+ import { buildModuleGraphForRules, DEFAULT_SURFACE, moduleGlobBaseDir } from "../module-graph.js";
9
+ // Without a config, recommend previews init's own walk in memory (same
10
+ // argument rules, same groups and globs) instead of running its own
11
+ // single-level "src/*" discovery - the two could disagree about which
12
+ // files exist and how they group, and no user-facing command should take
13
+ // a modules glob once init itself no longer does.
14
+ import { freshRun, normalizeDirArg } from "./init.js";
15
+ // 4/5 (80%), the same threshold and the same rationale check.ts's own
16
+ // SURFACE_LESS_NOTE_THRESHOLD uses for the opposite direction (how many
17
+ // bypasses share this one root cause) - integer math so a real fraction
18
+ // (7 covered of 9) never rounds the wrong way against a float constant.
19
+ const SURFACE_COVERAGE_NUMERATOR = 4;
20
+ const SURFACE_COVERAGE_DENOMINATOR = 5;
21
+ // Pure and exported so a property test can drive it directly with
22
+ // synthetic importer counts, without building a real filesystem and
23
+ // compiler graph for every case. `counts` must already be sorted densest
24
+ // first (the same order `proposeSurfaces` ranks real candidates in) -
25
+ // this never sorts its own input, so a caller's tie-break choice (file
26
+ // path ascending) survives into which prefix wins a tie.
27
+ export function minimalCoveringPrefixLength(counts, numerator = SURFACE_COVERAGE_NUMERATOR, denominator = SURFACE_COVERAGE_DENOMINATOR) {
28
+ const total = counts.reduce((sum, count) => sum + count, 0);
29
+ if (total === 0)
30
+ return 0;
31
+ let covered = 0;
32
+ for (let i = 0; i < counts.length; i++) {
33
+ covered += counts[i];
34
+ if (covered * denominator >= total * numerator)
35
+ return i + 1;
36
+ }
37
+ return counts.length;
38
+ }
39
+ function moduleRelative(module, file) {
40
+ return relative(module.dir, file).split(sep).join("/");
41
+ }
42
+ // One proposal per declared module with no public surface file present
43
+ // today (module.surfaceFiles.length === 0) and at least one real external
44
+ // importer - a module nothing outside it ever imports has no evidence to
45
+ // rank a surface from, so it is left out rather than guessed.
46
+ // `rootIsFile` modules are skipped outright: a single-file module's own
47
+ // surface is that file, by construction (module-graph.ts), so it can
48
+ // never lack one here.
49
+ function proposeSurfaces(graph) {
50
+ const proposals = [];
51
+ for (const module of graph.modules.values()) {
52
+ if (module.surfaceFiles.length > 0 || module.rootIsFile)
53
+ continue;
54
+ // A pair is (importing file, imported file) - the unit both the
55
+ // ranking key and the coverage unit share, so a file's own importer
56
+ // count is exactly its own share of `totalImports`, and summing a
57
+ // prefix's counts is exactly that prefix's own coverage (see this
58
+ // module's own top-level comment on the property this keeps true).
59
+ const pairs = new Set();
60
+ const importersByFile = new Map();
61
+ for (const edge of graph.crossModuleEdges) {
62
+ if (edge.toModule !== module.name)
63
+ continue;
64
+ const pairKey = `${edge.fromFile}\0${edge.resolvedFile}`;
65
+ if (pairs.has(pairKey))
66
+ continue;
67
+ pairs.add(pairKey);
68
+ let importers = importersByFile.get(edge.resolvedFile);
69
+ if (importers === undefined) {
70
+ importers = new Set();
71
+ importersByFile.set(edge.resolvedFile, importers);
72
+ }
73
+ importers.add(edge.fromFile);
74
+ }
75
+ if (pairs.size === 0)
76
+ continue;
77
+ const candidates = [...importersByFile.entries()]
78
+ .map(([file, importers]) => ({ file: moduleRelative(module, file), importers: importers.size }))
79
+ .sort((a, b) => b.importers - a.importers || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
80
+ const prefixLength = minimalCoveringPrefixLength(candidates.map(c => c.importers));
81
+ const proposedSurface = candidates.slice(0, prefixLength).map(c => c.file);
82
+ const coveredImports = candidates.slice(0, prefixLength).reduce((sum, c) => sum + c.importers, 0);
83
+ const totalImports = pairs.size;
84
+ proposals.push({
85
+ module: module.name,
86
+ candidates,
87
+ proposedSurface,
88
+ totalImports,
89
+ coveredImports,
90
+ remainingImports: totalImports - coveredImports,
91
+ choices: [
92
+ `do: set { name: ${JSON.stringify(module.name)}, ..., surface: ${JSON.stringify(proposedSurface)} } in declaredModules to retire ${coveredImports} of ${totalImports} bypasses into '${module.name}', leaving ${totalImports - coveredImports}`,
93
+ `do: add a barrel file re-exporting from ${proposedSurface[0] ?? "a chosen entry file"}, then name it as this module's surface instead`,
94
+ `do: leave '${module.name}' entirely private and run archstrict todo to freeze its bypasses as debt instead`,
95
+ ],
96
+ });
97
+ }
98
+ return proposals.sort((a, b) => a.module < b.module ? -1 : a.module > b.module ? 1 : 0);
99
+ }
100
+ // Runs `proposedConfig` (the real config plus one candidate classify/edges
101
+ // addition) through the same rule pipeline `check` uses, and counts only
102
+ // NEW violations of the rule id this one proposal's own edges produce -
103
+ // never a violation some other, pre-existing rule already reported, and
104
+ // never a different rule this proposal happened to also touch. `baseConfig`
105
+ // is run first so a project that already has, say, an unrelated
106
+ // tag-boundary rule does not have its existing findings miscounted as
107
+ // this proposal's own. Rule 6 (type-leak) is skipped: it needs a
108
+ // compiler Program per module surface, a cost this in-memory preview
109
+ // pays once per proposal otherwise, for a rule no classify/edges
110
+ // addition here can ever affect.
111
+ function countAddedViolations(graph, baseConfig, proposedConfig, ruleId) {
112
+ const keyOf = (v) => fingerprintOf(relativizeForTodo(v, graph.relativePath));
113
+ const baseLocator = createConfigLocator(baseConfig);
114
+ const base = applyTodo(graph, baseConfig, runRules(graph, baseConfig, { configLocator: baseLocator, skipTypeLeak: true }), { configLocator: baseLocator });
115
+ const baseKeys = new Set(base.violations.filter(v => v.rule === ruleId).map(keyOf));
116
+ const proposedLocator = createConfigLocator(proposedConfig);
117
+ const proposed = applyTodo(graph, proposedConfig, runRules(graph, proposedConfig, { configLocator: proposedLocator, skipTypeLeak: true }), { configLocator: proposedLocator });
118
+ return proposed.violations.filter(v => v.rule === ruleId && !baseKeys.has(keyOf(v))).length;
119
+ }
120
+ const PROVE_RULES_DO = "run archstrict simulate --json with a change set that adds one edge this rule should forbid, and confirm it fires; see node_modules/archstrict/skills/archstrict/references/prove-rules.md";
121
+ // A classify block for two groups that between them cover every present
122
+ // module: one broad catch-all glob for the larger, default-tagged group,
123
+ // then each member of the smaller, distinguished group overriding it with
124
+ // its own real glob. classify.ts's own most-specific-glob-wins already
125
+ // picks a longer literal prefix over "**"'s empty one, so this tags every
126
+ // file exactly as writing every module out by hand would - just far
127
+ // fewer lines on a project where most modules land on the default side.
128
+ // Only valid when the two groups are a true partition (nothing left
129
+ // over): a file genuinely outside every declared module also matches
130
+ // "**" and would gain the default tag it never had before - harmless for
131
+ // every detector this is used by, since none of them scope a rule by
132
+ // module coverage, only by this one classify namespace.
133
+ function partitionClassify(declaredModules, defaultTag, distinguishedNames, distinguishedTag) {
134
+ return [
135
+ { glob: "**", tags: [defaultTag] },
136
+ ...distinguishedNames.map(name => ({ glob: moduleGlob(declaredModules, name), tags: [distinguishedTag] })),
137
+ ];
138
+ }
139
+ // A general layered-order detector: it does not name a project's own
140
+ // layer vocabulary (patterns.md's "app vs lib" and "layered order" are
141
+ // both this same shape, over module names instead of directory-name
142
+ // tiers) - it finds whichever direction the real edges between declared
143
+ // modules already agree on, the same reading patterns.md gives a
144
+ // lopsided cycle ("one direction is intended; remove the few reverse
145
+ // edges", not "this pair has no order").
146
+ function detectLayeredOrder(modules, counts, declaredModules, baseConfig, graph) {
147
+ const names = modules.map(m => m.name);
148
+ if (names.length < 2)
149
+ return undefined;
150
+ // `before.get(X)` is every module that imports X - X must precede them
151
+ // in `sequence` (order.ts's own downward-only direction lets a source
152
+ // depend on its own layer or an earlier one, never a later one).
153
+ const before = new Map(names.map(n => [n, new Set()]));
154
+ let forward = 0;
155
+ let reverse = 0;
156
+ for (let i = 0; i < names.length; i++) {
157
+ for (let j = i + 1; j < names.length; j++) {
158
+ const a = names[i];
159
+ const b = names[j];
160
+ const ab = counts.get(a)?.get(b) ?? 0; // a imports b
161
+ const ba = counts.get(b)?.get(a) ?? 0; // b imports a
162
+ if (ab === 0 && ba === 0)
163
+ continue;
164
+ if (ab === ba)
165
+ continue; // exactly balanced: no direction to read, so no constraint either way
166
+ const [importer, dependency, majority, minority] = ab > ba ? [a, b, ab, ba] : [b, a, ba, ab];
167
+ before.get(dependency).add(importer);
168
+ forward += majority;
169
+ reverse += minority;
170
+ }
171
+ }
172
+ if (forward === 0)
173
+ return undefined; // no directional evidence between any pair at all
174
+ // One constraint (dependency before importer) per edge `before` recorded above.
175
+ const indegree = new Map(names.map(n => [n, 0]));
176
+ for (const importers of before.values())
177
+ for (const importer of importers)
178
+ indegree.set(importer, indegree.get(importer) + 1);
179
+ const remaining = new Set(names);
180
+ const order = [];
181
+ while (remaining.size > 0) {
182
+ const ready = [...remaining].filter(n => (indegree.get(n) ?? 0) === 0).sort();
183
+ if (ready.length === 0)
184
+ return undefined; // the majority graph itself has a cycle: no order to propose
185
+ const next = ready[0];
186
+ order.push(next);
187
+ remaining.delete(next);
188
+ for (const dependent of before.get(next) ?? []) {
189
+ if (remaining.has(dependent))
190
+ indegree.set(dependent, indegree.get(dependent) - 1);
191
+ }
192
+ }
193
+ const support = forward / (forward + reverse);
194
+ const classify = order.map(name => ({ glob: declaredModules.find(d => d.name === name).glob, tags: [`role:${name}`] }));
195
+ const because = `${forward} of ${forward + reverse} directed edges between these modules already match this order`;
196
+ const orderRule = { tagNamespace: "role", sequence: { "": order }, direction: "downward-only", because };
197
+ const configFragment = [
198
+ "classify: [",
199
+ ...classify.map(c => ` { glob: ${JSON.stringify(c.glob)}, tags: ${JSON.stringify(c.tags)} },`),
200
+ "],",
201
+ "edges: { order: [",
202
+ ` { tagNamespace: "role", sequence: { "": ${JSON.stringify(order)} }, direction: "downward-only", because: ${JSON.stringify(because)} },`,
203
+ "] },",
204
+ ].join("\n");
205
+ const proposedConfig = {
206
+ ...baseConfig,
207
+ classify: [...(baseConfig.classify ?? []), ...classify],
208
+ edges: { ...baseConfig.edges, order: [...(baseConfig.edges?.order ?? []), orderRule] },
209
+ };
210
+ return {
211
+ proposal: {
212
+ pattern: "layered-order",
213
+ support,
214
+ evidence: [
215
+ `order supported by these modules' own real edges: ${order.join(" -> ")}`,
216
+ `${forward} of ${forward + reverse} directed edges between them match this order (the rest would need fixing, or a deliberate skip)`,
217
+ ],
218
+ configFragment,
219
+ addedViolations: countAddedViolations(graph, baseConfig, proposedConfig, "tag-order"),
220
+ do: PROVE_RULES_DO,
221
+ },
222
+ weight: forward + reverse,
223
+ };
224
+ }
225
+ // A module with real importers but zero outgoing cross-module edges of
226
+ // its own is patterns.md's "leaf / pure kernel": nothing it imports can
227
+ // ever violate this rule (there is nothing to violate it with yet), so
228
+ // support is always 1 - ranked among each other by how many real edges
229
+ // already depend on it, the strength of the reason to keep it that way.
230
+ function detectLeafKernels(modules, graph, declaredModules, baseConfig) {
231
+ const outgoing = new Map();
232
+ const incoming = new Map();
233
+ for (const edge of graph.crossModuleEdges) {
234
+ if (edge.toModule === undefined)
235
+ continue;
236
+ outgoing.set(edge.fromModule, (outgoing.get(edge.fromModule) ?? 0) + 1);
237
+ incoming.set(edge.toModule, (incoming.get(edge.toModule) ?? 0) + 1);
238
+ }
239
+ const proposals = [];
240
+ for (const module of modules) {
241
+ const inCount = incoming.get(module.name) ?? 0;
242
+ if (inCount === 0 || (outgoing.get(module.name) ?? 0) > 0)
243
+ continue;
244
+ const glob = declaredModules.find(d => d.name === module.name).glob;
245
+ const tag = `kind:${module.name}`;
246
+ const because = `'${module.name}' is imported by ${inCount} real edge(s) and imports no other declared module today`;
247
+ const allowDenyRule = { source: tag, targetNamespace: "kind", allow: [], because };
248
+ const proposedConfig = {
249
+ ...baseConfig,
250
+ classify: [...(baseConfig.classify ?? []), { glob, tags: [tag] }],
251
+ edges: { ...baseConfig.edges, allowDeny: [...(baseConfig.edges?.allowDeny ?? []), allowDenyRule] },
252
+ };
253
+ proposals.push({
254
+ proposal: {
255
+ pattern: "leaf-kernel",
256
+ support: 1,
257
+ evidence: [`'${module.name}': 0 outgoing edges to another declared module; ${inCount} other module(s) import it`],
258
+ configFragment: [
259
+ "classify: [", ` { glob: ${JSON.stringify(glob)}, tags: ${JSON.stringify([tag])} },`, "],",
260
+ "edges: { allowDeny: [", ` { source: ${JSON.stringify(tag)}, targetNamespace: "kind", allow: [], because: ${JSON.stringify(because)} },`, "] },",
261
+ ].join("\n"),
262
+ addedViolations: countAddedViolations(graph, baseConfig, proposedConfig, "tag-boundary"),
263
+ do: PROVE_RULES_DO,
264
+ },
265
+ weight: inCount,
266
+ });
267
+ }
268
+ return proposals;
269
+ }
270
+ // patterns.md's "public entry only": reuses the same evidence
271
+ // `proposeSurfaces` already computed (module-relative candidate files,
272
+ // ranked by real importer count) instead of re-deriving it, so the two
273
+ // can never disagree about which files a surface-less module's own
274
+ // importers actually reach. `addedViolations` is always 0 here, unlike
275
+ // the other two detectors: a `surface` narrows which import already
276
+ // counts as `public-surface-bypass` (rule 1, always on), it can only
277
+ // retire an existing finding, never create a new rule or a new kind of
278
+ // violation.
279
+ function detectPublicEntryOnly(surfaceProposals) {
280
+ const totalImports = surfaceProposals.reduce((sum, p) => sum + p.totalImports, 0);
281
+ if (totalImports === 0)
282
+ return undefined;
283
+ const coveredImports = surfaceProposals.reduce((sum, p) => sum + p.coveredImports, 0);
284
+ return {
285
+ proposal: {
286
+ pattern: "public-entry-only",
287
+ support: coveredImports / totalImports,
288
+ evidence: surfaceProposals.map(p => `'${p.module}': ${JSON.stringify(p.proposedSurface)} covers ${p.coveredImports} of ${p.totalImports} real imports into it`),
289
+ configFragment: [
290
+ "declaredModules: [",
291
+ ...surfaceProposals.map(p => ` { name: ${JSON.stringify(p.module)}, glob: /* this module's existing glob */ "...", surface: ${JSON.stringify(p.proposedSurface)} },`),
292
+ "],",
293
+ ].join("\n"),
294
+ addedViolations: 0,
295
+ do: "run archstrict check to see which public-surface-bypass violations naming each proposed surface retire, then archstrict todo to freeze what remains",
296
+ },
297
+ weight: totalImports,
298
+ };
299
+ }
300
+ // Every path segment before the glob's own wildcard, lowercased - the
301
+ // directory-name evidence a proposal reads (patterns.md's own "look at
302
+ // directory names first"), not the declared module's name, which a
303
+ // project can set to anything regardless of where the module lives.
304
+ function moduleSegments(glob) {
305
+ return moduleGlobBaseDir(glob).replace(/\.(ts|tsx|mts|cts)$/i, "").split("/").filter(Boolean).map(s => s.toLowerCase());
306
+ }
307
+ function matchesAnySegment(glob, pattern) {
308
+ return moduleSegments(glob).some(segment => pattern.test(segment));
309
+ }
310
+ function moduleGlob(declaredModules, name) {
311
+ return declaredModules.find(d => d.name === name).glob;
312
+ }
313
+ // Sums real cross-module edges from every member of `from` to every
314
+ // member of `to` - the shared unit every grouped detector below reads a
315
+ // directed edge count from, instead of each re-walking crossModuleEdges.
316
+ function edgesBetweenGroups(counts, from, to) {
317
+ let total = 0;
318
+ for (const a of from)
319
+ for (const b of to)
320
+ total += counts.get(a)?.get(b) ?? 0;
321
+ return total;
322
+ }
323
+ // Every tag namespace `baseConfig` already assigns, real or previewed -
324
+ // `order`'s own config check throws the first time a real edge carries a
325
+ // `layer` (or whichever namespace) value missing from that rule's
326
+ // `sequence`, so proposing a namespace a real config already populates
327
+ // would make `countAddedViolations` crash instead of report, not just
328
+ // read wrong. `allowDeny`/`point` have no such throw, but reusing a live
329
+ // namespace would still misread as extending a rule the project already
330
+ // wrote for a different reason - so every new detector below picks a
331
+ // namespace free of both classify's own tags and classifyByDirectoryName.
332
+ function usedTagNamespaces(config) {
333
+ const namespaces = new Set();
334
+ for (const entry of config.classify ?? [])
335
+ for (const tag of entry.tags)
336
+ namespaces.add(tag.split(":")[0] ?? tag);
337
+ if (config.classifyByDirectoryName)
338
+ namespaces.add(config.classifyByDirectoryName.tagNamespace);
339
+ return namespaces;
340
+ }
341
+ function freeTagNamespace(config, preferred) {
342
+ const used = usedTagNamespaces(config);
343
+ if (!used.has(preferred))
344
+ return preferred;
345
+ for (let i = 2;; i++)
346
+ if (!used.has(`${preferred}${i}`))
347
+ return `${preferred}${i}`;
348
+ }
349
+ // patterns.md's "app vs lib": an application area (a CLI entry file, or a
350
+ // directory segment named app/apps/cli/cmd anywhere in its glob) that
351
+ // depends on the rest of the project, with few or no edges back. Unlike
352
+ // `detectLayeredOrder` (which reads whichever direction the evidence
353
+ // between EVERY pair of modules agrees on), this looks for one specific,
354
+ // named direction - the shape a real adoption picked by hand over the
355
+ // general detector, because "app" and "library" are recognizable on
356
+ // sight in a way an arbitrary majority-direction graph is not.
357
+ const APP_SEGMENT_PATTERN = /^(app|apps|cli|cmd)$/;
358
+ function detectAppOverLibrary(modules, counts, declaredModules, baseConfig, graph) {
359
+ const appNames = modules.filter(m => matchesAnySegment(moduleGlob(declaredModules, m.name), APP_SEGMENT_PATTERN)).map(m => m.name);
360
+ const libNames = modules.map(m => m.name).filter(n => !appNames.includes(n));
361
+ if (appNames.length === 0 || libNames.length === 0)
362
+ return undefined;
363
+ const forward = edgesBetweenGroups(counts, appNames, libNames); // app -> library, the intended direction
364
+ const reverse = edgesBetweenGroups(counts, libNames, appNames); // library -> app, the direction this proposal forbids
365
+ if (forward === 0)
366
+ return undefined; // no evidence an app area depends on a library area at all
367
+ const total = forward + reverse;
368
+ const ns = freeTagNamespace(baseConfig, "tier");
369
+ const classify = partitionClassify(declaredModules, `${ns}:lib`, appNames, `${ns}:app`);
370
+ const because = `library -> app: ${reverse} of ${total} edges; app -> library: ${forward}`;
371
+ const orderRule = { tagNamespace: ns, sequence: { "": ["lib", "app"] }, direction: "downward-only", because };
372
+ const proposedConfig = {
373
+ ...baseConfig,
374
+ classify: [...(baseConfig.classify ?? []), ...classify],
375
+ edges: { ...baseConfig.edges, order: [...(baseConfig.edges?.order ?? []), orderRule] },
376
+ };
377
+ const configFragment = [
378
+ "classify: [",
379
+ ...classify.map(c => ` { glob: ${JSON.stringify(c.glob)}, tags: ${JSON.stringify(c.tags)} },`),
380
+ "],",
381
+ "edges: { order: [",
382
+ ` { tagNamespace: ${JSON.stringify(ns)}, sequence: { "": ["lib","app"] }, direction: "downward-only", because: ${JSON.stringify(because)} },`,
383
+ "] },",
384
+ ].join("\n");
385
+ return {
386
+ proposal: {
387
+ pattern: "app-over-library",
388
+ support: forward / total,
389
+ evidence: [because, `app area(s): ${appNames.join(", ")}`, `library area(s): ${libNames.join(", ")}`],
390
+ configFragment,
391
+ addedViolations: countAddedViolations(graph, baseConfig, proposedConfig, "tag-order"),
392
+ do: PROVE_RULES_DO,
393
+ },
394
+ weight: total,
395
+ };
396
+ }
397
+ // A bare-name equivalent for a package resolved through its own
398
+ // `@types/<name>` shadow package (constraints.ts's own convention: a
399
+ // deny/allow rule against either identity matches the same real edge).
400
+ // Grouping by this bare name, not the raw resolved identity, keeps a
401
+ // package's value-import edges and its type-only `@types/` edges from
402
+ // splitting into two separate, half-evidenced candidates.
403
+ function bareExternalPackageName(name) {
404
+ if (!name.startsWith("@types/"))
405
+ return name;
406
+ const rest = name.slice("@types/".length);
407
+ const scopeSplit = rest.indexOf("__");
408
+ return scopeSplit === -1 ? rest : `@${rest.slice(0, scopeSplit)}/${rest.slice(scopeSplit + 2)}`;
409
+ }
410
+ // patterns.md's "external package confined to one area" - the most common
411
+ // shape kept in a real import graph even when no config declares it. Read
412
+ // from `graph.edges` (not `crossModuleEdges`): an external package's own
413
+ // target is never a declared module, so `toModule` is always undefined
414
+ // and `crossModuleEdges` filters every such edge out by construction.
415
+ // Capped to the 3 most-evidenced packages so a project with many
416
+ // confined dependencies (43 of 50 surveyed keep at least one) does not by
417
+ // itself fill every slot the overall 5-proposal cap allows.
418
+ const EXTERNAL_PACKAGE_PROPOSAL_CAP = 3;
419
+ function detectExternalPackageConfined(modules, graph, declaredModules, baseConfig) {
420
+ if (modules.length < 2)
421
+ return []; // "confined to one area" needs another area it is absent from
422
+ const byPackage = new Map(); // bare package name -> (owning module -> edge count)
423
+ for (const edge of graph.edges) {
424
+ if (edge.externalPackage === undefined)
425
+ continue;
426
+ const name = bareExternalPackageName(edge.externalPackage);
427
+ const byModule = byPackage.get(name) ?? new Map();
428
+ byModule.set(edge.fromModule, (byModule.get(edge.fromModule) ?? 0) + 1);
429
+ byPackage.set(name, byModule);
430
+ }
431
+ const candidates = [];
432
+ for (const [name, byModule] of byPackage) {
433
+ if (byModule.size !== 1)
434
+ continue; // imported from more than one area: not confined
435
+ const [module, edgeCount] = [...byModule.entries()][0];
436
+ candidates.push({ name, module, edgeCount });
437
+ }
438
+ candidates.sort((a, b) => b.edgeCount - a.edgeCount || a.name.localeCompare(b.name));
439
+ return candidates.slice(0, EXTERNAL_PACKAGE_PROPOSAL_CAP).map(({ name, module, edgeCount }) => {
440
+ const ns = freeTagNamespace(baseConfig, "kind");
441
+ const classify = partitionClassify(declaredModules, `${ns}:rest`, [module], `${ns}:confined`);
442
+ const because = `'${name}' is imported ${edgeCount} time(s), all from '${module}'; no other module imports it today`;
443
+ const allowDenyRule = { source: `${ns}:rest`, targetNamespace: "pkg", deny: [name], because };
444
+ const proposedConfig = {
445
+ ...baseConfig,
446
+ classify: [...(baseConfig.classify ?? []), ...classify],
447
+ edges: { ...baseConfig.edges, allowDeny: [...(baseConfig.edges?.allowDeny ?? []), allowDenyRule] },
448
+ };
449
+ const configFragment = [
450
+ "classify: [",
451
+ ...classify.map(c => ` { glob: ${JSON.stringify(c.glob)}, tags: ${JSON.stringify(c.tags)} },`),
452
+ "],",
453
+ "edges: { allowDeny: [",
454
+ ` { source: ${JSON.stringify(`${ns}:rest`)}, targetNamespace: "pkg", deny: ${JSON.stringify([name])}, because: ${JSON.stringify(because)} },`,
455
+ "] },",
456
+ ].join("\n");
457
+ return {
458
+ proposal: {
459
+ pattern: "external-package-confined",
460
+ support: 1,
461
+ evidence: [because],
462
+ configFragment,
463
+ addedViolations: countAddedViolations(graph, baseConfig, proposedConfig, "tag-boundary"),
464
+ do: PROVE_RULES_DO,
465
+ },
466
+ weight: edgeCount,
467
+ };
468
+ });
469
+ }
470
+ // patterns.md's "test code kept out of production": a test/fixture/mock
471
+ // module that already imports production code (real evidence it exists
472
+ // to exercise the rest of the project) but that no production module
473
+ // imports back today. Requiring evidence in both directions rules out an
474
+ // empty, disconnected directory that merely happens to share the name -
475
+ // zero edges either way is not a kept habit, it is silence.
476
+ const TEST_SEGMENT_PATTERN = /^(tests?|__tests__|test-utils|fixtures?|mocks?|helpers?)$/;
477
+ function detectTestCodeIsolation(modules, counts, declaredModules, baseConfig, graph) {
478
+ const testNames = modules.filter(m => matchesAnySegment(moduleGlob(declaredModules, m.name), TEST_SEGMENT_PATTERN)).map(m => m.name);
479
+ const prodNames = modules.map(m => m.name).filter(n => !testNames.includes(n));
480
+ if (prodNames.length === 0)
481
+ return [];
482
+ const proposals = [];
483
+ for (const testName of testNames) {
484
+ const prodToTest = edgesBetweenGroups(counts, prodNames, [testName]);
485
+ const testToProd = edgesBetweenGroups(counts, [testName], prodNames);
486
+ if (prodToTest > 0 || testToProd === 0)
487
+ continue; // already reached from production, or no evidence it exercises any production module
488
+ const ns = freeTagNamespace(baseConfig, "kind");
489
+ const classify = partitionClassify(declaredModules, `${ns}:prod`, [testName], `${ns}:test`);
490
+ const because = `'${testName}' is never imported by any of ${prodNames.length} production module(s) today; it imports ${testToProd} of them, real evidence it exercises production code`;
491
+ const pointRule = { from: { tags: [`${ns}:prod`] }, to: { tags: [`${ns}:test`] }, because };
492
+ const proposedConfig = {
493
+ ...baseConfig,
494
+ classify: [...(baseConfig.classify ?? []), ...classify],
495
+ edges: { ...baseConfig.edges, point: [...(baseConfig.edges?.point ?? []), pointRule] },
496
+ };
497
+ const configFragment = [
498
+ "classify: [",
499
+ ...classify.map(c => ` { glob: ${JSON.stringify(c.glob)}, tags: ${JSON.stringify(c.tags)} },`),
500
+ "],",
501
+ "edges: { point: [",
502
+ ` { from: { tags: [${JSON.stringify(`${ns}:prod`)}] }, to: { tags: [${JSON.stringify(`${ns}:test`)}] }, because: ${JSON.stringify(because)} },`,
503
+ "] },",
504
+ ].join("\n");
505
+ proposals.push({
506
+ proposal: {
507
+ pattern: "test-code-isolation",
508
+ support: 1,
509
+ evidence: [because],
510
+ configFragment,
511
+ addedViolations: countAddedViolations(graph, baseConfig, proposedConfig, "point-rule"),
512
+ do: PROVE_RULES_DO,
513
+ },
514
+ weight: testToProd,
515
+ });
516
+ }
517
+ return proposals;
518
+ }
519
+ // patterns.md's "host/plugin inversion": a host/core area a plugin area
520
+ // already depends on, that never depends back. `support` reads below 1
521
+ // exactly like `detectLayeredOrder`'s reverse edge does - a real host
522
+ // that names one concrete plugin today is still worth proposing, with the
523
+ // existing edge counted as the added violation this proposal would create.
524
+ const HOST_SEGMENT_PATTERN = /^(core|host)$/;
525
+ const PLUGIN_SEGMENT_PATTERN = /^(plugins?|extensions?)$/;
526
+ function detectHostPluginInversion(modules, counts, declaredModules, baseConfig, graph) {
527
+ const hostNames = modules.filter(m => matchesAnySegment(moduleGlob(declaredModules, m.name), HOST_SEGMENT_PATTERN)).map(m => m.name);
528
+ const pluginNames = modules.filter(m => matchesAnySegment(moduleGlob(declaredModules, m.name), PLUGIN_SEGMENT_PATTERN)).map(m => m.name);
529
+ if (hostNames.length === 0 || pluginNames.length === 0)
530
+ return undefined;
531
+ const pluginToHost = edgesBetweenGroups(counts, pluginNames, hostNames);
532
+ const hostToPlugin = edgesBetweenGroups(counts, hostNames, pluginNames);
533
+ if (pluginToHost === 0)
534
+ return undefined; // no evidence a plugin depends on the host at all
535
+ const total = pluginToHost + hostToPlugin;
536
+ const ns = freeTagNamespace(baseConfig, "kind");
537
+ const classify = [
538
+ ...hostNames.map(name => ({ glob: moduleGlob(declaredModules, name), tags: [`${ns}:host`] })),
539
+ ...pluginNames.map(name => ({ glob: moduleGlob(declaredModules, name), tags: [`${ns}:plugin`] })),
540
+ ];
541
+ const because = `plugin -> host: ${pluginToHost} edge(s); host -> plugin: ${hostToPlugin} edge(s)`;
542
+ const allowDenyRule = { source: `${ns}:host`, targetNamespace: ns, deny: ["plugin"], because };
543
+ const proposedConfig = {
544
+ ...baseConfig,
545
+ classify: [...(baseConfig.classify ?? []), ...classify],
546
+ edges: { ...baseConfig.edges, allowDeny: [...(baseConfig.edges?.allowDeny ?? []), allowDenyRule] },
547
+ };
548
+ const configFragment = [
549
+ "classify: [",
550
+ ...classify.map(c => ` { glob: ${JSON.stringify(c.glob)}, tags: ${JSON.stringify(c.tags)} },`),
551
+ "],",
552
+ "edges: { allowDeny: [",
553
+ ` { source: ${JSON.stringify(`${ns}:host`)}, targetNamespace: ${JSON.stringify(ns)}, deny: ["plugin"], because: ${JSON.stringify(because)} },`,
554
+ "] },",
555
+ ].join("\n");
556
+ return {
557
+ proposal: {
558
+ pattern: "host-plugin-inversion",
559
+ support: pluginToHost / total,
560
+ evidence: [because, `host area(s): ${hostNames.join(", ")}`, `plugin area(s): ${pluginNames.join(", ")}`],
561
+ configFragment,
562
+ addedViolations: countAddedViolations(graph, baseConfig, proposedConfig, "tag-boundary"),
563
+ do: PROVE_RULES_DO,
564
+ },
565
+ weight: total,
566
+ };
567
+ }
568
+ // patterns.md's "feature isolation with a shared kernel": at least two
569
+ // sibling modules under the same features/modules/pages container, plus
570
+ // one kernel-named module (shared/core/common/lib) they import - grouped
571
+ // by the container's own literal prefix so an unrelated directory sharing
572
+ // a feature's own name elsewhere in the tree never joins the group.
573
+ const FEATURE_CONTAINER_PATTERN = /^(features?|modules|pages)$/;
574
+ const KERNEL_SEGMENT_PATTERN = /^(shared|core|common|lib)$/;
575
+ function featureContainerKey(glob) {
576
+ const segments = moduleSegments(glob);
577
+ const index = segments.findIndex(s => FEATURE_CONTAINER_PATTERN.test(s));
578
+ if (index === -1 || index === segments.length - 1)
579
+ return undefined; // needs a feature name segment after the container
580
+ return segments.slice(0, index + 1).join("/");
581
+ }
582
+ function detectFeatureIsolation(modules, counts, declaredModules, baseConfig, graph) {
583
+ const groups = new Map();
584
+ for (const module of modules) {
585
+ const key = featureContainerKey(moduleGlob(declaredModules, module.name));
586
+ if (key === undefined)
587
+ continue;
588
+ (groups.get(key) ?? groups.set(key, []).get(key)).push(module.name);
589
+ }
590
+ const kernelNames = modules.filter(m => matchesAnySegment(moduleGlob(declaredModules, m.name), KERNEL_SEGMENT_PATTERN)
591
+ && featureContainerKey(moduleGlob(declaredModules, m.name)) === undefined).map(m => m.name);
592
+ const proposals = [];
593
+ for (const [, featureNames] of groups) {
594
+ if (featureNames.length < 2 || kernelNames.length === 0)
595
+ continue;
596
+ // The kernel candidate these features lean on most - the strongest
597
+ // evidence for which shared module, if more than one name matches.
598
+ const kernelName = [...kernelNames].sort((a, b) => edgesBetweenGroups(counts, featureNames, [b]) - edgesBetweenGroups(counts, featureNames, [a]) || a.localeCompare(b))[0];
599
+ const featureToKernel = edgesBetweenGroups(counts, featureNames, [kernelName]);
600
+ if (featureToKernel === 0)
601
+ continue; // no evidence these features actually use this kernel
602
+ const crossFeature = edgesBetweenGroups(counts, featureNames, featureNames);
603
+ const featureNs = freeTagNamespace(baseConfig, "feature");
604
+ const kernelNs = freeTagNamespace(baseConfig, "kind");
605
+ const classify = [
606
+ ...featureNames.map(name => ({ glob: moduleGlob(declaredModules, name), tags: [`${featureNs}:${name}`] })),
607
+ { glob: moduleGlob(declaredModules, kernelName), tags: [`${kernelNs}:shared`] },
608
+ ];
609
+ const because = `features -> kernel ('${kernelName}'): ${featureToKernel} edge(s); features -> each other: ${crossFeature} edge(s)`;
610
+ const allowDenyRules = featureNames.map(name => ({ source: `${featureNs}:${name}`, targetNamespace: featureNs, allow: [], because }));
611
+ const proposedConfig = {
612
+ ...baseConfig,
613
+ classify: [...(baseConfig.classify ?? []), ...classify],
614
+ edges: { ...baseConfig.edges, allowDeny: [...(baseConfig.edges?.allowDeny ?? []), ...allowDenyRules] },
615
+ };
616
+ const configFragment = [
617
+ "classify: [",
618
+ ...classify.map(c => ` { glob: ${JSON.stringify(c.glob)}, tags: ${JSON.stringify(c.tags)} },`),
619
+ "],",
620
+ "edges: { allowDeny: [",
621
+ ...allowDenyRules.map(r => ` { source: ${JSON.stringify(r.source)}, targetNamespace: ${JSON.stringify(featureNs)}, allow: [], because: ${JSON.stringify(because)} },`),
622
+ "] },",
623
+ ].join("\n");
624
+ proposals.push({
625
+ proposal: {
626
+ pattern: "feature-isolation",
627
+ support: featureToKernel / (featureToKernel + crossFeature),
628
+ evidence: [because, `sibling features: ${featureNames.join(", ")}`],
629
+ configFragment,
630
+ addedViolations: countAddedViolations(graph, baseConfig, proposedConfig, "tag-boundary"),
631
+ do: PROVE_RULES_DO,
632
+ },
633
+ weight: featureToKernel + crossFeature,
634
+ });
635
+ }
636
+ return proposals;
637
+ }
638
+ // At most 5 proposals survive - a project with more detectable shapes than
639
+ // that sees only its strongest-evidenced ones; `detected` (recommend()'s
640
+ // own field) keeps the cut visible.
641
+ //
642
+ // Ranking cannot sort on `support` alone: a leaf kernel with exactly one
643
+ // importer scores a clean 1, tying or beating a real, near-total fit like
644
+ // an application depending on a library through hundreds of edges with a
645
+ // small, real handful of exceptions (support just under 1). `rankScore`
646
+ // shrinks `support` toward 0 by how little evidence backs it
647
+ // (`weight / (weight + RANK_SHRINKAGE)`, the same idea a ratings site
648
+ // uses so five five-star reviews don't outrank a thousand at 4.9) - a
649
+ // trivially clean proposal with almost no real evidence sinks below a
650
+ // large, mostly-clean one, without changing `support` itself (still the
651
+ // plain fraction a reader sees, and what the two existing layered-order
652
+ // tests already assert exactly).
653
+ const RANK_SHRINKAGE = 5;
654
+ const PATTERN_PROPOSAL_CAP = 5;
655
+ function rankScore(proposal, weight) {
656
+ return proposal.support * (weight / (weight + RANK_SHRINKAGE));
657
+ }
658
+ function detectPatterns(modules, graph, counts, declaredModules, baseConfig, surfaceProposals) {
659
+ const publicEntryOnly = detectPublicEntryOnly(surfaceProposals);
660
+ const weighted = [
661
+ ...[detectLayeredOrder(modules, counts, declaredModules, baseConfig, graph)].filter((w) => w !== undefined),
662
+ ...detectLeafKernels(modules, graph, declaredModules, baseConfig),
663
+ ...(publicEntryOnly === undefined ? [] : [publicEntryOnly]),
664
+ ...[detectAppOverLibrary(modules, counts, declaredModules, baseConfig, graph)].filter((w) => w !== undefined),
665
+ ...detectExternalPackageConfined(modules, graph, declaredModules, baseConfig),
666
+ ...detectTestCodeIsolation(modules, counts, declaredModules, baseConfig, graph),
667
+ ...[detectHostPluginInversion(modules, counts, declaredModules, baseConfig, graph)].filter((w) => w !== undefined),
668
+ ...detectFeatureIsolation(modules, counts, declaredModules, baseConfig, graph),
669
+ ];
670
+ weighted.sort((a, b) => rankScore(b.proposal, b.weight) - rankScore(a.proposal, a.weight) || a.proposal.pattern.localeCompare(b.proposal.pattern));
671
+ return weighted.map(w => w.proposal);
672
+ }
673
+ // Report every eligible pair, even when the count is large; a hidden cap would conceal choices the reader should make.
674
+ // Beyond empty directories, pruning heuristics would substitute the tool's priorities for the reader's decision about which boundaries matter.
675
+ // This verb proposes observed boundaries without imposing or judging them, so it offers no --apply, --write, or --prove flag.
676
+ export async function recommend(projectRoot, dir, surface = DEFAULT_SURFACE) {
677
+ const configPath = resolve(projectRoot, "archstrict.config.ts");
678
+ const config = existsSync(configPath) ? await loadConfig(configPath) : undefined;
679
+ // A config supplies its own scope regardless of `dir` - unchanged from
680
+ // before. Without one, `dir` means init's own directory argument (its
681
+ // same normalization rules), not a glob: recommend walks in memory
682
+ // exactly what init would write. Passing "recommend" as the verb keeps a
683
+ // bad argument's own error and `do:` naming the command that was
684
+ // actually run, not init.
685
+ const plan = config ? undefined : freshRun(projectRoot, normalizeDirArg(dir, "recommend"), "recommend");
686
+ const declaredModules = config ? config.declaredModules : plan.declaredModules;
687
+ const graph = config
688
+ ? buildModuleGraphForRules({ projectRoot, declaredModules: config.declaredModules, exclude: config.exclude, surface: config.surface })
689
+ : buildModuleGraphForRules({ projectRoot, declaredModules: plan.declaredModules, exclude: plan.exclude, surface });
690
+ // An empty directory has no files to import or be imported by within this graph.
691
+ // It cannot form a real candidate pair, so reporting it would add noise rather than information.
692
+ const modules = [...graph.modules.values()].filter(module => module.files.length > 0).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
693
+ const counts = new Map();
694
+ for (const edge of graph.crossModuleEdges) {
695
+ if (edge.toModule === undefined)
696
+ continue;
697
+ const targets = counts.get(edge.fromModule) ?? new Map();
698
+ targets.set(edge.toModule, (targets.get(edge.toModule) ?? 0) + 1);
699
+ counts.set(edge.fromModule, targets);
700
+ }
701
+ const surfaceProposals = proposeSurfaces(graph);
702
+ // A placeholder Config for the no-config path (freshRun's own plan, not
703
+ // a file on disk): detectPatterns only ever reads classify/edges/because
704
+ // off it and passes it straight to runRules, which needs a well-formed
705
+ // Config either way, real or previewed.
706
+ const baseConfig = config ?? { configPath, declaredModules: plan.declaredModules, exclude: plan.exclude, surface, because: "archstrict recommend preview" };
707
+ const detected = detectPatterns(modules, graph, counts, declaredModules, baseConfig, surfaceProposals);
708
+ return {
709
+ modules: modules.length,
710
+ proposedClassify: modules.map(module => ({ glob: declaredModules.find(d => d.name === module.name).glob, tags: [`role:${module.name}`] })),
711
+ detected: detected.length,
712
+ patternProposals: detected.slice(0, PATTERN_PROPOSAL_CAP),
713
+ surfaceProposals,
714
+ };
715
+ }
716
+ // Bounds how many surface-less modules and how many name lists inside an
717
+ // evidence line get printed (JSON keeps every module and every name) - the
718
+ // same "bounded text, complete JSON" split check.ts's own grouped text
719
+ // follows for a large violation list. A surface proposal's own candidate
720
+ // files use the tighter SURFACE_CANDIDATE_TEXT_CAP below instead: a
721
+ // project with dozens of modules must not turn one `recommend` run's own
722
+ // text into hundreds of lines; `--json` always carries every module,
723
+ // every candidate, every name.
724
+ const TEXT_LIST_CAP = 5;
725
+ // Truncates a comma-separated list to its first `TEXT_LIST_CAP` items,
726
+ // appending how many were left out - `items.length` alone (not a fixed
727
+ // count) so a 6-item list reads "+1 more", never a cap that only ever
728
+ // fires past its own trigger point.
729
+ function formatCappedList(items) {
730
+ if (items.length <= TEXT_LIST_CAP)
731
+ return items.join(", ");
732
+ return `${items.slice(0, TEXT_LIST_CAP).join(", ")}, +${items.length - TEXT_LIST_CAP} more`;
733
+ }
734
+ // Every evidence line this file's own detectors emit that names a group
735
+ // of modules by a fixed prefix, matched here so this stays a text-only
736
+ // concern: `evidence` itself (and `--json`) keeps the full list, since
737
+ // truncating a shared string array at construction time would truncate
738
+ // the JSON too, not just the text a human reads.
739
+ const EVIDENCE_LIST_PREFIXES = [/^(?:app|library|host|plugin) area\(s\): /, /^sibling features: /];
740
+ function capEvidenceLineForText(line) {
741
+ const prefix = EVIDENCE_LIST_PREFIXES.map(p => p.exec(line)?.[0]).find((m) => m !== undefined);
742
+ if (prefix === undefined)
743
+ return line;
744
+ return prefix + formatCappedList(line.slice(prefix.length).split(", "));
745
+ }
746
+ // public-entry-only's own evidence repeats one line per surface-less
747
+ // module - exactly what "proposed surfaces" below already prints in full,
748
+ // with real candidate files and per-module do: lines the pattern's own
749
+ // evidence never carries. Text collapses it to three counts (modules
750
+ // without a surface, bypasses retired in total, bypasses remaining) and a
751
+ // pointer to where the detail already lives; --json keeps the full
752
+ // per-module evidence this summary is computed from, unchanged.
753
+ function summarizePublicEntryOnlyForText(surfaceProposals) {
754
+ const totalCovered = surfaceProposals.reduce((sum, p) => sum + p.coveredImports, 0);
755
+ const totalRemaining = surfaceProposals.reduce((sum, p) => sum + p.remainingImports, 0);
756
+ return [
757
+ ` ${surfaceProposals.length} module(s) have no public surface today; naming the proposed surfaces below would retire ${totalCovered} bypass(es), leaving ${totalRemaining}`,
758
+ " see \"proposed surfaces\" below for the per-module detail",
759
+ ];
760
+ }
761
+ // Tighter than TEXT_LIST_CAP: a surface proposal's own candidates are
762
+ // already ranked densest-first (proposeSurfaces's own sort), so the first
763
+ // 3 carry most of the coverage story a reader needs, and this list repeats
764
+ // once per shown module (up to TEXT_LIST_CAP of them) - the main line cost
765
+ // in this section. JSON keeps every candidate regardless.
766
+ const SURFACE_CANDIDATE_TEXT_CAP = 3;
767
+ export function formatRecommendText(result) {
768
+ const quote = JSON.stringify;
769
+ const shownSurfaceProposals = result.surfaceProposals.slice(0, TEXT_LIST_CAP);
770
+ return [
771
+ `${result.modules} modules; ${result.detected} pattern(s) detected, ${result.patternProposals.length} shown`,
772
+ ...(result.patternProposals.length === 0 ? [] : [
773
+ "", "pattern proposals, ranked by evidence:",
774
+ ...result.patternProposals.flatMap(proposal => [
775
+ ` ${proposal.pattern} (support ${(proposal.support * 100).toFixed(0)}%, would add ${proposal.addedViolations} violation(s) today):`,
776
+ ...(proposal.pattern === "public-entry-only"
777
+ ? summarizePublicEntryOnlyForText(result.surfaceProposals)
778
+ : proposal.evidence.map(line => ` ${capEvidenceLineForText(line)}`)),
779
+ ` do: ${proposal.do}`,
780
+ ]),
781
+ ]),
782
+ ...(result.surfaceProposals.length === 0 ? [] : [
783
+ "", "proposed surfaces (no public surface file present today):",
784
+ // The three choices below apply the same way to every module in
785
+ // this section - printed once here instead of once per module (see
786
+ // proposeSurfaces's own `choices`, still complete in JSON).
787
+ " do: set { name, ..., surface: [...] } in declaredModules, per module below, to retire its listed bypasses",
788
+ " do: or add a barrel file re-exporting from a chosen entry file, and name that as the module's surface instead",
789
+ " do: or leave a module entirely private and run archstrict todo to freeze its bypasses as debt instead",
790
+ ...shownSurfaceProposals.flatMap(proposal => [
791
+ ` ${proposal.module}: ${quote(proposal.proposedSurface)} covers ${proposal.coveredImports} of ${proposal.totalImports} bypasses, ${proposal.remainingImports} remaining`,
792
+ ...proposal.candidates.slice(0, SURFACE_CANDIDATE_TEXT_CAP).map(c => ` ${c.file} (${c.importers} importer(s))`),
793
+ ...(proposal.candidates.length > SURFACE_CANDIDATE_TEXT_CAP ? [` ... ${proposal.candidates.length - SURFACE_CANDIDATE_TEXT_CAP} more candidate(s); see --json`] : []),
794
+ ` ${proposal.choices[0]}`,
795
+ ]),
796
+ ...(result.surfaceProposals.length > TEXT_LIST_CAP ? [` ... ${result.surfaceProposals.length - TEXT_LIST_CAP} more surface-less module(s); see --json`] : []),
797
+ ]),
798
+ "",
799
+ ].join("\n");
800
+ }