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.
- package/.agents/hooks/hooks.json +29 -0
- package/.agents/hooks/post-tool-use.mjs +107 -0
- package/.agents/hooks/pre-tool-use.mjs +182 -0
- package/.agents/mcp/server.mjs +71 -0
- package/.agents/plugin.json +19 -0
- package/AGENTS.md +69 -0
- package/CHANGELOG.md +38 -0
- package/README.ja.md +62 -0
- package/README.md +63 -2
- package/dist/augmentation-cache.js +65 -0
- package/dist/check-options.js +40 -0
- package/dist/classify.js +148 -0
- package/dist/cli.js +239 -0
- package/dist/config-pointer.js +251 -0
- package/dist/config.js +186 -0
- package/dist/edge-cache.js +530 -0
- package/dist/mcp-server.js +111 -0
- package/dist/module-candidates.js +118 -0
- package/dist/module-graph.js +2072 -0
- package/dist/project-path.js +59 -0
- package/dist/report-error.js +13 -0
- package/dist/rules/config-meaning.js +143 -0
- package/dist/rules/constraints.js +417 -0
- package/dist/rules/cycles.js +257 -0
- package/dist/rules/deprecated.js +67 -0
- package/dist/rules/empty-rule.js +101 -0
- package/dist/rules/moves.js +79 -0
- package/dist/rules/must-be-empty.js +52 -0
- package/dist/rules/public-surface.js +100 -0
- package/dist/rules/type-leak.js +562 -0
- package/dist/rules/uncovered.js +75 -0
- package/dist/todo-migration.js +112 -0
- package/dist/todo-store.js +434 -0
- package/dist/type-closure.js +959 -0
- package/dist/verbs/agents.js +116 -0
- package/dist/verbs/check.js +957 -0
- package/dist/verbs/fix.js +170 -0
- package/dist/verbs/hotspots.js +261 -0
- package/dist/verbs/init.js +522 -0
- package/dist/verbs/recommend.js +800 -0
- package/dist/verbs/rules.js +188 -0
- package/dist/verbs/search.js +109 -0
- package/dist/verbs/simulate.js +220 -0
- package/dist/verbs/todo.js +163 -0
- package/dist/warm-graph.js +82 -0
- package/docs/boundary-patterns.md +374 -0
- package/docs/calibrated-rules-design.md +124 -0
- package/docs/init-singleton-modules.md +128 -0
- package/docs/maintenance.md +82 -0
- package/docs/releasing.md +55 -0
- package/docs/rules-edge-cache.md +50 -0
- package/docs/todo-single-file-migration.md +58 -0
- package/llms.txt +19 -0
- package/package.json +57 -4
- package/skills/archstrict/SKILL.md +42 -0
- package/skills/archstrict/references/agents-verb.md +39 -0
- package/skills/archstrict/references/config.md +107 -0
- package/skills/archstrict/references/hook.md +57 -0
- package/skills/archstrict/references/path-rules.md +57 -0
- package/skills/archstrict/references/patterns.md +883 -0
- package/skills/archstrict/references/prove-rules.md +58 -0
- package/skills/archstrict/references/rearchitect.md +35 -0
- package/skills/archstrict/references/recommend.md +80 -0
- package/skills/archstrict/references/rules.md +146 -0
- 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
|
+
}
|