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,257 @@
1
+ import { withPointerSpecs } from "../config-pointer.js";
2
+ const BECAUSE = "modules that import each other cannot be reasoned about, tested, or replaced independently";
3
+ const STALE_BECAUSE = "an ignoredCycles entry naming no real cycle hides nothing - it is dead configuration, not a decision anyone can still judge";
4
+ // A pair is lopsided when its minority direction is small in absolute
5
+ // terms (survey: 1-3 edges covers ~63% of real minority sides) AND
6
+ // dominated in relative terms (majority at least 3x minority) - see the
7
+ // header comment above for why both conditions are required together.
8
+ const MINORITY_MAX_EDGES = 3;
9
+ const MAJORITY_MIN_RATIO = 3;
10
+ // Real, project-relative file edges named in a lopsided pair's do: are
11
+ // capped at 5, so a pathological future change to the threshold above
12
+ // can't grow this list without bound. In practice the cap never binds
13
+ // today: the minority side has at most MINORITY_MAX_EDGES edges, deduped
14
+ // by (fromFile, resolvedFile) pair, so the displayed list is never
15
+ // longer than 3.
16
+ const MINORITY_FILE_EDGES_SHOWN = 5;
17
+ // All real value edges (not type-only, already excludes same-module
18
+ // edges via crossModuleEdges), grouped by ordered (from, to) module
19
+ // pair - undeduped, unlike buildAdjacency's adjacency list, because a
20
+ // lopsided-pair judgment needs the true edge count, not one
21
+ // representative edge.
22
+ function valueEdgesByOrderedPair(graph) {
23
+ const map = new Map();
24
+ for (const edge of graph.crossModuleEdges) {
25
+ if (edge.isTypeOnly)
26
+ continue;
27
+ const key = `${edge.fromModule}->${edge.toModule}`;
28
+ const list = map.get(key) ?? [];
29
+ list.push(edge);
30
+ map.set(key, list);
31
+ }
32
+ return map;
33
+ }
34
+ // The most lopsided pair among a component's members that has edges in
35
+ // both directions, or undefined when none qualifies. Iterates pairs in
36
+ // sorted module-name order and only replaces the running best on a
37
+ // strictly higher ratio, so a tie keeps the alphabetically first pair -
38
+ // a stable, deterministic tie-break, so re-running `check` always names
39
+ // the same pair for the same graph. Ratios are compared by cross
40
+ // multiplication, not floats, since edge counts are always small
41
+ // integers and this must stay exact.
42
+ function findMostLopsidedPair(component, edgesByPair) {
43
+ const sorted = [...component].sort();
44
+ let best;
45
+ for (let i = 0; i < sorted.length; i++) {
46
+ for (let j = i + 1; j < sorted.length; j++) {
47
+ const a = sorted[i];
48
+ const b = sorted[j];
49
+ const aToB = edgesByPair.get(`${a}->${b}`) ?? [];
50
+ const bToA = edgesByPair.get(`${b}->${a}`) ?? [];
51
+ if (aToB.length === 0 || bToA.length === 0)
52
+ continue; // not a pair with edges in both directions
53
+ const [minorityFrom, minorityTo, minorityEdges, majorityCount] = aToB.length <= bToA.length ? [a, b, aToB, bToA.length] : [b, a, bToA, aToB.length];
54
+ const minorityCount = minorityEdges.length;
55
+ if (minorityCount > MINORITY_MAX_EDGES)
56
+ continue;
57
+ if (majorityCount < minorityCount * MAJORITY_MIN_RATIO)
58
+ continue;
59
+ if (best === undefined || majorityCount * best.minorityEdges.length > best.majorityCount * minorityCount) {
60
+ best = { minorityFrom, minorityTo, minorityEdges, majorityCount };
61
+ }
62
+ }
63
+ }
64
+ return best;
65
+ }
66
+ function lopsidedDo(pair, relativePath) {
67
+ const fileEdges = [...new Set(pair.minorityEdges.map((e) => `${relativePath(e.fromFile)} -> ${relativePath(e.resolvedFile)}`))].sort().slice(0, MINORITY_FILE_EDGES_SHOWN).join(", ");
68
+ return `remove the ${pair.minorityEdges.length} import(s) from ${pair.minorityFrom} to ${pair.minorityTo} (${pair.minorityTo} imports ${pair.minorityFrom} ${pair.majorityCount} times, so ${pair.minorityFrom} -> ${pair.minorityTo} is likely the unintended direction): ${fileEdges}`;
69
+ }
70
+ // Smaller (fromFile, line, column) wins, by plain code-unit path compare -
71
+ // a total order independent of which edge the walk happened to visit
72
+ // first, so the SAME representative edge is picked for a given (from, to)
73
+ // module pair regardless of walk order (module-graph.ts's own edge build
74
+ // makes no promise about that order - see its own header).
75
+ function isEarlierEdge(a, b) {
76
+ if (a.fromFile !== b.fromFile)
77
+ return a.fromFile < b.fromFile;
78
+ if (a.fromPosition.line !== b.fromPosition.line)
79
+ return a.fromPosition.line < b.fromPosition.line;
80
+ return a.fromPosition.column < b.fromPosition.column;
81
+ }
82
+ function buildAdjacency(graph) {
83
+ const representativeByPair = new Map();
84
+ for (const edge of graph.crossModuleEdges) {
85
+ if (edge.isTypeOnly)
86
+ continue; // decision above: type-only edges don't count for cycles
87
+ const to = edge.toModule;
88
+ const pairKey = `${edge.fromModule}->${to}`;
89
+ const existing = representativeByPair.get(pairKey);
90
+ if (existing === undefined || isEarlierEdge(edge, existing.edge)) {
91
+ representativeByPair.set(pairKey, { from: edge.fromModule, to, edge });
92
+ }
93
+ }
94
+ const adjacency = new Map();
95
+ for (const { from, to, edge } of representativeByPair.values()) {
96
+ const list = adjacency.get(from) ?? [];
97
+ list.push({ to, edge });
98
+ adjacency.set(from, list);
99
+ }
100
+ // Sorted by target module name - shortestCycleFrom's own BFS explores
101
+ // each node's neighbors in this list's order, so a tie between two
102
+ // equally short paths must also not depend on edge walk order. The
103
+ // reported cycle (which module, which file, which line) must be the
104
+ // same every run on the same graph, not an accident of file walk order.
105
+ for (const list of adjacency.values())
106
+ list.sort((a, b) => (a.to < b.to ? -1 : a.to > b.to ? 1 : 0));
107
+ return adjacency;
108
+ }
109
+ // Tarjan's algorithm: strongly connected components of size > 1 are cycles
110
+ // (a module-level self-loop cannot occur here — crossModuleEdges already
111
+ // excludes same-module edges).
112
+ function stronglyConnectedComponents(nodes, adjacency) {
113
+ let index = 0;
114
+ const indices = new Map();
115
+ const lowlinks = new Map();
116
+ const onStack = new Set();
117
+ const stack = [];
118
+ const components = [];
119
+ function strongconnect(v) {
120
+ indices.set(v, index);
121
+ lowlinks.set(v, index);
122
+ index++;
123
+ stack.push(v);
124
+ onStack.add(v);
125
+ for (const { to: w } of adjacency.get(v) ?? []) {
126
+ if (!indices.has(w)) {
127
+ strongconnect(w);
128
+ lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w)));
129
+ }
130
+ else if (onStack.has(w)) {
131
+ lowlinks.set(v, Math.min(lowlinks.get(v), indices.get(w)));
132
+ }
133
+ }
134
+ if (lowlinks.get(v) === indices.get(v)) {
135
+ const component = [];
136
+ let w;
137
+ do {
138
+ w = stack.pop();
139
+ onStack.delete(w);
140
+ component.push(w);
141
+ } while (w !== v);
142
+ components.push(component);
143
+ }
144
+ }
145
+ for (const node of nodes) {
146
+ if (!indices.has(node))
147
+ strongconnect(node);
148
+ }
149
+ return components;
150
+ }
151
+ // Shortest simple cycle that visits `start`, using only edges within
152
+ // `component` (BFS over paths, since module graphs are small — v0 does not
153
+ // need this to scale past a few dozen modules).
154
+ function shortestCycleFrom(start, component, adjacency) {
155
+ const queue = [{ node: start, path: [start], edges: [] }];
156
+ while (queue.length > 0) {
157
+ const { node, path, edges } = queue.shift();
158
+ for (const { to, edge } of adjacency.get(node) ?? []) {
159
+ if (!component.has(to))
160
+ continue;
161
+ if (to === start)
162
+ return { modules: [...path, start], edges: [...edges, edge] };
163
+ if (path.includes(to))
164
+ continue; // simple cycle only; don't revisit a node
165
+ queue.push({ node: to, path: [...path, to], edges: [...edges, edge] });
166
+ }
167
+ }
168
+ // Invariant, not error handling: every node in a strongly connected
169
+ // component of size > 1 lies on some cycle within it, so this branch is
170
+ // unreachable for a genuine SCC. It only fires if `component` was built
171
+ // wrong (e.g. from a stale or mismatched adjacency).
172
+ throw new Error(`no cycle found from ${start} within its own strongly connected component`);
173
+ }
174
+ // Reports at `firstEdge.fromFile` - one arbitrary edge inside the cycle,
175
+ // not the cycle's own identity (this file's header comment on
176
+ // fingerprints has more). "Any file" in the sense that a `check <file>`
177
+ // run's own focus could name any module in a cycle, or none - and the
178
+ // SCC computation itself has no per-file shortcut (a cycle is a property
179
+ // of the whole component, not of one edge), so this rule always runs its
180
+ // current, whole-project logic; runRules narrows its OWN returned
181
+ // violations down to the focus file afterward, the same as it does for
182
+ // every rule this file doesn't itself scope.
183
+ export function checkCycles(graph, config) {
184
+ const adjacency = buildAdjacency(graph);
185
+ const nodes = [...graph.modules.keys()];
186
+ const components = stronglyConnectedComponents(nodes, adjacency).filter((c) => c.length > 1);
187
+ const ignoredCycles = config?.ignoredCycles ?? [];
188
+ const valueEdgesByPair = valueEdgesByOrderedPair(graph);
189
+ const violations = [];
190
+ for (const component of components) {
191
+ const memberSet = new Set(component);
192
+ // A pair named in either order is ignored the moment both its modules
193
+ // are in the same component - the whole component, not just that one
194
+ // edge, since a cycle spanning more than two modules is one finding
195
+ // either way (this rule reports one violation per component already).
196
+ const ignored = ignoredCycles.some(([a, b]) => memberSet.has(a) && memberSet.has(b));
197
+ if (ignored)
198
+ continue;
199
+ const sorted = [...component].sort();
200
+ const anchor = sorted[0];
201
+ const { modules, edges } = shortestCycleFrom(anchor, new Set(component), adjacency);
202
+ const firstEdge = edges[0];
203
+ const fileChain = edges
204
+ .map((e) => `${graph.relativePath(e.fromFile)} -> ${graph.relativePath(e.resolvedFile)}`)
205
+ .join(", ");
206
+ const breakCycleDo = `break the cycle at ${graph.relativePath(firstEdge.fromFile)} -> ${graph.relativePath(firstEdge.resolvedFile)} (module ${modules[0]} -> ${modules[1]}), or merge the modules involved - real import chain: ${fileChain}`;
207
+ // A lopsided pair's minority edges are the likely accident and the
208
+ // cheap fix, so they lead the do:; the general break-the-cycle advice
209
+ // stays as the fallback for when that guess is wrong.
210
+ const lopsided = findMostLopsidedPair(component, valueEdgesByPair);
211
+ const doText = lopsided === undefined
212
+ ? breakCycleDo
213
+ : `${lopsidedDo(lopsided, graph.relativePath)}; alternatively, ${breakCycleDo}`;
214
+ violations.push({
215
+ rule: "cycle",
216
+ path: firstEdge.fromFile,
217
+ line: firstEdge.fromPosition.line,
218
+ column: firstEdge.fromPosition.column,
219
+ evidence: modules.join(" -> "),
220
+ because: BECAUSE,
221
+ do: doText,
222
+ todoModule: anchor,
223
+ });
224
+ }
225
+ return violations;
226
+ }
227
+ // Reports at `config.configPath` - a fact about `ignoredCycles` itself,
228
+ // not about any one file's own edge - so a `check <file>` run only ever
229
+ // keeps this rule's own finding when `focus` is the config file; runRules'
230
+ // own end-of-call filter (not this function) is what narrows it down.
231
+ //
232
+ // A declared pair not found together in any real strongly connected
233
+ // component at all (ignored or not) is stale - checked against every
234
+ // component, not just the ignored ones, since a pair that never cycled in
235
+ // the first place is just as stale as one that used to but no longer does.
236
+ export function checkStaleCycleExceptions(graph, config) {
237
+ const adjacency = buildAdjacency(graph);
238
+ const nodes = [...graph.modules.keys()];
239
+ const components = stronglyConnectedComponents(nodes, adjacency).filter((c) => c.length > 1);
240
+ const componentSets = components.map((c) => new Set(c));
241
+ const violations = [];
242
+ for (const [entryIndex, [a, b]] of (config.ignoredCycles ?? []).entries()) {
243
+ const stillCycles = componentSets.some((members) => members.has(a) && members.has(b));
244
+ if (stillCycles)
245
+ continue;
246
+ violations.push(withPointerSpecs({
247
+ rule: "stale-cycle-exception",
248
+ path: config.configPath,
249
+ line: 1,
250
+ column: 1,
251
+ evidence: `ignoredCycles entry ['${a}', '${b}'] names no real cycle`,
252
+ because: STALE_BECAUSE,
253
+ do: `remove ['${a}', '${b}'] from ignoredCycles in archstrict.config.ts`,
254
+ }, [{ pointer: `ignoredCycles[${entryIndex}]`, role: "fired" }]));
255
+ }
256
+ return violations;
257
+ }
@@ -0,0 +1,67 @@
1
+ // Responsibility: rule 5, deprecated edges. archstrict.config.ts's
2
+ // `deprecated` list records a from/to module edge with a declared `count`
3
+ // and a mandatory `because`. The edge's actual count must never increase
4
+ // (a real failure); a decrease only prompts updating `count` downward (an
5
+ // informational suggestion, not a failure) — tach's deprecated-dependency
6
+ // idea (warn, don't forbid) with "must not grow" added on top.
7
+ // Boundary: pure predicate over a ModuleGraph and a Config. No I/O, no
8
+ // output formatting. Does not suppress rule 1: a deprecated edge that also
9
+ // bypasses its target's public surface is still a rule-1 violation —
10
+ // deprecated means "shrinking," not "exempt from every other rule."
11
+ import { assertDeprecatedModulesExist } from "../config.js";
12
+ import { withPointerSpecs } from "../config-pointer.js";
13
+ // A deprecated edge's own count is the number of import/export/dynamic-
14
+ // import statements between the two named modules — what a reader would
15
+ // count by hand — not the number of distinct files involved. import type
16
+ // counts (same reasoning as rule 1: it still reaches the target module,
17
+ // even for a type alone).
18
+ function countEdges(graph, from, to) {
19
+ return graph.crossModuleEdges.filter((e) => e.fromModule === from && e.toModule === to).length;
20
+ }
21
+ // Both `violations` and `suggestions` report at `config.configPath` (a
22
+ // declared from/to module pair's own actual count, not a single edge's
23
+ // own file) - this rule always runs fully regardless of a `check <file>`
24
+ // run's own focus, and `assertDeprecatedModulesExist`'s own validation
25
+ // must run every time either way. runRules' own end-of-call filter keeps
26
+ // or drops the returned VIOLATIONS depending on whether focus names the
27
+ // config file - but never touches `suggestions`, which stays the full,
28
+ // whole-project list on every run: `filterToFile` (check.ts) only ever
29
+ // narrows `CheckResult.violations`, never `.suggestions`.
30
+ export function checkDeprecatedEdges(graph, config) {
31
+ // Validated up front, same reasoning as assertKindPatternsSupported: a
32
+ // config error must not depend on which branch of this function happens
33
+ // to run first.
34
+ assertDeprecatedModulesExist(graph, config);
35
+ const violations = [];
36
+ const suggestions = [];
37
+ for (const [entryIndex, entry] of (config.deprecated ?? []).entries()) {
38
+ const actual = countEdges(graph, entry.from, entry.to);
39
+ if (actual > entry.count) {
40
+ violations.push(withPointerSpecs({
41
+ rule: "deprecated-edge-increased",
42
+ path: config.configPath,
43
+ line: 1,
44
+ column: 1,
45
+ evidence: `${entry.from} -> ${entry.to}: declared count ${entry.count}, actual ${actual}`,
46
+ because: entry.because,
47
+ do: `reduce ${entry.from} -> ${entry.to} back to ${entry.count} edges, or raise count in archstrict.config.ts and record why the increase was accepted`,
48
+ }, [{ pointer: `deprecated[${entryIndex}].count`, role: "fired" }]));
49
+ }
50
+ else if (actual > 0 && actual < entry.count) {
51
+ // actual === 0 is not reported here at all: the edge is gone
52
+ // entirely, not merely smaller, which is rule 4's more specific
53
+ // "this deprecation is now moot" case (checkEmptyRuleSet), not a
54
+ // count to update.
55
+ suggestions.push({
56
+ rule: "deprecated-edge-decreased",
57
+ path: config.configPath,
58
+ line: 1,
59
+ column: 1,
60
+ evidence: `${entry.from} -> ${entry.to}: declared count ${entry.count}, actual ${actual}`,
61
+ because: entry.because,
62
+ do: `update count to ${actual} for ${entry.from} -> ${entry.to} in archstrict.config.ts`,
63
+ });
64
+ }
65
+ }
66
+ return { violations, suggestions };
67
+ }
@@ -0,0 +1,101 @@
1
+ // Responsibility: rule 4, the empty rule set (ArchUnitTS's Empty Test
2
+ // Protection). A configured rule that matches zero real things must not
3
+ // look like a pass — the same "a zero can look like success when it is
4
+ // really an omission" principle as rule 3, from the other direction: rule
5
+ // 3 is "a file no declared module covers is a failure"; this is "a
6
+ // declaration (a module, or a classify glob) that covers no real file is
7
+ // a failure". Also reports allow lists that cover every real target value.
8
+ // Boundary: a config-vs-graph consistency check, not an edge check. No
9
+ // I/O, no output formatting.
10
+ import { assertDeprecatedModulesExist } from "../config.js";
11
+ import { compileGlob } from "../classify.js";
12
+ import {} from "../module-graph.js";
13
+ import { checkEdgesCoverage, checkExhaustiveAllow } from "./constraints.js";
14
+ import { withPointerSpecs } from "../config-pointer.js";
15
+ const BECAUSE = "a rule that checks nothing must not look like a pass";
16
+ function violation(config, evidence, doText, pointer) {
17
+ return withPointerSpecs({
18
+ rule: "empty-rule-set",
19
+ path: config.configPath,
20
+ line: 1,
21
+ column: 1,
22
+ evidence,
23
+ because: BECAUSE,
24
+ do: doText,
25
+ }, [{ pointer, role: "fired" }]);
26
+ }
27
+ // Every finding here reports at `config.configPath` (see the Violation
28
+ // type's own comment above) - a config-vs-graph consistency fact, never a
29
+ // single file's own edge - so this rule always runs its current,
30
+ // whole-config logic regardless of a `check <file>` run's own focus;
31
+ // runRules' own end-of-call filter (not this function) is what keeps or
32
+ // drops it depending on whether focus names the config file itself.
33
+ export function checkEmptyRuleSet(graph, config) {
34
+ // Validated up front, same reasoning as ever: a config error must not
35
+ // depend on which branch runs first.
36
+ assertDeprecatedModulesExist(graph, config);
37
+ // No modules at all: not "a rule matched zero", but "nothing to check" -
38
+ // reported the same way (a violation, not a thrown error) so it fits
39
+ // the same 0-is-a-result shape as everything else `check` reports.
40
+ // Returned immediately rather than falling through to the classify loop
41
+ // below: with zero modules, every classify glob trivially matches
42
+ // nothing that belongs to any module either, so the loop would add one
43
+ // redundant violation per entry, all restating the same root cause.
44
+ if (graph.modules.size === 0) {
45
+ return [
46
+ violation(config, "no modules declared in declaredModules", "add at least one declaredModules entry in archstrict.config.ts", "declaredModules"),
47
+ ];
48
+ }
49
+ const violations = [];
50
+ // Every classify glob must match at least one real, in-scope file - the
51
+ // classification-layer's own version of "a kind that matches no
52
+ // module": a glob that matches nothing is a config typo or a stale
53
+ // entry, either way a rule that checks nothing must not look like a
54
+ // pass.
55
+ const allFiles = [...graph.modules.values()].flatMap((m) => m.files).concat(graph.outsideFiles);
56
+ for (const [entryIndex, entry] of (config.classify ?? []).entries()) {
57
+ const glob = compileGlob(entry.glob);
58
+ const matchesAny = allFiles.some((file) => glob.test(graph.relativePath(file)));
59
+ if (!matchesAny) {
60
+ violations.push(violation(config, `classify glob '${entry.glob}' matches no file in scope`, `remove this classify entry from archstrict.config.ts, or point its glob at real files`, `classify[${entryIndex}]`));
61
+ }
62
+ }
63
+ // A deprecated edge whose actual count has fallen to zero is not merely
64
+ // smaller (rule 5's "update count" suggestion) — the edge is gone
65
+ // entirely, so the entry itself is moot and checks nothing. Rule 5
66
+ // deliberately does not report this case (its own header explains why),
67
+ // so it belongs here instead: an empty-rule-set violation, not a count
68
+ // to shrink.
69
+ for (const [entryIndex, entry] of (config.deprecated ?? []).entries()) {
70
+ const actual = graph.crossModuleEdges.filter((e) => e.fromModule === entry.from && e.toModule === entry.to).length;
71
+ if (actual === 0) {
72
+ violations.push(violation(config, `deprecated edge '${entry.from} -> ${entry.to}' (declared count ${entry.count}) no longer exists`, `remove the '${entry.from} -> ${entry.to}' entry from deprecated in archstrict.config.ts`, `deprecated[${entryIndex}]`));
73
+ }
74
+ }
75
+ // An allowDeny/order/point rule whose own source/target combination
76
+ // never applies to any real edge in the graph is the constraint
77
+ // engine's own version of the same idea: a rule that structurally
78
+ // cannot fire must not look like a clean pass. This only catches the
79
+ // zero case. For an allow list, a genuine pass also needs a real target
80
+ // value outside the list, after the source-group exemption. Otherwise,
81
+ // the list guarantees a pass; the exhaustive-list check reports it below.
82
+ // rules.md explains why a nonzero clean result still needs a positive
83
+ // control, which these checks cannot replace.
84
+ const coverageIndices = { allowDeny: 0, order: 0, point: 0 };
85
+ for (const c of checkEdgesCoverage(graph, config)) {
86
+ const entryIndex = coverageIndices[c.kind]++;
87
+ if (c.evaluated === 0) {
88
+ violations.push(violation(config, `${c.kind} rule '${c.identifier}' matches no real edge in scope`, `remove or correct this ${c.kind} entry in archstrict.config.ts's edges - its own source/target never applies to any real edge this project has (a workspace-sibling import may resolve as an external package rather than a project tag; see rules.md)`, `edges.${c.kind}[${entryIndex}]`));
89
+ }
90
+ }
91
+ for (const { identifier, rule } of checkExhaustiveAllow(graph, config)) {
92
+ const entryIndex = (config.edges?.allowDeny ?? []).indexOf(rule);
93
+ violations.push(withPointerSpecs({
94
+ rule: "exhaustive-allow-list", path: config.configPath, line: 1, column: 1,
95
+ evidence: `allowDeny rule '${identifier}' allows every real target value with allow ${JSON.stringify(rule.allow)}`,
96
+ because: rule.because,
97
+ do: `narrow the allow list for '${identifier}' in archstrict.config.ts to a genuine subset of real target values, or remove the rule if it should forbid nothing today`,
98
+ }, [{ pointer: `edges.allowDeny[${entryIndex}].allow`, role: "fired" }]));
99
+ }
100
+ return violations;
101
+ }
@@ -0,0 +1,79 @@
1
+ // Responsibility: propose ranked moves for existing tag-boundary violations using the real graph and config.
2
+ // Boundary: decorates existing findings; never emits violations or applies edits.
3
+ // Retag moves are deferred: shared classification globs can affect sibling files and every rule that uses those tags.
4
+ // Such moves need a broader verification pass than these local proposals.
5
+ import { classifyFile, classifyByDirectoryName } from "../classify.js";
6
+ import {} from "../module-graph.js";
7
+ import { computeAllowDeny, checkExhaustiveAllow, isExemptedByGlobPair, targetTagsInGraph } from "./constraints.js";
8
+ export function computeMoves(violation, graph, config, context) {
9
+ if (violation.rule !== "tag-boundary")
10
+ return undefined;
11
+ const { edge, ruleIndex, violatingTag } = context;
12
+ const rules = config.edges?.allowDeny ?? [];
13
+ const rule = rules[ruleIndex];
14
+ if (!rule)
15
+ return undefined;
16
+ const prefix = `${rule.targetNamespace}:`;
17
+ const legal = rule.allow !== undefined ? new Set(rule.allow.map(value => `${prefix}${value}`)) :
18
+ new Set([...targetTagsInGraph(graph, config)].filter(tag => tag.startsWith(prefix) && tag !== rule.source &&
19
+ !(rule.deny ?? []).includes(tag.slice(prefix.length))));
20
+ // Use public surfaces as import targets; a module's full file list includes private implementation files.
21
+ // Proposing those files could trade a tag-boundary violation for the public-surface-bypass violation that rule 1 detects.
22
+ const surfaces = new Set();
23
+ for (const module of graph.modules?.values() ?? []) {
24
+ const tags = new Set(classifyByDirectoryName(graph.relativePath(module.dir), config.classifyByDirectoryName));
25
+ for (const surface of module.surfaceFiles) {
26
+ for (const tag of classifyFile(graph.relativePath(surface), config))
27
+ tags.add(tag);
28
+ }
29
+ if ([...tags].some(tag => legal.has(tag))) {
30
+ for (const surface of module.surfaceFiles)
31
+ surfaces.add(graph.relativePath(surface));
32
+ }
33
+ }
34
+ const moves = [];
35
+ if (surfaces.size > 0)
36
+ moves.push({ kind: "reroute", verified: false,
37
+ do: `consider importing from these public surfaces: ${JSON.stringify([...surfaces].sort())}; confirm the needed symbol is available` });
38
+ const from = graph.relativePath(edge.fromFile);
39
+ const to = edge.externalPackage === undefined ? graph.relativePath(edge.resolvedFile) : undefined;
40
+ // compileGlob treats a literal star as a wildcard and provides no escape mechanism.
41
+ // Such a path could silently exempt other pairs; omit the move rather than promise an exact exception we cannot guarantee.
42
+ if (to !== undefined && !from.includes("*") && !to.includes("*")) {
43
+ const entry = { from, to, because: "<author must state a real reason>" };
44
+ moves.push({ kind: "exception", widens: true,
45
+ verified: isExemptedByGlobPair(edge, [...rule.exceptions ?? [], entry], graph.relativePath),
46
+ do: `add ${JSON.stringify(entry)} to exceptions for allowDeny entry ${ruleIndex}; this exempts only this one edge pair` });
47
+ }
48
+ const value = violatingTag.slice(prefix.length);
49
+ const modified = rule.allow !== undefined ? { ...rule, allow: [...rule.allow, value] } :
50
+ { ...rule, deny: (rule.deny ?? []).filter(item => item !== value) };
51
+ // Verify widening with the real constraint and exhaustive-list checks; a shallow config copy preserves the caller's rules.
52
+ // This follows the empirical verification policy: a property test checks that expanding allow cannot add denied edges instead of assuming it.
53
+ const hypothetical = { ...config, edges: { ...config.edges,
54
+ allowDeny: rules.map((entry, index) => index === ruleIndex ? modified : entry) } };
55
+ const before = computeAllowDeny(graph, config).matches;
56
+ const after = computeAllowDeny(graph, hypothetical).matches;
57
+ const sameEdgeRule = (a, b) => a.edge === b.edge && a.ruleIndex === b.ruleIndex;
58
+ // Keep last-resort moves visible and name new findings from either check in creates.
59
+ // Hiding an imperfect option would conceal a real choice; labeling its consequences lets the reader assess it honestly.
60
+ const creates = new Set(after.filter(match => !before.some(old => sameEdgeRule(old, match))).map(match => match.violation.rule));
61
+ const beforeExhaustive = checkExhaustiveAllow(graph, config);
62
+ for (const finding of checkExhaustiveAllow(graph, hypothetical)) {
63
+ const index = hypothetical.edges.allowDeny.indexOf(finding.rule);
64
+ if (!beforeExhaustive.some(old => rules.indexOf(old.rule) === index && old.ruleId === finding.ruleId))
65
+ creates.add(finding.ruleId);
66
+ }
67
+ const move = {
68
+ kind: rule.allow !== undefined ? "widen-allow" : "widen-deny", widens: true,
69
+ // One import can carry several tags in the same namespace, such as synthesized pkg: tags.
70
+ // Admitting one value can leave another forbidden, so a proposed widening does not automatically verify the edge.
71
+ verified: !after.some(match => match.edge === edge && match.ruleIndex === ruleIndex),
72
+ do: rule.allow !== undefined ? `add ${JSON.stringify(value)} to allow for allowDeny entry ${ruleIndex}` :
73
+ `remove ${JSON.stringify(value)} from deny for allowDeny entry ${ruleIndex}`,
74
+ };
75
+ if (creates.size > 0)
76
+ move.creates = [...creates].sort();
77
+ moves.push(move);
78
+ return moves;
79
+ }
@@ -0,0 +1,52 @@
1
+ // Responsibility: config.mustBeEmpty - archspec's own "empty component"
2
+ // concept (a directory a team decided must hold no code at all, e.g.
3
+ // vanilla_rails's own convention that app/services stays empty when a
4
+ // project deliberately keeps rich models instead of service objects).
5
+ // Distinct from rule 4 (empty-rule-set): that rule flags a RULE that
6
+ // structurally cannot match anything; this one flags a real FILE existing
7
+ // where the config says none should. A violation is any file matching the
8
+ // glob at all - 0 matches is a clean pass, not silence, the same
9
+ // convention every other rule here follows.
10
+ // Boundary: pure predicate over a file list and a Config. No I/O of its
11
+ // own (the caller supplies which files exist), no output formatting.
12
+ import { compileGlob } from "../classify.js";
13
+ import { withPointerSpecs } from "../config-pointer.js";
14
+ // Reports at `file` (each entry's own path) - independent per file, unlike
15
+ // uncovered-module's own grouping/naming: whether one file matches a
16
+ // `mustBeEmpty` glob never depends on any other file in `files`. So a
17
+ // `check <file>` run's own caller (runRules) can safely narrow `files`
18
+ // down to just the focus file before calling this rule at all, and get
19
+ // exactly the same result as running the whole list and filtering
20
+ // afterward - the narrowing happens in runRules, not in this file.
21
+ //
22
+ // `files`: every real, project-root-relative (forward-slash) path this
23
+ // config's own analysis scope covers - the caller decides what that scope
24
+ // is (declared modules' own files, or a project's whole file list); this
25
+ // rule only tests each one against every `mustBeEmpty` glob.
26
+ export function checkMustBeEmpty(files, config) {
27
+ const entries = config.mustBeEmpty ?? [];
28
+ if (entries.length === 0)
29
+ return [];
30
+ const violations = [];
31
+ for (const [entryIndex, entry] of entries.entries()) {
32
+ const glob = compileGlob(entry.glob);
33
+ // `files` is not a promise about order (module membership is built
34
+ // walking rootNames order - a directory scan, not a promise about
35
+ // reading order across files) - sorted here so two files matching the
36
+ // same entry come out in a stable order regardless of it.
37
+ for (const file of [...files].sort()) {
38
+ if (!glob.test(file))
39
+ continue;
40
+ violations.push(withPointerSpecs({
41
+ rule: "must-be-empty",
42
+ path: file,
43
+ line: 1,
44
+ column: 1,
45
+ evidence: `'${file}' matches '${entry.glob}', which must stay empty`,
46
+ because: entry.because,
47
+ do: `move '${file}' out of '${entry.glob}', or drop this mustBeEmpty entry in archstrict.config.ts if the restriction no longer applies`,
48
+ }, [{ pointer: `mustBeEmpty[${entryIndex}]`, role: "fired" }]));
49
+ }
50
+ }
51
+ return violations;
52
+ }