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,188 @@
|
|
|
1
|
+
// Responsibility: describe a current or proposed path using the project's config.
|
|
2
|
+
// Boundary: projects source-side constraints; actual target evaluation remains with check.
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { classifyFile, compileGlob } from "../classify.js";
|
|
6
|
+
import { buildModuleGraphForRules, DEFAULT_SURFACE, moduleForDeclaredFile, surfaceGlobsFor, toProjectRelativePosix } from "../module-graph.js";
|
|
7
|
+
import { checkMustBeEmpty } from "../rules/must-be-empty.js";
|
|
8
|
+
import { uncoveredViolationFor } from "../rules/uncovered.js";
|
|
9
|
+
import { groupForRelFile, suggestUncovered } from "../module-candidates.js";
|
|
10
|
+
import { assertSequenceListsValue, formatPredicate, matchesPredicate, sequenceFor } from "../rules/constraints.js";
|
|
11
|
+
import { formatConfigPointerLines, loadConfig } from "./check.js";
|
|
12
|
+
import { createConfigLocator, locateViolation } from "../config-pointer.js";
|
|
13
|
+
// The graph uses real paths. Resolve existing ancestors so new paths also
|
|
14
|
+
// agree with it through directory symlinks, including macOS temporary roots.
|
|
15
|
+
function canonicalPath(path) {
|
|
16
|
+
const missing = [];
|
|
17
|
+
let ancestor = path;
|
|
18
|
+
while (!existsSync(ancestor)) {
|
|
19
|
+
const parent = dirname(ancestor);
|
|
20
|
+
missing.unshift(relative(parent, ancestor));
|
|
21
|
+
ancestor = parent;
|
|
22
|
+
}
|
|
23
|
+
return join(realpathSync(ancestor), ...missing);
|
|
24
|
+
}
|
|
25
|
+
// Shares module-candidates.ts's grouping/naming with check's own
|
|
26
|
+
// checkUncoveredModules, so a queried path's `do:` names the identical
|
|
27
|
+
// entry check would report for that same file (rules.test.ts checks this
|
|
28
|
+
// directly). A planned (not yet existing) path was never in
|
|
29
|
+
// graph.outsideFiles, so it's appended to the file list groupAnalyzedFiles
|
|
30
|
+
// sees - otherwise a lone planned file in an empty directory would be
|
|
31
|
+
// missing from its own group entirely.
|
|
32
|
+
function uncoveredViolationForQuery(resolvedPath, rel, exists, module, excluded, graph, config) {
|
|
33
|
+
if (excluded)
|
|
34
|
+
return undefined;
|
|
35
|
+
const isUncovered = exists ? graph.outsideFiles.includes(resolvedPath) : module === undefined;
|
|
36
|
+
if (!isUncovered)
|
|
37
|
+
return undefined;
|
|
38
|
+
const outsideRelFiles = graph.outsideFiles.map(graph.relativePath);
|
|
39
|
+
const relFiles = exists ? outsideRelFiles : [...outsideRelFiles, rel];
|
|
40
|
+
const groups = suggestUncovered(relFiles, config.declaredModules ?? []);
|
|
41
|
+
return uncoveredViolationFor(resolvedPath, graph.rootDir, groupForRelFile(rel, groups));
|
|
42
|
+
}
|
|
43
|
+
export async function rules(projectRoot, path) {
|
|
44
|
+
const root = realpathSync(projectRoot);
|
|
45
|
+
const resolvedPath = canonicalPath(resolve(path));
|
|
46
|
+
const rel = toProjectRelativePosix(resolvedPath, root);
|
|
47
|
+
if (rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
|
|
48
|
+
throw new Error(`rules ${path}: path is outside project root '${root}'`);
|
|
49
|
+
}
|
|
50
|
+
const config = await loadConfig(resolve(root, "archstrict.config.ts"));
|
|
51
|
+
const configLocator = createConfigLocator(config);
|
|
52
|
+
const graph = buildModuleGraphForRules({ projectRoot: root, declaredModules: config.declaredModules, exclude: config.exclude, surface: config.surface });
|
|
53
|
+
const exists = existsSync(resolvedPath);
|
|
54
|
+
const excluded = (config.exclude ?? []).some((glob) => compileGlob(glob).test(rel));
|
|
55
|
+
let module;
|
|
56
|
+
let isSurfaceFile = false;
|
|
57
|
+
if (exists) {
|
|
58
|
+
const owner = [...graph.modules.values()].find((m) => m.files.includes(resolvedPath));
|
|
59
|
+
module = owner?.name;
|
|
60
|
+
isSurfaceFile = owner?.surfaceFiles.includes(resolvedPath) ?? false;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
module = moduleForDeclaredFile(resolvedPath, root, config.declaredModules ?? []);
|
|
64
|
+
const declaration = config.declaredModules?.find((dm) => dm.name === module);
|
|
65
|
+
if (declaration !== undefined) {
|
|
66
|
+
isSurfaceFile = surfaceGlobsFor(declaration, root, config.surface ?? DEFAULT_SURFACE)
|
|
67
|
+
.some((glob) => compileGlob(glob).test(rel));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const modules = [...graph.modules.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
71
|
+
const sourceTags = classifyFile(rel, config);
|
|
72
|
+
const tags = [...sourceTags].sort();
|
|
73
|
+
const allowDenyConstraints = (config.edges?.allowDeny ?? [])
|
|
74
|
+
.filter((rule) => tags.includes(rule.source))
|
|
75
|
+
.map((rule) => ({
|
|
76
|
+
source: rule.source,
|
|
77
|
+
targetNamespace: rule.targetNamespace,
|
|
78
|
+
allow: rule.allow ? [...rule.allow] : undefined,
|
|
79
|
+
deny: rule.deny ? [...rule.deny] : undefined,
|
|
80
|
+
sameGroupExempt: true,
|
|
81
|
+
exceptionsFromP: (rule.exceptions ?? []).filter((ex) => compileGlob(ex.from).test(rel))
|
|
82
|
+
.map((ex) => ({ to: ex.to, because: ex.because })),
|
|
83
|
+
edgeType: rule.edgeType ?? "both",
|
|
84
|
+
importForm: rule.importForm ?? "both",
|
|
85
|
+
because: rule.because,
|
|
86
|
+
}));
|
|
87
|
+
const orderConstraints = [];
|
|
88
|
+
for (const rule of config.edges?.order ?? []) {
|
|
89
|
+
const ownLayerTag = [...sourceTags].find((tag) => tag.startsWith(`${rule.tagNamespace}:`));
|
|
90
|
+
if (ownLayerTag === undefined)
|
|
91
|
+
continue;
|
|
92
|
+
let withinValue;
|
|
93
|
+
if (rule.within !== undefined) {
|
|
94
|
+
const withinTag = [...sourceTags].find((tag) => tag.startsWith(`${rule.within}:`));
|
|
95
|
+
if (withinTag === undefined)
|
|
96
|
+
continue;
|
|
97
|
+
withinValue = withinTag.slice(rule.within.length + 1);
|
|
98
|
+
}
|
|
99
|
+
const sequence = sequenceFor(rule, withinValue);
|
|
100
|
+
if (sequence === undefined)
|
|
101
|
+
continue;
|
|
102
|
+
// Detect the same configuration error as check before an edge exists:
|
|
103
|
+
// the queried path's own layer must already be placed in its sequence.
|
|
104
|
+
assertSequenceListsValue(rule, withinValue, sequence, ownLayerTag);
|
|
105
|
+
const ownLayer = ownLayerTag.slice(rule.tagNamespace.length + 1);
|
|
106
|
+
const idx = sequence.indexOf(ownLayer);
|
|
107
|
+
orderConstraints.push({
|
|
108
|
+
tagNamespace: rule.tagNamespace,
|
|
109
|
+
within: rule.within,
|
|
110
|
+
ownLayer,
|
|
111
|
+
sequence: [...sequence],
|
|
112
|
+
// computeOrder permits targetIndex <= sourceIndex (downward-only),
|
|
113
|
+
// so this prefix includes the queried path's own layer.
|
|
114
|
+
mayDependOn: sequence.slice(0, idx + 1),
|
|
115
|
+
edgeType: rule.edgeType ?? "both",
|
|
116
|
+
importForm: rule.importForm ?? "both",
|
|
117
|
+
because: rule.because,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const pointConstraints = (config.edges?.point ?? [])
|
|
121
|
+
.filter((rule) => matchesPredicate(rule.from, rel, new Set(tags)))
|
|
122
|
+
.map((rule) => ({
|
|
123
|
+
identifier: `${formatPredicate(rule.from)} -> ${formatPredicate(rule.to)}`,
|
|
124
|
+
forbiddenTo: formatPredicate(rule.to),
|
|
125
|
+
edgeType: rule.edgeType ?? "both",
|
|
126
|
+
importForm: rule.importForm ?? "both",
|
|
127
|
+
because: rule.because,
|
|
128
|
+
}));
|
|
129
|
+
const mustBeEmptyViolation = excluded ? undefined : checkMustBeEmpty([rel], config)[0];
|
|
130
|
+
const uncoveredViolation = uncoveredViolationForQuery(resolvedPath, rel, exists, module, excluded, graph, config);
|
|
131
|
+
return {
|
|
132
|
+
allowDenyConstraints,
|
|
133
|
+
orderConstraints,
|
|
134
|
+
pointConstraints,
|
|
135
|
+
path: resolvedPath,
|
|
136
|
+
exists,
|
|
137
|
+
excluded,
|
|
138
|
+
module,
|
|
139
|
+
tags,
|
|
140
|
+
isSurfaceFile,
|
|
141
|
+
importableFrom: modules.filter((m) => m.name !== module && m.surfaceFiles.length > 0)
|
|
142
|
+
.map((m) => ({ module: m.name, surfaceFiles: m.surfaceFiles })),
|
|
143
|
+
friendAccess: modules.flatMap((m) => m.friends
|
|
144
|
+
.filter((friend) => compileGlob(friend.from).test(rel))
|
|
145
|
+
.map((friend) => ({ module: m.name, file: friend.fileGlob, from: friend.from, because: friend.because }))),
|
|
146
|
+
mustBeEmptyViolation: mustBeEmptyViolation === undefined
|
|
147
|
+
? undefined
|
|
148
|
+
: locateViolation(mustBeEmptyViolation, config, configLocator),
|
|
149
|
+
uncoveredViolation: uncoveredViolation === undefined
|
|
150
|
+
? undefined
|
|
151
|
+
: locateViolation(uncoveredViolation, config, configLocator),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
export function formatRulesText(result) {
|
|
155
|
+
const lines = [];
|
|
156
|
+
if (result.excluded)
|
|
157
|
+
lines.push("excluded - out of scope, none of the following apply");
|
|
158
|
+
lines.push(`path: ${result.path}`, `exists: ${result.exists}`, `excluded: ${result.excluded ? "yes" : "no"}`, `module: ${result.module ?? "(none)"}`, `tags: ${result.tags.length > 0 ? result.tags.join(", ") : "(none)"}`, `surface file: ${result.isSurfaceFile ? "yes" : "no"}`, `must-be-empty: ${result.excluded ? "out of scope" : result.mustBeEmptyViolation ? "violation" : "ok"}`);
|
|
159
|
+
for (const violation of [result.mustBeEmptyViolation, result.uncoveredViolation]) {
|
|
160
|
+
if (violation === undefined)
|
|
161
|
+
continue;
|
|
162
|
+
lines.push(`[${violation.rule}] ${violation.path}:${violation.line}:${violation.column}`, ` evidence: ${violation.evidence}`, ` because: ${violation.because}`);
|
|
163
|
+
lines.push(...formatConfigPointerLines(violation.config));
|
|
164
|
+
lines.push(` do: ${violation.do}`);
|
|
165
|
+
}
|
|
166
|
+
lines.push(`importable from:${result.importableFrom.length === 0 ? " (none)" : ""}`);
|
|
167
|
+
for (const entry of result.importableFrom) {
|
|
168
|
+
for (const file of entry.surfaceFiles)
|
|
169
|
+
lines.push(` ${entry.module} -> ${file}`);
|
|
170
|
+
}
|
|
171
|
+
lines.push(`friend access:${result.friendAccess.length === 0 ? " (none)" : ""}`);
|
|
172
|
+
for (const entry of result.friendAccess) {
|
|
173
|
+
lines.push(` ${entry.module} -> ${entry.file}`, ` from: ${entry.from}`, ` because: ${entry.because}`);
|
|
174
|
+
}
|
|
175
|
+
lines.push(`allowDeny constraints:${result.allowDenyConstraints.length === 0 ? " (none)" : ""}`);
|
|
176
|
+
for (const entry of result.allowDenyConstraints) {
|
|
177
|
+
lines.push(` source: ${entry.source}`, ` target namespace: ${entry.targetNamespace}`, ` allow: ${entry.allow === undefined ? "(unset)" : JSON.stringify(entry.allow)}`, ` deny: ${entry.deny === undefined ? "(unset)" : JSON.stringify(entry.deny)}`, ` same group exempt: ${entry.sameGroupExempt}`, ` exceptions from path: ${entry.exceptionsFromP.length === 0 ? "(none)" : JSON.stringify(entry.exceptionsFromP)}`, ` edge type: ${entry.edgeType}`, ` import form: ${entry.importForm}`, ` because: ${entry.because}`);
|
|
178
|
+
}
|
|
179
|
+
lines.push(`order constraints:${result.orderConstraints.length === 0 ? " (none)" : ""}`);
|
|
180
|
+
for (const entry of result.orderConstraints) {
|
|
181
|
+
lines.push(` tag namespace: ${entry.tagNamespace}`, ` within: ${entry.within ?? "(unscoped)"}`, ` own layer: ${entry.ownLayer}`, ` sequence: ${JSON.stringify(entry.sequence)}`, ` may depend on: ${JSON.stringify(entry.mayDependOn)}`, ` edge type: ${entry.edgeType}`, ` import form: ${entry.importForm}`, ` because: ${entry.because}`);
|
|
182
|
+
}
|
|
183
|
+
lines.push(`point constraints:${result.pointConstraints.length === 0 ? " (none)" : ""}`);
|
|
184
|
+
for (const entry of result.pointConstraints) {
|
|
185
|
+
lines.push(` identifier: ${entry.identifier}`, ` forbidden to: ${entry.forbiddenTo}`, ` edge type: ${entry.edgeType}`, ` import form: ${entry.importForm}`, ` because: ${entry.because}`);
|
|
186
|
+
}
|
|
187
|
+
return lines.join("\n") + "\n";
|
|
188
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Responsibility: rank public surface exports by name-token overlap with a query.
|
|
2
|
+
// Boundary: reads declared modules; does not search private declarations or change project files.
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import { buildModuleGraphForRules } from "../module-graph.js";
|
|
6
|
+
import { loadConfig } from "./check.js";
|
|
7
|
+
// Punctuation alone leaves JSONConfig as one token. These transitions separate
|
|
8
|
+
// lowercase from uppercase and an acronym from the next word without splitting its letters.
|
|
9
|
+
// Thus parseJSONConfig becomes ["parse", "json", "config"], not separate acronym letters.
|
|
10
|
+
export function tokenize(text) {
|
|
11
|
+
return text.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
12
|
+
.replace(/([A-Z])([A-Z][a-z])/g, "$1 $2")
|
|
13
|
+
.split(/[^a-zA-Z0-9]+/).filter(Boolean).map(token => token.toLowerCase());
|
|
14
|
+
}
|
|
15
|
+
function kindOf(symbol) {
|
|
16
|
+
if (symbol.flags & ts.SymbolFlags.Function)
|
|
17
|
+
return "function";
|
|
18
|
+
if (symbol.flags & ts.SymbolFlags.Class)
|
|
19
|
+
return "class";
|
|
20
|
+
if (symbol.flags & ts.SymbolFlags.Interface)
|
|
21
|
+
return "interface";
|
|
22
|
+
if (symbol.flags & ts.SymbolFlags.TypeAlias)
|
|
23
|
+
return "type-alias";
|
|
24
|
+
if (symbol.flags & ts.SymbolFlags.Enum)
|
|
25
|
+
return "enum";
|
|
26
|
+
if (symbol.flags & ts.SymbolFlags.Variable)
|
|
27
|
+
return "variable";
|
|
28
|
+
if (symbol.flags & ts.SymbolFlags.Module)
|
|
29
|
+
return "namespace";
|
|
30
|
+
return "other";
|
|
31
|
+
}
|
|
32
|
+
// Interfaces and type aliases have no value type; they need getDeclaredTypeOfSymbol.
|
|
33
|
+
// Enums also use their declared type here, rather than their runtime value type.
|
|
34
|
+
// Functions, classes, variables, and namespaces backed by source files need
|
|
35
|
+
// getTypeOfSymbolAtLocation to describe the exported value.
|
|
36
|
+
// A namespace Foo {} block has neither supported shape cheaply available here.
|
|
37
|
+
// An empty signature avoids a guess about that block's type.
|
|
38
|
+
function signatureOf(checker, target, kind) {
|
|
39
|
+
const declaration = target.declarations?.[0];
|
|
40
|
+
if (declaration === undefined)
|
|
41
|
+
return "";
|
|
42
|
+
if (kind === "interface" || kind === "type-alias" || kind === "enum") {
|
|
43
|
+
return checker.typeToString(checker.getDeclaredTypeOfSymbol(target));
|
|
44
|
+
}
|
|
45
|
+
if (kind === "function" || kind === "class" || kind === "variable" ||
|
|
46
|
+
(kind === "namespace" && ts.isSourceFile(declaration))) {
|
|
47
|
+
return checker.typeToString(checker.getTypeOfSymbolAtLocation(target, declaration));
|
|
48
|
+
}
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
export async function search(projectRoot, query) {
|
|
52
|
+
const queryTokens = tokenize(query);
|
|
53
|
+
if (queryTokens.length === 0)
|
|
54
|
+
throw new Error("archstrict search: query must contain at least one word");
|
|
55
|
+
const config = await loadConfig(resolve(projectRoot, "archstrict.config.ts"));
|
|
56
|
+
// loadConfig already guarantees declaredModules is a well-shaped array
|
|
57
|
+
// (assertDeclaredModulesShapeValid) - see check.ts's own comment.
|
|
58
|
+
const options = { projectRoot, declaredModules: config.declaredModules, exclude: config.exclude, surface: config.surface };
|
|
59
|
+
const graph = buildModuleGraphForRules(options);
|
|
60
|
+
const checker = graph.checker;
|
|
61
|
+
const matches = [];
|
|
62
|
+
for (const module of graph.modules.values()) {
|
|
63
|
+
for (const surfacePath of module.surfaceFiles) {
|
|
64
|
+
const sf = graph.program.getSourceFile(surfacePath);
|
|
65
|
+
if (sf === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
const moduleSymbol = checker.getSymbolAtLocation(sf);
|
|
68
|
+
if (moduleSymbol === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
for (const exportSymbol of checker.getExportsOfModule(moduleSymbol)) {
|
|
71
|
+
const name = exportSymbol.name;
|
|
72
|
+
const tokens = tokenize(name);
|
|
73
|
+
const score = queryTokens.filter(queryToken => tokens.some(token => token.includes(queryToken) || queryToken.includes(token))).length / queryTokens.length;
|
|
74
|
+
if (score === 0)
|
|
75
|
+
continue;
|
|
76
|
+
// Public surfaces commonly re-export names from internal files. getExportsOfModule
|
|
77
|
+
// returns Alias symbols for those names; their flags would give a generic kind.
|
|
78
|
+
// Resolve the underlying symbol first so the kind describes the actual export.
|
|
79
|
+
let target = exportSymbol;
|
|
80
|
+
if (target.flags & ts.SymbolFlags.Alias)
|
|
81
|
+
target = checker.getAliasedSymbol(target);
|
|
82
|
+
const kind = target.flags & ts.SymbolFlags.Alias ? "other" : kindOf(target);
|
|
83
|
+
// typeToString can expose an internal declaring path through typeof import("...")
|
|
84
|
+
// for export * as ns or a const that holds an imported module.
|
|
85
|
+
// Output must identify the module and its public surface, not that internal file.
|
|
86
|
+
// enclosingDeclaration was rejected: it makes the path relative but retains the reference.
|
|
87
|
+
// Apply replacement to every kind: a function's return object can contain the same type.
|
|
88
|
+
const signature = signatureOf(checker, target, kind)
|
|
89
|
+
.replace(/import\("[^"]*"(?:\s*,\s*\{[^)]*\})?\)/g, 'import("<module>")');
|
|
90
|
+
// The surface path gives an agent a legal import destination. The internal
|
|
91
|
+
// declaring path would invite the public-surface bypass that rule 1 rejects.
|
|
92
|
+
matches.push({ module: module.name, surface: graph.relativePath(surfacePath),
|
|
93
|
+
name, kind, signature, score });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
matches.sort((a, b) => b.score - a.score || a.module.localeCompare(b.module) || a.name.localeCompare(b.name));
|
|
98
|
+
// Search presents the best ranked matches with a visible total count.
|
|
99
|
+
// A top-K limit serves that query; recommend instead reports its whole candidate set.
|
|
100
|
+
const shown = matches.slice(0, 20);
|
|
101
|
+
const notes = graph.programNotes;
|
|
102
|
+
return { query, total: matches.length, shown: shown.length, matches: shown, ...(notes.length > 0 ? { notes: [...notes] } : {}) };
|
|
103
|
+
}
|
|
104
|
+
export function formatSearchText(result) {
|
|
105
|
+
return [`${result.total} matches for "${result.query}" (showing ${result.shown})`,
|
|
106
|
+
...result.matches.map(match => `${match.module} :: ${match.name} (${match.kind}) - ${match.signature} [score ${match.score.toFixed(2)}]`),
|
|
107
|
+
...(result.notes ?? []).map(note => `note: ${note}`),
|
|
108
|
+
].join("\n") + "\n";
|
|
109
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Responsibility: compare proposed source and config changes with the current project through the full rule pipeline.
|
|
2
|
+
// Boundary: all changes stay in memory; the baseline and archstrict.todo.json remain inputs from disk.
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import ts from "typescript";
|
|
6
|
+
import { buildModuleGraphForRules, DEFAULT_SURFACE, isEligibleSourceFile, isResolvableFile, prepareGraph } from "../module-graph.js";
|
|
7
|
+
import { fingerprintOf, relativizeForTodo } from "../todo-store.js";
|
|
8
|
+
import { applyTodo, formatText, loadConfig, runRules } from "./check.js";
|
|
9
|
+
import { createConfigLocator } from "../config-pointer.js";
|
|
10
|
+
// A proposed file can belong to a directory that does not exist yet.
|
|
11
|
+
// realpathSync would throw for that file or directory. Resolve only the
|
|
12
|
+
// nearest existing ancestor, then append the missing segments to preserve
|
|
13
|
+
// canonical paths through existing symlinks.
|
|
14
|
+
function canonicalChangePath(projectRoot, path) {
|
|
15
|
+
const absolute = resolve(projectRoot, path);
|
|
16
|
+
const missing = [basename(absolute)];
|
|
17
|
+
let ancestor = dirname(absolute);
|
|
18
|
+
while (!existsSync(ancestor)) {
|
|
19
|
+
missing.unshift(basename(ancestor));
|
|
20
|
+
const parent = dirname(ancestor);
|
|
21
|
+
if (parent === ancestor)
|
|
22
|
+
throw new Error(`cannot resolve change path: ${path}`);
|
|
23
|
+
ancestor = parent;
|
|
24
|
+
}
|
|
25
|
+
return join(realpathSync(ancestor), ...missing);
|
|
26
|
+
}
|
|
27
|
+
function overlayHost(options, changes) {
|
|
28
|
+
const host = ts.createCompilerHost(options);
|
|
29
|
+
const getSourceFile = host.getSourceFile.bind(host);
|
|
30
|
+
const readFile = host.readFile.bind(host);
|
|
31
|
+
const fileExists = host.fileExists.bind(host);
|
|
32
|
+
const directoryExists = host.directoryExists.bind(host);
|
|
33
|
+
const getDirectories = host.getDirectories.bind(host);
|
|
34
|
+
// Module resolution can probe directoryExists and getDirectories before
|
|
35
|
+
// it reads a file. File overrides alone cannot resolve an import into a
|
|
36
|
+
// new directory. Include the parents of each added or modified file so
|
|
37
|
+
// the compiler can reach files that exist only in the overlay.
|
|
38
|
+
const directories = new Map();
|
|
39
|
+
for (const [file, content] of changes) {
|
|
40
|
+
if (content === null)
|
|
41
|
+
continue;
|
|
42
|
+
let directory = dirname(file);
|
|
43
|
+
if (!directories.has(directory))
|
|
44
|
+
directories.set(directory, new Set());
|
|
45
|
+
while (dirname(directory) !== directory) {
|
|
46
|
+
const parent = dirname(directory);
|
|
47
|
+
if (!directories.has(parent))
|
|
48
|
+
directories.set(parent, new Set());
|
|
49
|
+
directories.get(parent).add(basename(directory));
|
|
50
|
+
directory = parent;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
host.getSourceFile = (file, languageVersion, onError, shouldCreateNewSourceFile) => {
|
|
54
|
+
const content = changes.get(resolve(file));
|
|
55
|
+
if (content === null)
|
|
56
|
+
return undefined;
|
|
57
|
+
if (content !== undefined)
|
|
58
|
+
return ts.createSourceFile(file, content, languageVersion);
|
|
59
|
+
return getSourceFile(file, languageVersion, onError, shouldCreateNewSourceFile);
|
|
60
|
+
};
|
|
61
|
+
host.readFile = file => {
|
|
62
|
+
const content = changes.get(resolve(file));
|
|
63
|
+
return content === null ? undefined : content ?? readFile(file);
|
|
64
|
+
};
|
|
65
|
+
host.fileExists = file => {
|
|
66
|
+
const content = changes.get(resolve(file));
|
|
67
|
+
return content === undefined ? fileExists(file) : content !== null;
|
|
68
|
+
};
|
|
69
|
+
host.directoryExists = directory => directories.has(resolve(directory)) || directoryExists(directory);
|
|
70
|
+
host.getDirectories = directory => [...new Set([
|
|
71
|
+
...(directoryExists(directory) ? getDirectories(directory) : []),
|
|
72
|
+
...(directories.get(resolve(directory)) ?? []),
|
|
73
|
+
])];
|
|
74
|
+
return host;
|
|
75
|
+
}
|
|
76
|
+
function surfaceModuleForPath(graph, path) {
|
|
77
|
+
for (const module of graph.modules.values()) {
|
|
78
|
+
if (module.surfaceFiles.includes(path))
|
|
79
|
+
return module.name;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
function evaluate(graph, config, locator, focusPaths) {
|
|
84
|
+
if (focusPaths === undefined) {
|
|
85
|
+
const result = applyTodo(graph, config, runRules(graph, config, { configLocator: locator }), { configLocator: locator });
|
|
86
|
+
graph.releaseProgram();
|
|
87
|
+
return result.violations;
|
|
88
|
+
}
|
|
89
|
+
const violations = new Map();
|
|
90
|
+
for (const focus of focusPaths) {
|
|
91
|
+
const focusedTypeLeakModule = surfaceModuleForPath(graph, focus);
|
|
92
|
+
const skipTypeLeak = focusedTypeLeakModule === undefined;
|
|
93
|
+
const result = applyTodo(graph, config, runRules(graph, config, { configLocator: locator, focus, focusedTypeLeakModule, skipTypeLeak }), { configLocator: locator, focus, skipStaleCheckForRules: skipTypeLeak ? ["type-leak"] : [] });
|
|
94
|
+
graph.releaseProgram();
|
|
95
|
+
// fingerprintOf needs a project-relative path (see todo-store.ts's own
|
|
96
|
+
// relativizeForTodo) - a live violation's own `path`/`target` is
|
|
97
|
+
// always absolute, so every key computed from one must relativize
|
|
98
|
+
// first, the same as todo.ts's freeze/prune and check.ts's own
|
|
99
|
+
// matching already do.
|
|
100
|
+
for (const violation of result.violations) {
|
|
101
|
+
violations.set(fingerprintOf(relativizeForTodo(violation, graph.relativePath)), violation);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...violations.values()];
|
|
105
|
+
}
|
|
106
|
+
export async function simulate(projectRoot, changes, simulateOptions = {}) {
|
|
107
|
+
projectRoot = realpathSync(projectRoot);
|
|
108
|
+
const configPath = canonicalChangePath(projectRoot, "archstrict.config.ts");
|
|
109
|
+
const beforeConfig = await loadConfig(configPath);
|
|
110
|
+
let proposedSource;
|
|
111
|
+
const contents = new Map();
|
|
112
|
+
for (const change of changes) {
|
|
113
|
+
if (typeof change?.path !== "string" || change.path.length === 0 ||
|
|
114
|
+
(change.content !== null && typeof change.content !== "string")) {
|
|
115
|
+
throw new Error("each change must have a nonempty path and string or null content");
|
|
116
|
+
}
|
|
117
|
+
const path = canonicalChangePath(projectRoot, change.path);
|
|
118
|
+
if (contents.has(path))
|
|
119
|
+
throw new Error(`duplicate change path: ${change.path}`);
|
|
120
|
+
if (path === configPath) {
|
|
121
|
+
if (change.content === null)
|
|
122
|
+
throw new Error("cannot delete archstrict.config.ts");
|
|
123
|
+
proposedSource = change.content;
|
|
124
|
+
}
|
|
125
|
+
contents.set(path, change.content);
|
|
126
|
+
}
|
|
127
|
+
// The baseline must reflect disk, while every after-side rule uses the proposed config.
|
|
128
|
+
const afterConfig = proposedSource === undefined ? beforeConfig : await loadConfig(configPath, proposedSource);
|
|
129
|
+
// loadConfig already guarantees declaredModules is a well-shaped array
|
|
130
|
+
// (assertDeclaredModulesShapeValid) - see check.ts's own comment.
|
|
131
|
+
const options = { projectRoot, declaredModules: beforeConfig.declaredModules, exclude: beforeConfig.exclude, surface: beforeConfig.surface };
|
|
132
|
+
const prepared = prepareGraph(options);
|
|
133
|
+
const baseline = buildModuleGraphForRules(options);
|
|
134
|
+
const beforeLocator = createConfigLocator(beforeConfig);
|
|
135
|
+
const mode = simulateOptions.wholeProject ? "whole-project" : "scoped";
|
|
136
|
+
const focusPaths = simulateOptions.wholeProject ? undefined : [...contents.keys()];
|
|
137
|
+
const before = evaluate(baseline, beforeConfig, beforeLocator, focusPaths);
|
|
138
|
+
const added = new Set();
|
|
139
|
+
const deleted = new Set();
|
|
140
|
+
for (const [file, content] of contents) {
|
|
141
|
+
if (content === null)
|
|
142
|
+
deleted.add(file);
|
|
143
|
+
else if (!existsSync(file))
|
|
144
|
+
added.add(file);
|
|
145
|
+
}
|
|
146
|
+
const host = overlayHost(prepared.compilerOptions, contents);
|
|
147
|
+
// A second preparation keeps rootNames, modules, surfaceFiles, and
|
|
148
|
+
// resolvers consistent through the same computations as a real build.
|
|
149
|
+
// Manual reconstruction missed new surface files and admitted excluded
|
|
150
|
+
// files as roots. Adjust the input list and let preparation derive the
|
|
151
|
+
// metadata again, without changes to the baseline's module objects.
|
|
152
|
+
const listOverride = (realFiles) => [...new Set([
|
|
153
|
+
...realFiles.filter(file => !deleted.has(file)),
|
|
154
|
+
...[...added].filter(isResolvableFile),
|
|
155
|
+
])];
|
|
156
|
+
const graphOptions = {
|
|
157
|
+
projectRoot, declaredModules: afterConfig.declaredModules, exclude: afterConfig.exclude,
|
|
158
|
+
surface: afterConfig.surface,
|
|
159
|
+
fileListOverride: (realFiles) => [...new Set([
|
|
160
|
+
...realFiles.filter(file => !deleted.has(file)),
|
|
161
|
+
...[...added].filter(file => isEligibleSourceFile(file, projectRoot, afterConfig.exclude ?? [], afterConfig.declaredModules, afterConfig.surface ?? DEFAULT_SURFACE)),
|
|
162
|
+
])],
|
|
163
|
+
resolvableFileListOverride: listOverride,
|
|
164
|
+
};
|
|
165
|
+
const simulatedPrepared = prepareGraph(graphOptions);
|
|
166
|
+
const graph = buildModuleGraphForRules(graphOptions, { host, dirtyFiles: new Set(contents.keys()), persistCache: false });
|
|
167
|
+
// graph.edges above already came from this same `host` (buildPreparedGraph's
|
|
168
|
+
// own per-file walk reads through it, then drops each SourceFile once
|
|
169
|
+
// walked - it never keeps one around to inspect). This does not force
|
|
170
|
+
// `graph.program`: rule 6's own Program holds only the type-reachable
|
|
171
|
+
// closure from every module's surface (type-closure.ts), a smaller file
|
|
172
|
+
// list than the simulation's own, so an ordinary change outside that
|
|
173
|
+
// closure would misread here as a host bug that never happened.
|
|
174
|
+
// Instead this calls `host.getSourceFile` directly - the same function
|
|
175
|
+
// a real `ts.createProgram` call uses to load a file's text - for
|
|
176
|
+
// every change, and compares its own text with the change's own
|
|
177
|
+
// content: a deleted file must read back as absent there, and every
|
|
178
|
+
// other change's real SourceFile text must equal what this call asked
|
|
179
|
+
// to write.
|
|
180
|
+
const languageVersion = simulatedPrepared.compilerOptions.target ?? ts.ScriptTarget.ESNext;
|
|
181
|
+
for (const [file, content] of contents) {
|
|
182
|
+
const source = host.getSourceFile(file, languageVersion);
|
|
183
|
+
if (content === null ? source !== undefined : (source === undefined || source.text !== content)) {
|
|
184
|
+
throw new Error(`internal simulation error: overlay mismatch for ${file}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const afterLocator = createConfigLocator(afterConfig, proposedSource);
|
|
188
|
+
const after = evaluate(graph, afterConfig, afterLocator, focusPaths);
|
|
189
|
+
// Both graphs share the same (realpath'd) projectRoot, so `baseline`'s
|
|
190
|
+
// and `graph`'s own relativePath agree on every real file either could
|
|
191
|
+
// name - either one relativizes either side's violations correctly.
|
|
192
|
+
// fingerprintOf itself needs a project-relative path (see
|
|
193
|
+
// todo-store.ts's own relativizeForTodo); a live violation's own
|
|
194
|
+
// `path`/`target` is always absolute.
|
|
195
|
+
const keyOf = (v) => fingerprintOf(relativizeForTodo(v, baseline.relativePath));
|
|
196
|
+
const beforeFingerprints = new Set(before.map(keyOf));
|
|
197
|
+
const afterFingerprints = new Set(after.map(keyOf));
|
|
198
|
+
return {
|
|
199
|
+
mode,
|
|
200
|
+
added: after.filter(violation => !beforeFingerprints.has(keyOf(violation))),
|
|
201
|
+
resolved: before.filter(violation => !afterFingerprints.has(keyOf(violation))),
|
|
202
|
+
unchangedCount: before.filter(violation => afterFingerprints.has(keyOf(violation))).length,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
export function formatSimulateText(result) {
|
|
206
|
+
const render = (violations) => {
|
|
207
|
+
const report = {
|
|
208
|
+
violations, suggestions: [], modules: 0, modulesWithoutSurface: 0, modulesWithoutSurfaceNames: [], edges: 0, outsideFiles: 0,
|
|
209
|
+
nonTsSourceFiles: 0,
|
|
210
|
+
unresolvedSpecifiers: 0, unresolvedSpecifierBreakdown: [], unsupportedSyntax: 0, typeLeaks: 0,
|
|
211
|
+
todo: 0, edgeRuleCoverage: [],
|
|
212
|
+
};
|
|
213
|
+
const text = formatText(report);
|
|
214
|
+
// Keep the shared violation rendering, but omit counts that describe a full check rather than a change set.
|
|
215
|
+
return text.slice(0, text.lastIndexOf("\nmodules:") + 1);
|
|
216
|
+
};
|
|
217
|
+
return `mode: ${result.mode}\nadded: ${result.added.length}; resolved: ${result.resolved.length}; unchanged: ${result.unchangedCount}\n` +
|
|
218
|
+
(result.added.length ? `added violations:\n${render(result.added)}` : "") +
|
|
219
|
+
(result.resolved.length ? `resolved violations:\n${render(result.resolved)}` : "");
|
|
220
|
+
}
|