archstrict 0.0.0 → 0.2.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 +81 -0
- package/CHANGELOG.md +77 -0
- package/README.ja.md +142 -0
- package/README.md +143 -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 +243 -0
- package/dist/config-pointer.js +251 -0
- package/dist/config.js +194 -0
- package/dist/edge-cache.js +530 -0
- package/dist/gitignore.js +271 -0
- package/dist/mcp-server.js +111 -0
- package/dist/module-candidates.js +125 -0
- package/dist/module-graph.js +2179 -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 +419 -0
- package/dist/rules/cycles.js +285 -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/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/type-leak.js +590 -0
- package/dist/verbs/agents.js +116 -0
- package/dist/verbs/check.js +1011 -0
- package/dist/verbs/fix.js +170 -0
- package/dist/verbs/hotspots.js +261 -0
- package/dist/verbs/init.js +538 -0
- package/dist/verbs/map-shape.js +78 -0
- package/dist/verbs/recommend.js +863 -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 +180 -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 +133 -0
- package/docs/maintenance.md +109 -0
- package/docs/releasing.md +58 -0
- package/docs/rules-edge-cache.md +50 -0
- package/docs/todo-single-file-migration.md +58 -0
- package/llms.txt +25 -0
- package/package.json +61 -4
- package/skills/archstrict/SKILL.md +54 -0
- package/skills/archstrict/references/agents-verb.md +39 -0
- package/skills/archstrict/references/config.md +116 -0
- package/skills/archstrict/references/hook.md +57 -0
- package/skills/archstrict/references/path-rules.md +57 -0
- package/skills/archstrict/references/patterns.md +915 -0
- package/skills/archstrict/references/prove-rules.md +58 -0
- package/skills/archstrict/references/rearchitect.md +66 -0
- package/skills/archstrict/references/recommend.md +98 -0
- package/skills/archstrict/references/rules.md +149 -0
- package/skills/archstrict/references/simulate.md +109 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Responsibility: plan type re-exports and keep each surface edit only after real rule verification.
|
|
2
|
+
// Boundary: fixes surface files only; preserves frozen violations and leaves internal declarations unchanged.
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { dirname, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { prepareGraph } from "../module-graph.js";
|
|
7
|
+
import { createWarmGraph } from "../warm-graph.js";
|
|
8
|
+
import { fingerprintOf, relativizeForTodo } from "../todo-store.js";
|
|
9
|
+
import { loadConfig, runRules, applyTodo, filterToFile } from "./check.js";
|
|
10
|
+
import { resolveWriteTarget, writeTarget } from "./agents.js";
|
|
11
|
+
import { createConfigLocator } from "../config-pointer.js";
|
|
12
|
+
// A bare interface or type can leak through an inferred return type without its declaring file ever exporting it.
|
|
13
|
+
// Re-exporting that declaration produces TS2459, but the surface's new export name can make the type-leak rule report success.
|
|
14
|
+
// Verification runs architecture rules, not a full semantic-diagnostics pass, so this guard must reject the declaration before any write.
|
|
15
|
+
function exportedFromInternal(graph, leak) {
|
|
16
|
+
const source = graph.program.getSourceFile(leak.internalFile);
|
|
17
|
+
const symbol = source === undefined ? undefined : graph.checker.getSymbolAtLocation(source);
|
|
18
|
+
return symbol !== undefined && graph.checker.getExportsOfModule(symbol).some(entry => entry.name === leak.internalType);
|
|
19
|
+
}
|
|
20
|
+
export async function fix(projectRoot, file, dryRun = false) {
|
|
21
|
+
const result = { fixed: [], planned: [], unfixable: [], reverted: [] };
|
|
22
|
+
const focus = file === undefined ? undefined : resolve(projectRoot, file);
|
|
23
|
+
if (focus !== undefined && !existsSync(focus))
|
|
24
|
+
return result;
|
|
25
|
+
const config = await loadConfig(resolve(projectRoot, "archstrict.config.ts"));
|
|
26
|
+
const configLocator = createConfigLocator(config);
|
|
27
|
+
// loadConfig already guarantees declaredModules is a well-shaped array
|
|
28
|
+
// (assertDeclaredModulesShapeValid) - see check.ts's own comment.
|
|
29
|
+
const options = { projectRoot, declaredModules: config.declaredModules, exclude: config.exclude, surface: config.surface };
|
|
30
|
+
const warm = createWarmGraph();
|
|
31
|
+
let graph = warm.refresh(options);
|
|
32
|
+
// fingerprintOf needs a project-relative path (see todo-store.ts's own
|
|
33
|
+
// relativizeForTodo) - a live violation's own `path`/`target` is always
|
|
34
|
+
// absolute. Reads `graph` fresh on every call (not captured once): the
|
|
35
|
+
// graph is reassigned after each write below, but `options.projectRoot`
|
|
36
|
+
// never changes, so relativePath's own output stays consistent across
|
|
37
|
+
// the reassignment.
|
|
38
|
+
const keyOf = (v) => fingerprintOf(relativizeForTodo(v, graph.relativePath));
|
|
39
|
+
const notesSeen = new Set();
|
|
40
|
+
const evaluate = () => {
|
|
41
|
+
const evaluated = applyTodo(graph, config, runRules(graph, config, { configLocator }), { configLocator });
|
|
42
|
+
for (const note of graph.programNotes)
|
|
43
|
+
notesSeen.add(note);
|
|
44
|
+
return evaluated;
|
|
45
|
+
};
|
|
46
|
+
const baseline = evaluate();
|
|
47
|
+
const baselineKeys = new Set(baseline.violations.map(keyOf));
|
|
48
|
+
const scoped = focus === undefined ? baseline : filterToFile(baseline, focus);
|
|
49
|
+
const files = new Map();
|
|
50
|
+
for (const violation of scoped.violations) {
|
|
51
|
+
if (violation.rule !== "type-leak")
|
|
52
|
+
continue;
|
|
53
|
+
const group = files.get(violation.path) ?? [];
|
|
54
|
+
group.push(violation);
|
|
55
|
+
files.set(violation.path, group);
|
|
56
|
+
}
|
|
57
|
+
const prepared = prepareGraph(options);
|
|
58
|
+
const host = ts.createCompilerHost(prepared.compilerOptions);
|
|
59
|
+
for (const [path, leaks] of files) {
|
|
60
|
+
const declarations = new Map();
|
|
61
|
+
for (const { leak } of leaks) {
|
|
62
|
+
if (leak === undefined)
|
|
63
|
+
continue;
|
|
64
|
+
const sources = declarations.get(leak.internalType) ?? new Set();
|
|
65
|
+
sources.add(leak.internalFile);
|
|
66
|
+
declarations.set(leak.internalType, sources);
|
|
67
|
+
}
|
|
68
|
+
// Rule 6 checks exported names, so one re-export can make two distinct declarations with the same name appear fixed.
|
|
69
|
+
// Consumers of the other declaration would receive the wrong type. A partial fix cannot resolve which declaration should own the name.
|
|
70
|
+
// Leave the whole file unchanged until a human resolves the collision, including the otherwise fixable leaks.
|
|
71
|
+
const collisions = [...declarations].filter(([, sources]) => sources.size > 1);
|
|
72
|
+
if (collisions.length > 0) {
|
|
73
|
+
const reason = collisions.map(([name, sources]) => `name collision for '${name}' in ${[...sources].sort().join(", ")}; rename by hand`).join("; ");
|
|
74
|
+
for (const violation of leaks)
|
|
75
|
+
result.unfixable.push({ path, type: violation.leak?.internalType ?? "unknown", reason });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const groups = new Map();
|
|
79
|
+
const targeted = new Set();
|
|
80
|
+
for (const violation of leaks) {
|
|
81
|
+
const leak = violation.leak;
|
|
82
|
+
if (leak === undefined) {
|
|
83
|
+
result.unfixable.push({ path, type: "unknown", reason: "structured leak information is unavailable" });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!exportedFromInternal(graph, leak)) {
|
|
87
|
+
result.unfixable.push({ path, type: leak.internalType,
|
|
88
|
+
reason: `the internal declaration itself is not exported from its own file '${leak.internalFile}' - add export to its declaration first, or fix by hand` });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const rel = relative(dirname(path), leak.internalFile).split(sep).join("/").replace(/(?:\.d)?\.[cm]?tsx?$/, "");
|
|
92
|
+
const base = rel.startsWith("../") ? rel : `./${rel}`;
|
|
93
|
+
// A formatted relative path can still fail under the compiler's extension and module resolution rules.
|
|
94
|
+
// Require the real resolver to confirm the exact internal file before emitting a specifier that only appears correct.
|
|
95
|
+
const specifier = [`${base}.js`, base, `${base}.ts`].find(candidate => ts.resolveModuleName(candidate, path, prepared.compilerOptionsForFile(path), host).resolvedModule?.resolvedFileName === leak.internalFile);
|
|
96
|
+
if (specifier === undefined) {
|
|
97
|
+
result.unfixable.push({ path, type: leak.internalType, reason: `no candidate specifier resolves to '${leak.internalFile}'` });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const names = groups.get(specifier) ?? new Set();
|
|
101
|
+
names.add(leak.internalType);
|
|
102
|
+
groups.set(specifier, names);
|
|
103
|
+
targeted.add(keyOf(violation));
|
|
104
|
+
}
|
|
105
|
+
const lines = [...groups.keys()].sort().map(specifier => `export type { ${[...groups.get(specifier)].sort().join(", ")} } from ${JSON.stringify(specifier)};`);
|
|
106
|
+
if (lines.length === 0)
|
|
107
|
+
continue;
|
|
108
|
+
if (dryRun) {
|
|
109
|
+
result.planned.push({ path, lines });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// A re-export can remove a type leak while crossing a module boundary that the project's tag rules forbid.
|
|
113
|
+
// Compare the full violation set with baselineKeys: the regression can appear elsewhere, even when every targeted leak disappears.
|
|
114
|
+
// Restore the original bytes on failure so a locally successful type fix cannot leave a new architecture violation behind.
|
|
115
|
+
const target = resolveWriteTarget(path);
|
|
116
|
+
const original = readFileSync(target);
|
|
117
|
+
const firstNewline = original.indexOf(10);
|
|
118
|
+
const newline = firstNewline > 0 && original[firstNewline - 1] === 13 ? "\r\n" : "\n";
|
|
119
|
+
const separator = original.length > 0 && original[original.length - 1] !== 10 ? newline : "";
|
|
120
|
+
const updated = Buffer.concat([original, Buffer.from(separator + lines.join(newline) + newline)]);
|
|
121
|
+
let failure;
|
|
122
|
+
try {
|
|
123
|
+
writeTarget(target, updated);
|
|
124
|
+
graph = warm.refresh(options);
|
|
125
|
+
const after = evaluate();
|
|
126
|
+
if (after.violations.some(v => v.rule === "type-leak" && v.path === path && targeted.has(keyOf(v)))) {
|
|
127
|
+
failure = "verification still reports a targeted type leak";
|
|
128
|
+
}
|
|
129
|
+
else if (after.violations.some(v => !baselineKeys.has(keyOf(v)))) {
|
|
130
|
+
failure = "verification reports a new violation";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
failure = `verification or write failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
135
|
+
}
|
|
136
|
+
if (failure !== undefined) {
|
|
137
|
+
// A failure that blocks the first write can also block the revert at the same path.
|
|
138
|
+
// Preserve both failures so the report does not imply that restoration succeeded.
|
|
139
|
+
try {
|
|
140
|
+
writeTarget(target, original);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
failure += `; revert failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
144
|
+
}
|
|
145
|
+
graph = warm.refresh(options);
|
|
146
|
+
result.reverted.push({ path, reason: failure });
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
result.fixed.push({ path, lines });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (notesSeen.size > 0)
|
|
153
|
+
result.notes = [...notesSeen];
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
export function formatFixText(result) {
|
|
157
|
+
const lines = [];
|
|
158
|
+
for (const category of ["fixed", "planned"]) {
|
|
159
|
+
for (const entry of result[category])
|
|
160
|
+
lines.push(`${category}: ${entry.path}`, ...entry.lines);
|
|
161
|
+
}
|
|
162
|
+
for (const entry of result.unfixable)
|
|
163
|
+
lines.push(`unfixable: ${entry.path} (${entry.type}): ${entry.reason}`);
|
|
164
|
+
for (const entry of result.reverted)
|
|
165
|
+
lines.push(`reverted: ${entry.path}: ${entry.reason}`);
|
|
166
|
+
lines.push(`fixed: ${result.fixed.length}; planned: ${result.planned.length}; unfixable: ${result.unfixable.length}; reverted: ${result.reverted.length}`);
|
|
167
|
+
for (const note of result.notes ?? [])
|
|
168
|
+
lines.push(`note: ${note}`);
|
|
169
|
+
return lines.join("\n") + "\n";
|
|
170
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Responsibility: combine Git change history with the current module graph and debt.
|
|
2
|
+
// Boundary: this verb reports evidence; it does not choose or apply a refactor.
|
|
3
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { compileGlob } from "../classify.js";
|
|
8
|
+
import { buildModuleGraphForRules, moduleForDeclaredFile } from "../module-graph.js";
|
|
9
|
+
import { applyTodo, loadConfig, readCurrentTodo, runRules } from "./check.js";
|
|
10
|
+
function pairKey(a, b) {
|
|
11
|
+
return a < b ? `${a}\0${b}` : `${b}\0${a}`;
|
|
12
|
+
}
|
|
13
|
+
export function coChangeCount(commits, a, b) {
|
|
14
|
+
let count = 0;
|
|
15
|
+
for (const commit of commits) {
|
|
16
|
+
const modules = new Set(commit);
|
|
17
|
+
if (modules.has(a) && modules.has(b))
|
|
18
|
+
count++;
|
|
19
|
+
}
|
|
20
|
+
return count;
|
|
21
|
+
}
|
|
22
|
+
export function summarizeCommitHistory(moduleNames, commits) {
|
|
23
|
+
const commitsByModule = new Map(moduleNames.map((name) => [name, 0]));
|
|
24
|
+
const linesByModule = new Map(moduleNames.map((name) => [name, 0]));
|
|
25
|
+
const pairCounts = new Map();
|
|
26
|
+
for (const commit of commits) {
|
|
27
|
+
const names = [...commit.modules].sort();
|
|
28
|
+
for (const name of names) {
|
|
29
|
+
commitsByModule.set(name, (commitsByModule.get(name) ?? 0) + 1);
|
|
30
|
+
linesByModule.set(name, (linesByModule.get(name) ?? 0) + (commit.linesByModule.get(name) ?? 0));
|
|
31
|
+
}
|
|
32
|
+
for (let i = 0; i < names.length; i++) {
|
|
33
|
+
for (let j = i + 1; j < names.length; j++) {
|
|
34
|
+
const key = pairKey(names[i], names[j]);
|
|
35
|
+
pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const pairs = [...pairCounts.entries()].map(([key, coChanges]) => {
|
|
40
|
+
const [moduleA, moduleB] = key.split("\0");
|
|
41
|
+
return {
|
|
42
|
+
moduleA,
|
|
43
|
+
moduleB,
|
|
44
|
+
coChanges,
|
|
45
|
+
shareOfA: coChanges / commitsByModule.get(moduleA),
|
|
46
|
+
shareOfB: coChanges / commitsByModule.get(moduleB),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
return { commitsByModule, linesByModule, pairs };
|
|
50
|
+
}
|
|
51
|
+
function countRules(rules) {
|
|
52
|
+
const counts = {};
|
|
53
|
+
for (const rule of rules)
|
|
54
|
+
counts[rule] = (counts[rule] ?? 0) + 1;
|
|
55
|
+
return Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)));
|
|
56
|
+
}
|
|
57
|
+
function graphBoundaries(graph) {
|
|
58
|
+
const boundaries = new Set();
|
|
59
|
+
for (const edge of graph.crossModuleEdges) {
|
|
60
|
+
if (edge.toModule !== undefined)
|
|
61
|
+
boundaries.add(pairKey(edge.fromModule, edge.toModule));
|
|
62
|
+
}
|
|
63
|
+
return boundaries;
|
|
64
|
+
}
|
|
65
|
+
function fanByModule(graph) {
|
|
66
|
+
const incoming = new Map();
|
|
67
|
+
const outgoing = new Map();
|
|
68
|
+
for (const name of graph.modules.keys()) {
|
|
69
|
+
incoming.set(name, new Set());
|
|
70
|
+
outgoing.set(name, new Set());
|
|
71
|
+
}
|
|
72
|
+
for (const edge of graph.crossModuleEdges) {
|
|
73
|
+
if (edge.toModule === undefined)
|
|
74
|
+
continue;
|
|
75
|
+
outgoing.get(edge.fromModule)?.add(edge.toModule);
|
|
76
|
+
incoming.get(edge.toModule)?.add(edge.fromModule);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
fanIn: new Map([...incoming].map(([name, modules]) => [name, modules.size])),
|
|
80
|
+
fanOut: new Map([...outgoing].map(([name, modules]) => [name, modules.size])),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function gitValue(root, args) {
|
|
84
|
+
try {
|
|
85
|
+
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function isAnalyzedSource(path) {
|
|
92
|
+
return /\.(?:ts|tsx|mts|cts)$/.test(path) && !/\.d\.(?:ts|mts|cts)$/.test(path);
|
|
93
|
+
}
|
|
94
|
+
async function readGitHistory(projectRoot, since, moduleForPath) {
|
|
95
|
+
if (gitValue(projectRoot, ["rev-parse", "--is-inside-work-tree"]) !== "true") {
|
|
96
|
+
return { available: false, shallow: false, commits: [], note: "Git history is unavailable because this project is not in a Git repository." };
|
|
97
|
+
}
|
|
98
|
+
const shallow = gitValue(projectRoot, ["rev-parse", "--is-shallow-repository"]) === "true";
|
|
99
|
+
const prefix = gitValue(projectRoot, ["rev-parse", "--show-prefix"]) ?? "";
|
|
100
|
+
const args = ["log", "--numstat", "--no-renames", "--format=%x1e%H"];
|
|
101
|
+
if (since !== undefined) {
|
|
102
|
+
const isRef = gitValue(projectRoot, ["rev-parse", "--verify", "--quiet", `${since}^{commit}`]) !== undefined;
|
|
103
|
+
args.push(isRef ? `${since}..HEAD` : `--since=${since}`);
|
|
104
|
+
}
|
|
105
|
+
args.push("--", ".");
|
|
106
|
+
const child = spawn("git", args, { cwd: projectRoot, stdio: ["ignore", "pipe", "pipe"] });
|
|
107
|
+
const stderr = [];
|
|
108
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
109
|
+
const commits = [];
|
|
110
|
+
let current;
|
|
111
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
112
|
+
for await (const line of lines) {
|
|
113
|
+
if (line.startsWith("\u001e")) {
|
|
114
|
+
if (current !== undefined && current.modules.size > 0)
|
|
115
|
+
commits.push(current);
|
|
116
|
+
current = { modules: new Set(), linesByModule: new Map() };
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (current === undefined || line.length === 0)
|
|
120
|
+
continue;
|
|
121
|
+
const firstTab = line.indexOf("\t");
|
|
122
|
+
const secondTab = line.indexOf("\t", firstTab + 1);
|
|
123
|
+
if (firstTab === -1 || secondTab === -1)
|
|
124
|
+
continue;
|
|
125
|
+
let path = line.slice(secondTab + 1);
|
|
126
|
+
if (prefix !== "") {
|
|
127
|
+
if (!path.startsWith(prefix))
|
|
128
|
+
continue;
|
|
129
|
+
path = path.slice(prefix.length);
|
|
130
|
+
}
|
|
131
|
+
const moduleName = moduleForPath(path);
|
|
132
|
+
if (moduleName === undefined)
|
|
133
|
+
continue;
|
|
134
|
+
const added = Number(line.slice(0, firstTab));
|
|
135
|
+
const deleted = Number(line.slice(firstTab + 1, secondTab));
|
|
136
|
+
const changed = Number.isFinite(added) && Number.isFinite(deleted) ? added + deleted : 0;
|
|
137
|
+
current.modules.add(moduleName);
|
|
138
|
+
current.linesByModule.set(moduleName, (current.linesByModule.get(moduleName) ?? 0) + changed);
|
|
139
|
+
}
|
|
140
|
+
if (current !== undefined && current.modules.size > 0)
|
|
141
|
+
commits.push(current);
|
|
142
|
+
const exitCode = await new Promise((accept) => child.once("close", accept));
|
|
143
|
+
if (exitCode !== 0)
|
|
144
|
+
throw new Error(Buffer.concat(stderr).toString("utf8").trim() || "git log failed");
|
|
145
|
+
const note = shallow
|
|
146
|
+
? "Git history is limited because this is a shallow clone."
|
|
147
|
+
: commits.length < 2
|
|
148
|
+
? "Git history has fewer than two relevant commits, so change coupling is limited."
|
|
149
|
+
: null;
|
|
150
|
+
return { available: true, shallow, commits, note };
|
|
151
|
+
}
|
|
152
|
+
export async function hotspots(projectRoot, since) {
|
|
153
|
+
const configPath = resolve(projectRoot, "archstrict.config.ts");
|
|
154
|
+
const config = await loadConfig(configPath, undefined, "archstrict hotspots");
|
|
155
|
+
config.configPath = realpathSync(configPath);
|
|
156
|
+
const graph = buildModuleGraphForRules({
|
|
157
|
+
projectRoot,
|
|
158
|
+
declaredModules: config.declaredModules,
|
|
159
|
+
exclude: config.exclude,
|
|
160
|
+
surface: config.surface,
|
|
161
|
+
});
|
|
162
|
+
const evaluated = runRules(graph, config, { afterTypeLeak: () => graph.releaseProgram() });
|
|
163
|
+
const active = applyTodo(graph, config, evaluated);
|
|
164
|
+
const activeByModule = new Map();
|
|
165
|
+
for (const violation of active.violations) {
|
|
166
|
+
if (!("todoModule" in violation) || typeof violation.todoModule !== "string")
|
|
167
|
+
continue;
|
|
168
|
+
const rules = activeByModule.get(violation.todoModule) ?? [];
|
|
169
|
+
rules.push(violation.rule);
|
|
170
|
+
activeByModule.set(violation.todoModule, rules);
|
|
171
|
+
}
|
|
172
|
+
const excluded = (config.exclude ?? []).map(compileGlob);
|
|
173
|
+
const moduleCache = new Map();
|
|
174
|
+
const moduleForPath = (path) => {
|
|
175
|
+
if (moduleCache.has(path))
|
|
176
|
+
return moduleCache.get(path);
|
|
177
|
+
let name;
|
|
178
|
+
if (isAnalyzedSource(path)
|
|
179
|
+
&& path !== "archstrict.types.ts"
|
|
180
|
+
&& !path.endsWith("archstrict.todo.json")
|
|
181
|
+
&& !excluded.some((glob) => glob.test(path))) {
|
|
182
|
+
name = moduleForDeclaredFile(resolve(projectRoot, path), projectRoot, config.declaredModules);
|
|
183
|
+
}
|
|
184
|
+
moduleCache.set(path, name);
|
|
185
|
+
return name;
|
|
186
|
+
};
|
|
187
|
+
const history = await readGitHistory(projectRoot, since, moduleForPath);
|
|
188
|
+
const summary = summarizeCommitHistory([...graph.modules.keys()], history.commits);
|
|
189
|
+
const fan = fanByModule(graph);
|
|
190
|
+
const boundaries = graphBoundaries(graph);
|
|
191
|
+
const currentTodo = readCurrentTodo(graph);
|
|
192
|
+
const modules = [...graph.modules.values()].map((module) => {
|
|
193
|
+
const commits = summary.commitsByModule.get(module.name) ?? 0;
|
|
194
|
+
const fanIn = fan.fanIn.get(module.name) ?? 0;
|
|
195
|
+
return {
|
|
196
|
+
name: module.name,
|
|
197
|
+
commits,
|
|
198
|
+
changedLines: summary.linesByModule.get(module.name) ?? 0,
|
|
199
|
+
fanIn,
|
|
200
|
+
fanOut: fan.fanOut.get(module.name) ?? 0,
|
|
201
|
+
frozenDebtByRule: countRules((currentTodo.entriesByModule.get(module.name) ?? []).map((entry) => entry.rule)),
|
|
202
|
+
activeViolationsByRule: countRules(activeByModule.get(module.name) ?? []),
|
|
203
|
+
score: commits * fanIn,
|
|
204
|
+
// `--module` scopes the whole rule set to one module; a single file
|
|
205
|
+
// inside it (the earlier form) is not a module-level drill-down and
|
|
206
|
+
// can miss the violations that made the module a hotspot. `--frozen`
|
|
207
|
+
// surfaces that module's todo-matched debt alongside its live
|
|
208
|
+
// violations, instead of sending the reader to its todo JSON by hand.
|
|
209
|
+
do: `archstrict check --frozen --module ${module.name}`,
|
|
210
|
+
};
|
|
211
|
+
}).sort((a, b) => b.score - a.score || b.commits - a.commits || a.name.localeCompare(b.name));
|
|
212
|
+
const pairs = summary.pairs.map((pair) => {
|
|
213
|
+
const boundary = boundaries.has(pairKey(pair.moduleA, pair.moduleB));
|
|
214
|
+
return { ...pair, boundary, hotspot: boundary && (pair.shareOfA >= 0.5 || pair.shareOfB >= 0.5) };
|
|
215
|
+
}).sort((a, b) => b.coChanges - a.coChanges || a.moduleA.localeCompare(b.moduleA) || a.moduleB.localeCompare(b.moduleB));
|
|
216
|
+
return {
|
|
217
|
+
history: { available: history.available, shallow: history.shallow, commits: history.commits.length, since: since ?? null, note: history.note },
|
|
218
|
+
units: { commits: "commits", changedLines: "added plus deleted lines", shares: "fraction of module commits" },
|
|
219
|
+
exclusions: ["paths outside analysis", "archstrict todo files", "archstrict.types.ts"],
|
|
220
|
+
modules,
|
|
221
|
+
pairs,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function percentage(share) {
|
|
225
|
+
return `${Math.round(share * 100)}%`;
|
|
226
|
+
}
|
|
227
|
+
function ruleCountsText(counts) {
|
|
228
|
+
const entries = Object.entries(counts);
|
|
229
|
+
return entries.length === 0 ? "none" : entries.map(([rule, count]) => `${rule}=${count}`).join(", ");
|
|
230
|
+
}
|
|
231
|
+
export function formatHotspotsText(result) {
|
|
232
|
+
const lines = [
|
|
233
|
+
"Hotspot modules (top 10)",
|
|
234
|
+
"Read this as: score combines change frequency with the number of modules that depend on a module.",
|
|
235
|
+
];
|
|
236
|
+
if (result.history.note !== null)
|
|
237
|
+
lines.push(`History: ${result.history.note}`);
|
|
238
|
+
for (const module of result.modules.slice(0, 10)) {
|
|
239
|
+
lines.push(` ${module.name}: score ${module.score}; ${module.commits} commits; ${module.changedLines} changed lines; fan-in ${module.fanIn}; fan-out ${module.fanOut}`);
|
|
240
|
+
lines.push(` frozen debt: ${ruleCountsText(module.frozenDebtByRule)}; active: ${ruleCountsText(module.activeViolationsByRule)}`);
|
|
241
|
+
}
|
|
242
|
+
lines.push("", "Co-change pairs (top 10)");
|
|
243
|
+
lines.push("Read this as: a boundary hotspot changes together in at least half of either module's commits and has a current dependency edge.");
|
|
244
|
+
for (const pair of result.pairs.slice(0, 10)) {
|
|
245
|
+
const marker = pair.hotspot ? "boundary hotspot" : pair.boundary ? "boundary" : "no boundary edge";
|
|
246
|
+
lines.push(` ${pair.moduleA} <-> ${pair.moduleB}: ${pair.coChanges} commits; ${percentage(pair.shareOfA)} of ${pair.moduleA}; ${percentage(pair.shareOfB)} of ${pair.moduleB}; ${marker}`);
|
|
247
|
+
}
|
|
248
|
+
lines.push("", `Excluded from history: ${result.exclusions.join(", ")}.`);
|
|
249
|
+
// At most two lines: the top module (the score's own drill-down), and, when
|
|
250
|
+
// one exists, the top boundary-hotspot pair. `check --frozen` now reads a
|
|
251
|
+
// pair's frozen debt through the same rule/module filters as a live
|
|
252
|
+
// violation, so the drill-down names that command instead of the todo
|
|
253
|
+
// JSON files it used to send the reader to open by hand.
|
|
254
|
+
if (result.modules.length > 0)
|
|
255
|
+
lines.push(`do: ${result.modules[0].do}`);
|
|
256
|
+
const pair = result.pairs.find((candidate) => candidate.hotspot);
|
|
257
|
+
if (pair !== undefined) {
|
|
258
|
+
lines.push(`do: read node_modules/archstrict/skills/archstrict/references/rearchitect.md, then run archstrict check --frozen --module ${pair.moduleA} and archstrict check --frozen --module ${pair.moduleB}`);
|
|
259
|
+
}
|
|
260
|
+
return `${lines.join("\n")}\n`;
|
|
261
|
+
}
|