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,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
|
+
}
|