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,1011 @@
|
|
|
1
|
+
// Responsibility: the `check` verb. Loads a real archstrict.config.ts,
|
|
2
|
+
// builds the module graph, runs every available rule, and aggregates the
|
|
3
|
+
// results into one shape for JSON and text output.
|
|
4
|
+
// Boundary: this is where the six rules' differing return shapes get
|
|
5
|
+
// normalized to one — `deprecated`'s two arrays (violations/suggestions)
|
|
6
|
+
// flatten in here, not in each rule.
|
|
7
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
8
|
+
import { resolve, sep } from "node:path";
|
|
9
|
+
import ts from "typescript";
|
|
10
|
+
import { buildModuleGraphForRules, globResolutionDir } from "../module-graph.js";
|
|
11
|
+
import { dominantBypassModule, dominantBypassSentence } from "./map-shape.js";
|
|
12
|
+
import { assertEdgesShapeValid, assertGlobsSupported, assertSchemaVersion, describeShape } from "../config.js";
|
|
13
|
+
import { ReportError } from "../report-error.js";
|
|
14
|
+
import { checkPublicSurfaceBypass } from "../rules/public-surface.js";
|
|
15
|
+
import { checkCycles, checkStaleCycleExceptions, } from "../rules/cycles.js";
|
|
16
|
+
import { checkUncoveredModules } from "../rules/uncovered.js";
|
|
17
|
+
import { checkEmptyRuleSet } from "../rules/empty-rule.js";
|
|
18
|
+
import { checkDeprecatedEdges, } from "../rules/deprecated.js";
|
|
19
|
+
import { checkTypeLeaks } from "../type-leak.js";
|
|
20
|
+
import { checkMustBeEmpty } from "../rules/must-be-empty.js";
|
|
21
|
+
import { checkAllowDeny, checkEdgesCoverage, checkOrder, checkPoint, } from "../rules/constraints.js";
|
|
22
|
+
import { checkConfigMeaning } from "../rules/config-meaning.js";
|
|
23
|
+
import { buildTodoIndex, EMPTY_TODO_INDEX, findMatchingEntry, fingerprintOf, readTodoFile, } from "../todo-store.js";
|
|
24
|
+
import { readLegacyTodoState } from "../todo-migration.js";
|
|
25
|
+
import { createConfigLocator, declaredModulePointerForName, locateViolation, locateViolations, } from "../config-pointer.js";
|
|
26
|
+
export function formatConfigPointerLines(config) {
|
|
27
|
+
const pointers = Array.isArray(config) ? config : [config];
|
|
28
|
+
return pointers.map((pointer) => ` config: ${pointer.path}:${pointer.line}:${pointer.column} ${pointer.pointer} (${pointer.role})`);
|
|
29
|
+
}
|
|
30
|
+
export const NO_EDGES_SUMMARY = "this config freezes today's import graph, not a target architecture; no edges rule is configured yet";
|
|
31
|
+
export const NO_EDGES_DO = [
|
|
32
|
+
"archstrict recommend",
|
|
33
|
+
"archstrict hotspots",
|
|
34
|
+
"read node_modules/archstrict/skills/archstrict/references/rearchitect.md",
|
|
35
|
+
];
|
|
36
|
+
function hasEdgesRule(config) {
|
|
37
|
+
const edges = config.edges;
|
|
38
|
+
return (edges?.allowDeny?.length ?? 0) + (edges?.order?.length ?? 0) + (edges?.point?.length ?? 0) > 0;
|
|
39
|
+
}
|
|
40
|
+
// declaredModules replaces modules/kinds as the required field, the same
|
|
41
|
+
// class of config error as a missing kinds used to be: check/todo build
|
|
42
|
+
// their graph from declaredModules unconditionally now, so a config
|
|
43
|
+
// without it cannot be analyzed at all - `archstrict init` is what writes
|
|
44
|
+
// it.
|
|
45
|
+
const REQUIRED_FIELDS = ["declaredModules", "because"];
|
|
46
|
+
// A config file cannot know its own path (init.ts's own generated template
|
|
47
|
+
// says the same); the loader is what adds it, after reading the file, not
|
|
48
|
+
// before.
|
|
49
|
+
//
|
|
50
|
+
// Reads the source and strips types with the TypeScript compiler API
|
|
51
|
+
// itself (this project's own dependency), then imports the result as a
|
|
52
|
+
// data: URL, rather than a plain `import(pathToFileURL(configPath).href)`
|
|
53
|
+
// of the file directly. A file-path import is cached by Node's ESM loader
|
|
54
|
+
// keyed on that exact URL, so importing the same path twice in one
|
|
55
|
+
// process returns the first import's stale object even after the file
|
|
56
|
+
// changed on disk since - measured directly. A real CLI invocation is a
|
|
57
|
+
// fresh process each time and never hits this, but this project's own
|
|
58
|
+
// test suite calls loadConfig more than once per process, which is
|
|
59
|
+
// exactly how the staleness surfaced. A data: URL's content IS its
|
|
60
|
+
// identity, so a changed file naturally produces a different URL and a
|
|
61
|
+
// fresh module; identical content correctly reuses the cache.
|
|
62
|
+
//
|
|
63
|
+
// Constraint this puts on a config file: its only import must be a
|
|
64
|
+
// type-only one (`import type { Config } from "./archstrict.types.js"`,
|
|
65
|
+
// exactly what init writes). `verbatimModuleSyntax` erases a type-only
|
|
66
|
+
// import completely, leaving no import statement in the transpiled output
|
|
67
|
+
// for the data: URL to resolve. A plain `import { Config } from "..."` (no
|
|
68
|
+
// `type` keyword) is NOT erased - transpileModule works one file at a
|
|
69
|
+
// time and cannot see that `Config` is only ever used as a type - so it
|
|
70
|
+
// survives into the output and fails to resolve against a data: URL, which
|
|
71
|
+
// has no directory of its own. Checked for below, with a clear error
|
|
72
|
+
// instead of that opaque resolution failure.
|
|
73
|
+
// Proposed source uses the same content-keyed URL, so a changed proposal cannot reuse a stale config module.
|
|
74
|
+
//
|
|
75
|
+
// `verb` is the command a thrown error's `do:` tells the caller to re-run
|
|
76
|
+
// once the file is fixed. init's own two calls pass "archstrict init": a
|
|
77
|
+
// re-run's do: pointing at `archstrict check` would send the caller to a
|
|
78
|
+
// command that never regenerates archstrict.types.ts, the file init's own
|
|
79
|
+
// load exists to write. Only errors thrown directly here take `verb` -
|
|
80
|
+
// assertSchemaVersion and assertEdgesShapeValid are shared with other
|
|
81
|
+
// verbs and keep their own fixed "archstrict check", unchanged.
|
|
82
|
+
export async function loadConfig(configPath, sourceOverride, verb = "archstrict check") {
|
|
83
|
+
// init is what creates this file. A missing one is the first-run path,
|
|
84
|
+
// and a raw ENOENT doesn't name that command.
|
|
85
|
+
if (sourceOverride === undefined && !existsSync(configPath)) {
|
|
86
|
+
throw new ReportError(`${configPath} does not exist`, "archstrict init");
|
|
87
|
+
}
|
|
88
|
+
const source = sourceOverride ?? readFileSync(configPath, "utf8");
|
|
89
|
+
const { outputText } = ts.transpileModule(source, {
|
|
90
|
+
compilerOptions: {
|
|
91
|
+
module: ts.ModuleKind.ESNext,
|
|
92
|
+
target: ts.ScriptTarget.ESNext,
|
|
93
|
+
verbatimModuleSyntax: true,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
if (/^\s*import\b/m.test(outputText)) {
|
|
97
|
+
throw new ReportError(`${configPath} may only import types from "./archstrict.types.js" - use \`import type\`, not \`import\``, `change the import in ${configPath} to \`import type\`, then run ${verb}`);
|
|
98
|
+
}
|
|
99
|
+
const dataUrl = `data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`;
|
|
100
|
+
const mod = await import(dataUrl);
|
|
101
|
+
const raw = mod.default;
|
|
102
|
+
if (raw === undefined || typeof raw !== "object" || raw === null) {
|
|
103
|
+
throw new ReportError(`${configPath} has no default export`, `add a default export satisfying Config to ${configPath}, then run ${verb}`);
|
|
104
|
+
}
|
|
105
|
+
assertSchemaVersion(configPath, raw);
|
|
106
|
+
for (const field of REQUIRED_FIELDS) {
|
|
107
|
+
if (!(field in raw)) {
|
|
108
|
+
throw new ReportError(`${configPath} is missing required field '${field}'`, `add '${field}' to the default export in ${configPath}, then run ${verb}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
assertDeclaredModulesShapeValid(configPath, raw, verb);
|
|
112
|
+
const config = { ...raw, configPath };
|
|
113
|
+
assertEdgesShapeValid(config);
|
|
114
|
+
assertGlobsSupported(config, verb);
|
|
115
|
+
return config;
|
|
116
|
+
}
|
|
117
|
+
// `field in raw` (REQUIRED_FIELDS above) only checks presence, not shape:
|
|
118
|
+
// `declaredModules: null` or `declaredModules: undefined` both satisfy
|
|
119
|
+
// `in` and passed straight through to init's `.map`, which then either
|
|
120
|
+
// threw a raw TypeError (check) or produced `ModuleName = never` /
|
|
121
|
+
// `"a" | ;` - invalid TypeScript - written over the last good union
|
|
122
|
+
// (init). Every reader of `config.declaredModules` (buildModuleGraph,
|
|
123
|
+
// init's own union writer) depends on it actually being an array of
|
|
124
|
+
// `{ name: non-empty string, glob: string }`, so that shape is validated
|
|
125
|
+
// once, here, rather than trusted at every call site.
|
|
126
|
+
function assertDeclaredModulesShapeValid(configPath, raw, verb) {
|
|
127
|
+
const declaredModules = raw.declaredModules;
|
|
128
|
+
if (!Array.isArray(declaredModules)) {
|
|
129
|
+
throw new ReportError(`${configPath} field 'declaredModules' must be an array, not ${describeShape(declaredModules)}`, `make 'declaredModules' an array of { name, glob } entries in ${configPath}, then run ${verb}`);
|
|
130
|
+
}
|
|
131
|
+
declaredModules.forEach((entry, i) => {
|
|
132
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
133
|
+
throw new ReportError(`${configPath} field 'declaredModules[${i}]' must be an object, not ${describeShape(entry)}`, `make 'declaredModules[${i}]' an object with a 'name' and a 'glob' in ${configPath}, then run ${verb}`);
|
|
134
|
+
}
|
|
135
|
+
const name = entry.name;
|
|
136
|
+
if (typeof name === "string" && name.length === 0) {
|
|
137
|
+
throw new ReportError(`${configPath} field 'declaredModules[${i}].name' must be a non-empty string, got an empty string`, `give 'declaredModules[${i}]' a non-empty string 'name' in ${configPath}, then run ${verb}`);
|
|
138
|
+
}
|
|
139
|
+
if (typeof name !== "string") {
|
|
140
|
+
throw new ReportError(`${configPath} field 'declaredModules[${i}].name' must be a non-empty string, not ${describeShape(name)}`, `give 'declaredModules[${i}]' a non-empty string 'name' in ${configPath}, then run ${verb}`);
|
|
141
|
+
}
|
|
142
|
+
const glob = entry.glob;
|
|
143
|
+
if (typeof glob === "string")
|
|
144
|
+
return;
|
|
145
|
+
if (Array.isArray(glob) && glob.length > 0 && glob.every((item) => typeof item === "string" && item.length > 0)) {
|
|
146
|
+
const dir = globResolutionDir(glob[0]);
|
|
147
|
+
if (glob.every((item) => globResolutionDir(item) === dir))
|
|
148
|
+
return;
|
|
149
|
+
throw new ReportError(`${configPath} field 'declaredModules[${i}].glob' lists paths that do not share one directory`, `list only files in one directory, or use one declaredModules entry per directory, in ${configPath}, then run ${verb}`);
|
|
150
|
+
}
|
|
151
|
+
throw new ReportError(`${configPath} field 'declaredModules[${i}].glob' must be a string or a non-empty array of strings, not ${describeShape(glob)}`, `give 'declaredModules[${i}]' a string 'glob', or an array of file paths in one directory, in ${configPath}, then run ${verb}`);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function allProjectRelativeFiles(graph) {
|
|
155
|
+
const files = [...graph.modules.values()].flatMap((m) => m.files).concat(graph.outsideFiles);
|
|
156
|
+
return files.map(graph.relativePath);
|
|
157
|
+
}
|
|
158
|
+
// A scoped specifier's own two segments ("@scope/name") are the meaningful
|
|
159
|
+
// grouping unit - "@scope" alone would merge every package under one scope
|
|
160
|
+
// into a single, useless bucket. A relative or unscoped specifier groups by
|
|
161
|
+
// its own first segment only.
|
|
162
|
+
function specifierPrefix(specifier) {
|
|
163
|
+
const segments = specifier.split("/");
|
|
164
|
+
if (specifier.startsWith("@") && segments.length > 1) {
|
|
165
|
+
return `${segments[0]}/${segments[1]}`;
|
|
166
|
+
}
|
|
167
|
+
return segments[0];
|
|
168
|
+
}
|
|
169
|
+
function unresolvedSpecifierBreakdown(specifiers) {
|
|
170
|
+
const counts = new Map();
|
|
171
|
+
for (const specifier of specifiers) {
|
|
172
|
+
const prefix = specifierPrefix(specifier);
|
|
173
|
+
counts.set(prefix, (counts.get(prefix) ?? 0) + 1);
|
|
174
|
+
}
|
|
175
|
+
return [...counts.entries()]
|
|
176
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
177
|
+
.slice(0, 10)
|
|
178
|
+
.map(([prefix, count]) => ({ prefix, count }));
|
|
179
|
+
}
|
|
180
|
+
// The exact predicate `filterToFile` itself applies to a violation's own
|
|
181
|
+
// `path` - shared so every early-filtering call site above narrows down to
|
|
182
|
+
// precisely the set `filterToFile` would keep from an unscoped run. Most
|
|
183
|
+
// of this file's own violations carry an already-absolute, already-real
|
|
184
|
+
// `path` (an edge's own `fromFile`, or a module's own directory) and are
|
|
185
|
+
// compared directly instead, for the edges the cost of this call would
|
|
186
|
+
// itself add back (checkPublicSurfaceBypass's own comment has the
|
|
187
|
+
// reasoning) - `resolve()` here exists for the one path shape that needs
|
|
188
|
+
// it: must-be-empty's own `path` is project-relative, resolved against
|
|
189
|
+
// `process.cwd()`, the same as `filterToFile` resolves the `file` a
|
|
190
|
+
// caller named.
|
|
191
|
+
function reportedAtFocus(path, focus) {
|
|
192
|
+
return resolve(path) === focus;
|
|
193
|
+
}
|
|
194
|
+
export function runRules(graph, config, options = {}) {
|
|
195
|
+
// null (not 0, not an empty array's own .length) when this rule did not
|
|
196
|
+
// run: "not evaluated" and "evaluated, found none" are different facts,
|
|
197
|
+
// and CheckResult.typeLeaks's own comment is the field this distinction
|
|
198
|
+
// exists for. Rule 6 runs first so that its Program can be released
|
|
199
|
+
// (options.afterTypeLeak) before the other rules build their own
|
|
200
|
+
// violations; its findings still go last in the report. The `typeLeaks`
|
|
201
|
+
// count stays whole-project for a full
|
|
202
|
+
// run. A surface-focused run counts only findings at that surface.
|
|
203
|
+
// A non-surface focus cannot receive a rule-6 finding, so evaluation is
|
|
204
|
+
// refused. A surface focus uses the separate scoped graph path because an
|
|
205
|
+
// all-surface closure found 53 leaks to report 4 on a 23,000-file project.
|
|
206
|
+
const allTypeLeaks = options.skipTypeLeak ? undefined : options.focusedTypeLeakModule === undefined
|
|
207
|
+
? checkTypeLeaks(graph)
|
|
208
|
+
: graph.typeLeaksForFocus(options.focusedTypeLeakModule);
|
|
209
|
+
// A focused module can have several surfaces. Counting every surface is
|
|
210
|
+
// refused because `check <file>` reports facts about one requested file.
|
|
211
|
+
const typeLeaks = allTypeLeaks === undefined || options.focus === undefined
|
|
212
|
+
? allTypeLeaks
|
|
213
|
+
: allTypeLeaks.filter((violation) => reportedAtFocus(violation.path, options.focus));
|
|
214
|
+
options.afterTypeLeak?.();
|
|
215
|
+
const focus = options.focus;
|
|
216
|
+
const mustBeEmptyFiles = allProjectRelativeFiles(graph);
|
|
217
|
+
const violations = [
|
|
218
|
+
...checkPublicSurfaceBypass(graph, focus),
|
|
219
|
+
...checkCycles(graph, config),
|
|
220
|
+
...checkStaleCycleExceptions(graph, config),
|
|
221
|
+
...checkUncoveredModules(graph, config, focus),
|
|
222
|
+
...checkEmptyRuleSet(graph, config),
|
|
223
|
+
...checkMustBeEmpty(focus === undefined ? mustBeEmptyFiles : mustBeEmptyFiles.filter((f) => reportedAtFocus(f, focus)), config),
|
|
224
|
+
...checkAllowDeny(graph, config, focus),
|
|
225
|
+
...checkOrder(graph, config, focus),
|
|
226
|
+
...checkPoint(graph, config, focus),
|
|
227
|
+
];
|
|
228
|
+
const deprecated = checkDeprecatedEdges(graph, config);
|
|
229
|
+
violations.push(...deprecated.violations);
|
|
230
|
+
if (typeLeaks !== undefined)
|
|
231
|
+
violations.push(...typeLeaks);
|
|
232
|
+
const modulesWithoutSurfaceNames = [];
|
|
233
|
+
for (const m of graph.modules.values()) {
|
|
234
|
+
if (m.surfaceFiles.length === 0)
|
|
235
|
+
modulesWithoutSurfaceNames.push(m.name);
|
|
236
|
+
}
|
|
237
|
+
modulesWithoutSurfaceNames.sort();
|
|
238
|
+
// Every rule above either already builds only focus-matching violations
|
|
239
|
+
// (the early filters just passed in), or still runs fully and returns
|
|
240
|
+
// every one of its own findings (cycles; every config.configPath-only
|
|
241
|
+
// rule) - this pass is what makes the two the same either way: it keeps
|
|
242
|
+
// exactly what `filterToFile` would keep from a fully unscoped run, so
|
|
243
|
+
// `runRules`'s own return value is already the scoped result, not an
|
|
244
|
+
// approximation of it. Graph counts and edge coverage stay whole-project
|
|
245
|
+
// facts. Violations, todo, and a surface run's type-leak count are scoped.
|
|
246
|
+
const scopedViolations = focus === undefined ? violations : violations.filter((v) => reportedAtFocus(v.path, focus));
|
|
247
|
+
const locator = options.configLocator ?? createConfigLocator(config);
|
|
248
|
+
return {
|
|
249
|
+
modules: graph.modules.size,
|
|
250
|
+
modulesWithoutSurface: modulesWithoutSurfaceNames.length,
|
|
251
|
+
modulesWithoutSurfaceNames,
|
|
252
|
+
edges: graph.crossModuleEdges.length,
|
|
253
|
+
outsideFiles: graph.outsideFiles.length,
|
|
254
|
+
nonTsSourceFiles: graph.nonTsSourceFileCount,
|
|
255
|
+
unresolvedSpecifiers: graph.unresolvedSpecifierCount,
|
|
256
|
+
unresolvedSpecifierBreakdown: unresolvedSpecifierBreakdown(graph.unresolvedSpecifiers),
|
|
257
|
+
unsupportedSyntax: graph.unsupportedSyntaxCount,
|
|
258
|
+
typeLeaks: typeLeaks === undefined ? null : typeLeaks.length,
|
|
259
|
+
todo: 0,
|
|
260
|
+
violations: locateViolations(scopedViolations, config, locator),
|
|
261
|
+
suggestions: deprecated.suggestions,
|
|
262
|
+
edgeRuleCoverage: checkEdgesCoverage(graph, config),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// check <file>: analyze the whole project (module resolution needs every
|
|
266
|
+
// file to know what an edge crosses into), but report only the named
|
|
267
|
+
// path's own violations — analysis is whole-project, but the report is
|
|
268
|
+
// scoped to the one path asked about. Rule 6 uses a module's surface file
|
|
269
|
+
// as its own `path`, so it CAN match this filter (editing a surface file
|
|
270
|
+
// directly). Rules 2-5 use a module directory or the config file as their
|
|
271
|
+
// own `path`, never a single source file, so they never match this filter
|
|
272
|
+
// by design: a per-file hook cares about the edited file's own edges (rule
|
|
273
|
+
// 1, and rule 6), not a module- or config-level finding that no single
|
|
274
|
+
// file edit could have caused.
|
|
275
|
+
//
|
|
276
|
+
// `file` and a violation's own `path` can each name the same real file in
|
|
277
|
+
// a different textual form - one reached through a symlink, the other
|
|
278
|
+
// not (measured directly: a process chdir'd into a symlinked directory
|
|
279
|
+
// has its own process.cwd() come back already resolved, with no way to
|
|
280
|
+
// see the symlinked form again; a caller-supplied `file` carries whatever
|
|
281
|
+
// form it arrived in, independently). Comparing the two strings as given
|
|
282
|
+
// then silently matches nothing. `realpathSync` resolves `file` - the one
|
|
283
|
+
// path this function has any reason to distrust. A violation's own
|
|
284
|
+
// `path` never gets its own `realpathSync` call here: every one of them
|
|
285
|
+
// is already real by the time it reaches this filter - a module- or
|
|
286
|
+
// file-derived path comes from `graph.rootDir` (itself realpath'd once,
|
|
287
|
+
// in prepareGraph) joined onto a project-relative fragment, and a
|
|
288
|
+
// config-meaning violation's own path comes from `check()`'s own
|
|
289
|
+
// `configPath`, realpath'd once there for exactly this reason. Calling
|
|
290
|
+
// `realpathSync` per violation instead of `resolve` (a pure string op,
|
|
291
|
+
// no filesystem call) measured at 3.4% of a whole `check` run on a
|
|
292
|
+
// 23,000-file tree, almost all of it inside this one loop.
|
|
293
|
+
//
|
|
294
|
+
// `stale-todo` matches through its own `entryPath` instead of `path`: its
|
|
295
|
+
// `path` is unconditionally the todo file itself (so the violation always
|
|
296
|
+
// points a reader at the real line to edit), but the owner decided a
|
|
297
|
+
// scoped `check` should still surface the exact moment an edit retires a
|
|
298
|
+
// frozen violation - the file (or, for `check <dir>`, any file under the
|
|
299
|
+
// directory) the STALE ENTRY ITSELF is about, not the file the report
|
|
300
|
+
// happens to be written into. A directory target matches an `entryPath`
|
|
301
|
+
// equal to it or nested under it (`target + sep` prefix); a file target
|
|
302
|
+
// only ever matches by equality, since a file has no children to nest
|
|
303
|
+
// anything under.
|
|
304
|
+
export function filterToFile(result, file) {
|
|
305
|
+
const resolved = resolve(file);
|
|
306
|
+
if (!existsSync(resolved)) {
|
|
307
|
+
throw new ReportError(`check ${file}: no such file`, "archstrict check");
|
|
308
|
+
}
|
|
309
|
+
const target = realpathSync(resolved);
|
|
310
|
+
const targetWithSep = target + sep;
|
|
311
|
+
return {
|
|
312
|
+
...result,
|
|
313
|
+
violations: result.violations.filter((v) => {
|
|
314
|
+
if (v.rule === "stale-todo") {
|
|
315
|
+
const entryPath = resolve(v.entryPath);
|
|
316
|
+
return entryPath === target || entryPath.startsWith(targetWithSep);
|
|
317
|
+
}
|
|
318
|
+
return resolve(v.path) === target;
|
|
319
|
+
}),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function isFreezable(v) {
|
|
323
|
+
return "todoModule" in v;
|
|
324
|
+
}
|
|
325
|
+
// An entry's own stored `path` (todo-store.ts's own TodoEntry, always
|
|
326
|
+
// present, always project-relative to `rootDir` - todo.ts's own
|
|
327
|
+
// freezeOrPrune writes it that way, and both readTodoFile and the legacy
|
|
328
|
+
// reader normalize an old absolute one into the same form) resolved back
|
|
329
|
+
// to the real, absolute file it names, compared against `focus`. A
|
|
330
|
+
// public-surface-bypass or constraint-engine entry frozen under module M's
|
|
331
|
+
// own name records the IMPORTER's path, not M's own - the same reason
|
|
332
|
+
// those two rules' own Violation.path is the importer (public-surface.ts's
|
|
333
|
+
// own comment) - so this can differ from M's own directory even when M
|
|
334
|
+
// itself is the file being checked.
|
|
335
|
+
function entryReportedAtFocus(entry, rootDir, focus) {
|
|
336
|
+
return resolve(rootDir, entry.path) === focus;
|
|
337
|
+
}
|
|
338
|
+
function moduleTodoLocation(parsed, moduleDir, name) {
|
|
339
|
+
if (parsed !== undefined) {
|
|
340
|
+
const at = parsed.moduleKeyLocation.get(name);
|
|
341
|
+
return { path: parsed.path, line: at?.line ?? 1, column: at?.column ?? 1 };
|
|
342
|
+
}
|
|
343
|
+
// Only reached on the pre-migration layout, where moduleDir is always
|
|
344
|
+
// defined - readLegacyTodoState only ever reads a name the live graph
|
|
345
|
+
// already declares, so it never produces an orphan entry.
|
|
346
|
+
return { path: moduleDir, line: 1, column: 1 };
|
|
347
|
+
}
|
|
348
|
+
function entryTodoLocation(parsed, moduleDir, entry) {
|
|
349
|
+
if (parsed !== undefined) {
|
|
350
|
+
const at = parsed.entryLocation.get(entry);
|
|
351
|
+
return { path: parsed.path, line: at?.line ?? 1, column: at?.column ?? 1 };
|
|
352
|
+
}
|
|
353
|
+
return { path: moduleDir, line: 1, column: 1 };
|
|
354
|
+
}
|
|
355
|
+
export function readCurrentTodo(graph) {
|
|
356
|
+
const parsed = readTodoFile(graph.rootDir);
|
|
357
|
+
const moduleDirs = new Map([...graph.modules].map(([name, m]) => [name, m.dir]));
|
|
358
|
+
const legacy = parsed === undefined ? readLegacyTodoState(graph.rootDir, moduleDirs) : undefined;
|
|
359
|
+
const entriesByModule = new Map();
|
|
360
|
+
if (parsed !== undefined) {
|
|
361
|
+
for (const [name, entries] of parsed.modules)
|
|
362
|
+
entriesByModule.set(name, entries);
|
|
363
|
+
}
|
|
364
|
+
else if (legacy !== undefined) {
|
|
365
|
+
for (const [name, entries] of legacy.entriesByModule)
|
|
366
|
+
entriesByModule.set(name, entries);
|
|
367
|
+
}
|
|
368
|
+
// Prompts a one-line migration nudge in the report (result.notes, always
|
|
369
|
+
// printed - check.ts's own formatText loop) instead of check silently
|
|
370
|
+
// reading debt off a layout that's going away: an adopted project stays
|
|
371
|
+
// green under the old layout until someone actually runs the new
|
|
372
|
+
// `archstrict todo`, which folds it in and deletes the old files.
|
|
373
|
+
const migrationNote = legacy?.present === true
|
|
374
|
+
? "reading frozen debt from the old per-module todo layout - run archstrict todo to migrate it into one archstrict.todo.json"
|
|
375
|
+
: undefined;
|
|
376
|
+
return { parsed, entriesByModule, migrationNote };
|
|
377
|
+
}
|
|
378
|
+
export function applyTodo(graph, config, result, options = {}) {
|
|
379
|
+
const skipStaleCheckForRules = new Set(options.skipStaleCheckForRules ?? []);
|
|
380
|
+
const focus = options.focus;
|
|
381
|
+
const strict = new Set(config.strict ?? []);
|
|
382
|
+
const locator = options.configLocator ?? createConfigLocator(config);
|
|
383
|
+
const remaining = [];
|
|
384
|
+
// Keyed by a freshly recomputed fingerprint (todo-store.ts's own
|
|
385
|
+
// fingerprintOf), not an object identity or a stored string field (this
|
|
386
|
+
// file's entries no longer carry one - todo-store.ts's own comment on
|
|
387
|
+
// why): a real project can carry more than one stored entry with
|
|
388
|
+
// identical content (the same edge frozen twice - runRules itself can
|
|
389
|
+
// report the same edge more than once), so tracking by the recomputed
|
|
390
|
+
// value means every duplicate with that fingerprint reads as matched
|
|
391
|
+
// below, the same as a plain string-set comparison always did.
|
|
392
|
+
const matchedByModule = new Map();
|
|
393
|
+
let suppressed = 0;
|
|
394
|
+
const { parsed, entriesByModule, migrationNote } = readCurrentTodo(graph);
|
|
395
|
+
const moduleDirs = new Map([...graph.modules].map(([name, m]) => [name, m.dir]));
|
|
396
|
+
const todoIndexByModule = new Map();
|
|
397
|
+
function todoIndexFor(name) {
|
|
398
|
+
let index = todoIndexByModule.get(name);
|
|
399
|
+
if (index === undefined) {
|
|
400
|
+
index = buildTodoIndex(entriesByModule.get(name) ?? []);
|
|
401
|
+
todoIndexByModule.set(name, index);
|
|
402
|
+
}
|
|
403
|
+
return index;
|
|
404
|
+
}
|
|
405
|
+
for (const v of result.violations) {
|
|
406
|
+
// A module configured to stay clean has its own violations never
|
|
407
|
+
// suppressed by its todo, existing or not: a real violation there must
|
|
408
|
+
// stay a real violation, not disappear into the todo count while a
|
|
409
|
+
// separate clean-module-has-todo finding says the same thing from the
|
|
410
|
+
// config's side.
|
|
411
|
+
if (!isFreezable(v) || strict.has(v.todoModule)) {
|
|
412
|
+
remaining.push(v);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const index = graph.modules.has(v.todoModule) ? todoIndexFor(v.todoModule) : EMPTY_TODO_INDEX;
|
|
416
|
+
const matchedEntry = findMatchingEntry(index, v, graph.relativePath);
|
|
417
|
+
if (matchedEntry !== undefined) {
|
|
418
|
+
suppressed++;
|
|
419
|
+
let matched = matchedByModule.get(v.todoModule);
|
|
420
|
+
if (matched === undefined) {
|
|
421
|
+
matched = new Set();
|
|
422
|
+
matchedByModule.set(v.todoModule, matched);
|
|
423
|
+
}
|
|
424
|
+
matched.add(fingerprintOf(matchedEntry));
|
|
425
|
+
if (options.includeFrozen)
|
|
426
|
+
remaining.push({ ...v, frozen: true });
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
remaining.push(v);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// Every module name this file (or the old layout) has entries under -
|
|
433
|
+
// a currently declared module, or an ORPHAN name a config no longer
|
|
434
|
+
// declares (the module was renamed or removed since it was frozen).
|
|
435
|
+
// An orphan can never match any current violation (nothing this run's
|
|
436
|
+
// rules produced even claims that name), so every one of its entries is
|
|
437
|
+
// unconditionally stale - reported the same as any other unmatched
|
|
438
|
+
// entry below, just outside the `graph.modules` loop that also has to
|
|
439
|
+
// decide strict-vs-not for a still-declared name.
|
|
440
|
+
const allModuleNames = new Set([...graph.modules.keys(), ...entriesByModule.keys()]);
|
|
441
|
+
for (const name of allModuleNames) {
|
|
442
|
+
const entries = entriesByModule.get(name) ?? [];
|
|
443
|
+
if (entries.length === 0)
|
|
444
|
+
continue;
|
|
445
|
+
const moduleDir = moduleDirs.get(name);
|
|
446
|
+
if (moduleDir !== undefined && strict.has(name)) {
|
|
447
|
+
const at = moduleTodoLocation(parsed, moduleDir, name);
|
|
448
|
+
remaining.push(locateViolation({
|
|
449
|
+
rule: "clean-module-has-todo",
|
|
450
|
+
path: at.path,
|
|
451
|
+
line: at.line,
|
|
452
|
+
column: at.column,
|
|
453
|
+
evidence: `module '${name}' is configured to stay clean, but has ${entries.length} todo entrie(s)`,
|
|
454
|
+
because: "a module configured to stay clean must have no todo entries, not entries frozen from before",
|
|
455
|
+
do: `fix the ${entries.length} violation(s), then run archstrict todo to prune`,
|
|
456
|
+
}, config, locator));
|
|
457
|
+
continue; // this module's entries are never "stale" — they're a standing violation instead
|
|
458
|
+
}
|
|
459
|
+
const matched = matchedByModule.get(name) ?? new Set();
|
|
460
|
+
for (const entry of entries) {
|
|
461
|
+
const at = entryTodoLocation(parsed, moduleDir, entry);
|
|
462
|
+
// On a `check <file>` run, an entry recorded at some OTHER path was
|
|
463
|
+
// never evaluated this run - runRules never built (or scoped away)
|
|
464
|
+
// whatever violation would have matched it, so whether it's still
|
|
465
|
+
// real or now fixed is unknown, not "gone" (skipStaleCheckForRules's
|
|
466
|
+
// own comment makes the same call for a rule this run skipped
|
|
467
|
+
// outright). Reporting it stale here would be a false positive: the
|
|
468
|
+
// entry itself never got a chance to match anything this run.
|
|
469
|
+
if (focus !== undefined && !entryReportedAtFocus(entry, graph.rootDir, focus))
|
|
470
|
+
continue;
|
|
471
|
+
if (matched.has(fingerprintOf(entry)))
|
|
472
|
+
continue;
|
|
473
|
+
if (skipStaleCheckForRules.has(entry.rule))
|
|
474
|
+
continue;
|
|
475
|
+
remaining.push(locateViolation({
|
|
476
|
+
rule: "stale-todo",
|
|
477
|
+
path: at.path,
|
|
478
|
+
line: at.line,
|
|
479
|
+
column: at.column,
|
|
480
|
+
evidence: `todo entry (${entry.rule}) no longer matches any violation`,
|
|
481
|
+
because: "an unmatched todo entry hides nothing real; it must be pruned, not left behind",
|
|
482
|
+
do: "archstrict todo",
|
|
483
|
+
entryPath: resolve(graph.rootDir, entry.path),
|
|
484
|
+
}, config, locator, [{ pointer: declaredModulePointerForName(config, name), role: "governs" }]));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
...result,
|
|
489
|
+
violations: remaining,
|
|
490
|
+
todo: suppressed,
|
|
491
|
+
notes: migrationNote === undefined ? result.notes : [...(result.notes ?? []), migrationNote],
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
// `--rule` needs a fixed list of valid ids to reject a typo against, rather
|
|
495
|
+
// than accepting anything and silently matching zero violations. Listed by
|
|
496
|
+
// hand instead of derived at runtime, since a rule that never fires this
|
|
497
|
+
// project (the current graph has no cycle, say) still has a real id an
|
|
498
|
+
// agent can filter for once one does appear.
|
|
499
|
+
export const CHECK_RULE_IDS = [
|
|
500
|
+
"clean-module-has-todo",
|
|
501
|
+
"config-meaning",
|
|
502
|
+
"cycle",
|
|
503
|
+
"deprecated-edge-decreased",
|
|
504
|
+
"deprecated-edge-increased",
|
|
505
|
+
"empty-rule-set",
|
|
506
|
+
"exhaustive-allow-list",
|
|
507
|
+
"must-be-empty",
|
|
508
|
+
"point-rule",
|
|
509
|
+
"public-surface-bypass",
|
|
510
|
+
"stale-cycle-exception",
|
|
511
|
+
"stale-todo",
|
|
512
|
+
"tag-boundary",
|
|
513
|
+
"tag-order",
|
|
514
|
+
"type-leak",
|
|
515
|
+
"uncovered-module",
|
|
516
|
+
];
|
|
517
|
+
const _checkRuleIdsCoverEveryRule = true;
|
|
518
|
+
// A frozen violation is real but already accepted as debt; it must never
|
|
519
|
+
// flip the exit code back to failing on a project that todo already froze.
|
|
520
|
+
export function hasBlockingViolations(result) {
|
|
521
|
+
return result.violations.some((v) => v.rule !== "config-meaning" && !v.frozen);
|
|
522
|
+
}
|
|
523
|
+
// Runs after the graph is built (module names depend on it) and before
|
|
524
|
+
// applyFilters, so a typo'd `--rule`/`--module` reports its own error
|
|
525
|
+
// instead of silently returning zero violations - the same distinction
|
|
526
|
+
// an empty, but genuinely correctly filtered, result must keep (see
|
|
527
|
+
// applyFilters's own comment on the empty case exiting 0).
|
|
528
|
+
function validateFilters(graph, options) {
|
|
529
|
+
const validRules = new Set(CHECK_RULE_IDS);
|
|
530
|
+
for (const rule of options.rules ?? []) {
|
|
531
|
+
if (!validRules.has(rule)) {
|
|
532
|
+
// The `do:` is a real, runnable example (a valid id substituted in),
|
|
533
|
+
// not `check --json` - that would print the whole, unbounded project
|
|
534
|
+
// report, the exact shape bounded text exists to avoid.
|
|
535
|
+
throw new ReportError(`unknown rule id '${rule}' - valid rule ids: ${CHECK_RULE_IDS.join(", ")}`, `archstrict check --rule ${CHECK_RULE_IDS[0]}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const validModules = [...graph.modules.keys()].sort();
|
|
539
|
+
const validModuleSet = new Set(validModules);
|
|
540
|
+
for (const moduleName of options.modules ?? []) {
|
|
541
|
+
if (!validModuleSet.has(moduleName)) {
|
|
542
|
+
throw new ReportError(`unknown module name '${moduleName}' - valid module names: ${validModules.join(", ")}`, validModules.length > 0 ? `archstrict check --module ${validModules[0]}` : "archstrict check");
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// Filters after every rule has already run, not before: a violation's own
|
|
547
|
+
// fields (todoModule, rule) are only known once the rule that produced it
|
|
548
|
+
// has run, and running fewer rules to satisfy `--rule` would still cost the
|
|
549
|
+
// same graph build for no saved work. `hasBlockingViolations` (cli.ts's own
|
|
550
|
+
// exit-code check) reads this same, already-filtered `result.violations`,
|
|
551
|
+
// so a filter given here also narrows the exit code to the filtered set,
|
|
552
|
+
// by construction - no separate "filtered exit code" path exists.
|
|
553
|
+
function applyFilters(result, options) {
|
|
554
|
+
const rules = new Set(options.rules ?? []);
|
|
555
|
+
const modules = new Set(options.modules ?? []);
|
|
556
|
+
if (rules.size === 0 && modules.size === 0)
|
|
557
|
+
return result;
|
|
558
|
+
return {
|
|
559
|
+
...result,
|
|
560
|
+
violations: result.violations.filter((violation) => (rules.size === 0 || rules.has(violation.rule))
|
|
561
|
+
&& (modules.size === 0 || ("todoModule" in violation && modules.has(violation.todoModule)))),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
// True when `file` is (a real, existing path to) some module's own
|
|
565
|
+
// surface file - the only kind of path rule 6's own violations are ever
|
|
566
|
+
// reported at (checkTypeLeaks groups every finding under
|
|
567
|
+
// `group.path = surfacePath`). Never forces graph.program: surfaceFiles
|
|
568
|
+
// is plain module metadata, built before anything touches the lazy
|
|
569
|
+
// program/checker getters. A file that does not exist reads as "not a
|
|
570
|
+
// surface file" here (false) rather than throwing - the real, existing
|
|
571
|
+
// ReportError for that case is filterToFile's own, thrown later at its
|
|
572
|
+
// usual point once `focusFile` is given.
|
|
573
|
+
// check()'s own realpath'd target for a `check <file>` run, or undefined
|
|
574
|
+
// for a plain `check`, a named path that doesn't exist, or a named path
|
|
575
|
+
// that exists but isn't a regular file - `statSync(...).isFile()` is the
|
|
576
|
+
// deciding check: a directory's own realpath is just as real as a file's,
|
|
577
|
+
// but scoping the rules below to it is wrong, not merely unhelpful. Each
|
|
578
|
+
// rule this file scopes reports at ONE FILE's own path (public-surface-
|
|
579
|
+
// bypass and the constraint engine at `edge.fromFile`, must-be-empty and
|
|
580
|
+
// uncovered-module at a project file), and compares that path against
|
|
581
|
+
// `focus` directly - a directory can never equal a file's own path, so
|
|
582
|
+
// scoping to one would silently drop every real violation reachable
|
|
583
|
+
// through it, rather than keeping the ones inside it. `stale-todo`
|
|
584
|
+
// reports at a MODULE's own directory, not a file, so `check <a module's
|
|
585
|
+
// directory>` can produce it only by running the full, whole-project
|
|
586
|
+
// logic and letting `filterToFile` keep it afterward, never by scoping
|
|
587
|
+
// runRules to a target no rule reports at. Every other case (undefined, or a missing path) leaves
|
|
588
|
+
// `focus` undefined for the same reason: every rule runs full and
|
|
589
|
+
// unscoped, and `filterToFile` alone decides what survives - for a
|
|
590
|
+
// missing path, that means its own "no such file" error, thrown exactly
|
|
591
|
+
// where it always was.
|
|
592
|
+
function tryRealpath(file) {
|
|
593
|
+
try {
|
|
594
|
+
const target = realpathSync(resolve(file));
|
|
595
|
+
return statSync(target).isFile() ? target : undefined;
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// Rule 6 scopes only when the requested regular file is an actual surface.
|
|
602
|
+
// Textual path comparison is refused because symlinks can name the same file.
|
|
603
|
+
function surfaceModuleForFocus(graph, file) {
|
|
604
|
+
let target;
|
|
605
|
+
try {
|
|
606
|
+
target = realpathSync(resolve(file));
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// Missing and non-resolvable paths must keep the existing unscoped error path.
|
|
610
|
+
// Guessing a module is refused because `filterToFile` owns the missing-path error.
|
|
611
|
+
return undefined;
|
|
612
|
+
}
|
|
613
|
+
for (const module of graph.modules.values()) {
|
|
614
|
+
for (const surfacePath of module.surfaceFiles) {
|
|
615
|
+
try {
|
|
616
|
+
// The module name selects the scoped closure. Returning a boolean is
|
|
617
|
+
// refused because the rule would then need to repeat the path lookup.
|
|
618
|
+
if (realpathSync(surfacePath) === target)
|
|
619
|
+
return module.name;
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
// A configured surface glob can name a path that doesn't exist yet; not a match either way.
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// A regular non-surface file cannot own a rule-6 report. Building any
|
|
627
|
+
// Program is refused because `filterToFile` would discard every finding.
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
export async function check(projectRoot, focusFile, options = {}) {
|
|
631
|
+
const configPath = resolve(projectRoot, "archstrict.config.ts");
|
|
632
|
+
const config = await loadConfig(configPath);
|
|
633
|
+
// `config.configPath` (a config-meaning violation's own `path`) is
|
|
634
|
+
// realpath'd once, here, after a successful load - `filterToFile`
|
|
635
|
+
// realpaths the file it was asked about, and every other violation's
|
|
636
|
+
// own `path` already comes from `graph.rootDir` (itself realpath'd in
|
|
637
|
+
// prepareGraph); a config-meaning violation's own path is the one
|
|
638
|
+
// exception that would otherwise stay in whatever textual form
|
|
639
|
+
// `projectRoot` arrived in, comparing unequal to `filterToFile`'s own
|
|
640
|
+
// realpath'd target when the two differ (a symlinked project root).
|
|
641
|
+
// Done after loadConfig, not before: every error loadConfig itself can
|
|
642
|
+
// throw (a missing file, a bad default export, ...) still names the
|
|
643
|
+
// exact path the caller gave, not a form it never used.
|
|
644
|
+
config.configPath = realpathSync(configPath);
|
|
645
|
+
const configLocator = createConfigLocator(config);
|
|
646
|
+
// declaredModules is the only source of scope now - loadConfig already
|
|
647
|
+
// guarantees a loaded config has it, as an array of well-shaped entries
|
|
648
|
+
// (assertDeclaredModulesShapeValid), not merely present. config.exclude
|
|
649
|
+
// keeps a project's own root-level files (archstrict.config.ts itself,
|
|
650
|
+
// dist/, etc.) out of scope entirely; `init` writes one by default.
|
|
651
|
+
const graph = (options.buildGraph ?? buildModuleGraphForRules)({
|
|
652
|
+
projectRoot,
|
|
653
|
+
declaredModules: config.declaredModules,
|
|
654
|
+
exclude: config.exclude,
|
|
655
|
+
surface: config.surface,
|
|
656
|
+
});
|
|
657
|
+
validateFilters(graph, options);
|
|
658
|
+
// A `check <file>` scoped to a file that isn't any module's own
|
|
659
|
+
// surface can never surface a rule-6 violation (filterToFile below
|
|
660
|
+
// would filter it out regardless) - skip the rule so a whole-project
|
|
661
|
+
// ts.Program is never built just to throw its answer away. Plain
|
|
662
|
+
// `check` (no focusFile) always evaluates it.
|
|
663
|
+
// Only a named surface selects scoped rule 6. Scoping a full check is
|
|
664
|
+
// refused because an unscoped caller requires every module's findings.
|
|
665
|
+
const focusedTypeLeakModule = focusFile === undefined ? undefined : surfaceModuleForFocus(graph, focusFile);
|
|
666
|
+
// A named non-surface file cannot receive a type-leak violation. Running
|
|
667
|
+
// rule 6 is refused because its complete answer would be discarded.
|
|
668
|
+
const skipTypeLeak = focusFile !== undefined && focusedTypeLeakModule === undefined;
|
|
669
|
+
const focus = focusFile === undefined ? undefined : tryRealpath(focusFile);
|
|
670
|
+
// Rule 6 is the only rule that touches `graph.program`/`graph.checker`,
|
|
671
|
+
// and runRules runs it first - release the Program right after it, so
|
|
672
|
+
// the other rules never run beside it. The notes are read before the
|
|
673
|
+
// release, which clears them along with the Program (a later access on
|
|
674
|
+
// this graph would build a fresh one and might not need the fallback a
|
|
675
|
+
// first build did). A no-op when rule 6 never ran (skipTypeLeak).
|
|
676
|
+
let programNotes = [];
|
|
677
|
+
const evaluated = runRules(graph, config, {
|
|
678
|
+
configLocator,
|
|
679
|
+
skipTypeLeak,
|
|
680
|
+
focusedTypeLeakModule,
|
|
681
|
+
focus,
|
|
682
|
+
afterTypeLeak: () => {
|
|
683
|
+
// Scoped and unscoped notes have separate lifetimes. Combining them is
|
|
684
|
+
// refused because a reused graph must not expose a scoped fallback later.
|
|
685
|
+
programNotes = [...(focusedTypeLeakModule === undefined ? graph.programNotes : graph.focusedTypeLeakNotes)];
|
|
686
|
+
graph.releaseProgram();
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
if (skipTypeLeak)
|
|
690
|
+
evaluated.typeLeaksSkippedFile = focusFile;
|
|
691
|
+
if (programNotes.length > 0)
|
|
692
|
+
evaluated.notes = [...programNotes];
|
|
693
|
+
// Skipped outright, not merely filtered afterward, when focus names a
|
|
694
|
+
// real file that isn't the config file: unlike every other rule, a real
|
|
695
|
+
// call here can cost a paid, networked --prove request (config-
|
|
696
|
+
// meaning.ts's own comment), and that cost must never be paid just to
|
|
697
|
+
// throw the answer away.
|
|
698
|
+
if (focus === undefined || focus === config.configPath) {
|
|
699
|
+
evaluated.violations.push(...locateViolations(await checkConfigMeaning(config, options.prove ?? false, options.prover), config, configLocator));
|
|
700
|
+
}
|
|
701
|
+
const result = applyTodo(graph, config, evaluated, {
|
|
702
|
+
configLocator,
|
|
703
|
+
skipStaleCheckForRules: skipTypeLeak ? ["type-leak"] : [],
|
|
704
|
+
focus,
|
|
705
|
+
includeFrozen: options.frozen,
|
|
706
|
+
});
|
|
707
|
+
const focused = focusFile === undefined ? result : filterToFile(result, focusFile);
|
|
708
|
+
if (focusFile === undefined && !hasEdgesRule(config)) {
|
|
709
|
+
focused.nextSteps = { summary: NO_EDGES_SUMMARY, do: [...NO_EDGES_DO] };
|
|
710
|
+
}
|
|
711
|
+
// Count bypasses before todo suppression, so a project that already froze
|
|
712
|
+
// the mega-module still hears that the freeze recorded one bucket.
|
|
713
|
+
if (focusFile === undefined) {
|
|
714
|
+
const bypassesByModule = new Map();
|
|
715
|
+
for (const violation of evaluated.violations) {
|
|
716
|
+
if (violation.rule !== "public-surface-bypass" || !("todoModule" in violation))
|
|
717
|
+
continue;
|
|
718
|
+
bypassesByModule.set(violation.todoModule, (bypassesByModule.get(violation.todoModule) ?? 0) + 1);
|
|
719
|
+
}
|
|
720
|
+
const dominant = dominantBypassModule([...graph.modules.values()].map((module) => ({ name: module.name, files: module.files.length })), bypassesByModule);
|
|
721
|
+
if (dominant !== undefined)
|
|
722
|
+
focused.dominantModule = dominant;
|
|
723
|
+
}
|
|
724
|
+
return applyFilters(focused, options);
|
|
725
|
+
}
|
|
726
|
+
// The output must always carry: rule id, path:line:col, evidence, because,
|
|
727
|
+
// and a do: line last. "Inference" (pks's shape) is the evidence + do
|
|
728
|
+
// pair together, not a separate field: evidence says what was found
|
|
729
|
+
// ("resolved to module Y's X"), do says what to do about it.
|
|
730
|
+
function formatViolation(v) {
|
|
731
|
+
const lines = [];
|
|
732
|
+
lines.push(`[${v.rule}] ${v.path}:${v.line}:${v.column}`);
|
|
733
|
+
if (v.frozen)
|
|
734
|
+
lines.push(" frozen: true");
|
|
735
|
+
lines.push(` ${v.evidence}`);
|
|
736
|
+
if (v.rule === "stale-todo")
|
|
737
|
+
lines.push(` entryPath: ${v.entryPath}`);
|
|
738
|
+
if (v.rule === "config-meaning") {
|
|
739
|
+
lines.push(` tier: ${v.tier}`);
|
|
740
|
+
if (v.skipped)
|
|
741
|
+
lines.push(" skipped: true");
|
|
742
|
+
else if (v.undecided)
|
|
743
|
+
lines.push(" undecided: true");
|
|
744
|
+
else
|
|
745
|
+
lines.push(` confidence: ${v.confidence}`);
|
|
746
|
+
}
|
|
747
|
+
lines.push(` because: ${v.because}`);
|
|
748
|
+
lines.push(...formatConfigPointerLines(v.config));
|
|
749
|
+
lines.push(` do: ${v.do}`);
|
|
750
|
+
if (v.rule === "tag-boundary" && v.moves?.length) {
|
|
751
|
+
lines.push(" moves:");
|
|
752
|
+
for (const move of v.moves)
|
|
753
|
+
lines.push(` ${move.kind}: ${move.do}`);
|
|
754
|
+
}
|
|
755
|
+
return lines;
|
|
756
|
+
}
|
|
757
|
+
// Measured against a real 31-module project: 2,191 lines (61 KB) for 435
|
|
758
|
+
// violations was long enough that a tool display cut the middle, forcing a
|
|
759
|
+
// rerun with `--json` and a separate jq pass just to see what was omitted.
|
|
760
|
+
// 60 lines of grouped detail stays inside one screen and one tool-display
|
|
761
|
+
// page even before the fixed footer below is added.
|
|
762
|
+
export const CHECK_DETAIL_LINE_CAP = 60;
|
|
763
|
+
// Not a real module: rule 2 (cycle) and every rule reporting at
|
|
764
|
+
// config.configPath have no module of their own to group under, and a
|
|
765
|
+
// filter or a group naming a real module could never match one of these.
|
|
766
|
+
const UNGROUPED_MODULE = "<project>";
|
|
767
|
+
// 80%, not "any": a project with a real, chosen surface can still have a
|
|
768
|
+
// handful of genuine bypasses into one module that hasn't gotten one yet -
|
|
769
|
+
// that's the ordinary, one-violation-at-a-time case this note is not for.
|
|
770
|
+
// This note is for the first-run shape instead: most bypasses sharing one
|
|
771
|
+
// root cause (no module in the project chose a surface at all), which "add
|
|
772
|
+
// a surface file" fixes project-wide, not one violation at a time.
|
|
773
|
+
const SURFACE_LESS_NOTE_THRESHOLD = 0.8;
|
|
774
|
+
function dominantModuleLines(result) {
|
|
775
|
+
const dominant = result.dominantModule;
|
|
776
|
+
if (dominant === undefined)
|
|
777
|
+
return [];
|
|
778
|
+
return [
|
|
779
|
+
`note: ${dominantBypassSentence(dominant)}. Freezing them records one bucket, and hotspots then report one score.`,
|
|
780
|
+
`do: split '${dominant.name}' into directories that change together before archstrict todo`,
|
|
781
|
+
"do: archstrict recommend",
|
|
782
|
+
];
|
|
783
|
+
}
|
|
784
|
+
function surfaceLessNote(result) {
|
|
785
|
+
const missing = new Set(result.modulesWithoutSurfaceNames);
|
|
786
|
+
const bypasses = result.violations.filter((violation) => violation.rule === "public-surface-bypass");
|
|
787
|
+
const missingCount = bypasses.filter((violation) => "todoModule" in violation && violation.todoModule !== undefined && missing.has(violation.todoModule)).length;
|
|
788
|
+
if (bypasses.length === 0 || missingCount / bypasses.length < SURFACE_LESS_NOTE_THRESHOLD)
|
|
789
|
+
return [];
|
|
790
|
+
return [
|
|
791
|
+
`note: ${missingCount} of ${bypasses.length} public-surface-bypass violations target modules without a public surface`,
|
|
792
|
+
"do: archstrict todo # freeze them for now",
|
|
793
|
+
// `archstrict recommend --json` now proposes a ranked surface per
|
|
794
|
+
// surface-less module directly from the real import graph
|
|
795
|
+
// (`surfaceProposals`), instead of sending the reader to config.md to
|
|
796
|
+
// guess one by hand.
|
|
797
|
+
"do: archstrict recommend --json # read surfaceProposals for a ranked surface per module, from its own real importers",
|
|
798
|
+
];
|
|
799
|
+
}
|
|
800
|
+
// Exported so a test can assert on the grouping itself: once the printed
|
|
801
|
+
// text cuts a group's own line from view (CHECK_DETAIL_LINE_CAP), the count
|
|
802
|
+
// "every violation lands in exactly one group" can no longer be read back
|
|
803
|
+
// out of the text alone.
|
|
804
|
+
export function groupViolations(violations) {
|
|
805
|
+
const groups = new Map();
|
|
806
|
+
for (const violation of violations) {
|
|
807
|
+
const moduleName = "todoModule" in violation ? violation.todoModule : UNGROUPED_MODULE;
|
|
808
|
+
const key = `${violation.rule}\0${moduleName}`;
|
|
809
|
+
const group = groups.get(key) ?? { rule: violation.rule, moduleName, violations: [] };
|
|
810
|
+
group.violations.push(violation);
|
|
811
|
+
groups.set(key, group);
|
|
812
|
+
}
|
|
813
|
+
// Largest group first: the cap cuts groups, not violations within a
|
|
814
|
+
// group, so showing the smallest groups first (a plain alphabetical
|
|
815
|
+
// sort did exactly that) buried the modules with the most real work
|
|
816
|
+
// behind the cut - measured directly against nukadoko's own src (26
|
|
817
|
+
// groups), where a 2-violation group printed while a 49-violation one
|
|
818
|
+
// fell into "omitted". Ties break by rule id, then module name, so the
|
|
819
|
+
// same input always prints the same order.
|
|
820
|
+
return [...groups.values()].sort((left, right) => right.violations.length - left.violations.length
|
|
821
|
+
|| left.rule.localeCompare(right.rule)
|
|
822
|
+
|| left.moduleName.localeCompare(right.moduleName));
|
|
823
|
+
}
|
|
824
|
+
function filterArgsFor(group) {
|
|
825
|
+
return group.moduleName === UNGROUPED_MODULE
|
|
826
|
+
? `--rule ${group.rule}`
|
|
827
|
+
: `--rule ${group.rule} --module ${group.moduleName}`;
|
|
828
|
+
}
|
|
829
|
+
// A single group already carries exactly one rule and one module - either
|
|
830
|
+
// because the whole project happens to have only one, or because `--rule`/
|
|
831
|
+
// `--module` already narrowed to it. Its own drill-down `do:` would repeat
|
|
832
|
+
// the same filter and print this same truncated text again, not new
|
|
833
|
+
// information, so full violations are listed directly up to the cap
|
|
834
|
+
// instead, and `--json` (never truncated) is offered for the remainder.
|
|
835
|
+
function appendSingleGroupDetail(lines, group) {
|
|
836
|
+
const filterArgs = filterArgsFor(group);
|
|
837
|
+
let shown = 0;
|
|
838
|
+
for (const violation of group.violations) {
|
|
839
|
+
const block = formatViolation(violation);
|
|
840
|
+
const reserve = shown + 1 < group.violations.length ? 2 : 0;
|
|
841
|
+
if (lines.length + block.length + reserve > CHECK_DETAIL_LINE_CAP)
|
|
842
|
+
break;
|
|
843
|
+
lines.push(...block);
|
|
844
|
+
shown++;
|
|
845
|
+
}
|
|
846
|
+
if (shown < group.violations.length) {
|
|
847
|
+
lines.push(`violations omitted: ${group.violations.length - shown}`);
|
|
848
|
+
lines.push(`do: archstrict check ${filterArgs} --json`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
// Keeps one rule's own module/count listing on one physical line even when
|
|
852
|
+
// a project has dozens of surface-less modules sharing that rule. Other
|
|
853
|
+
// `do:` lines in this file already run past a typical terminal width, but
|
|
854
|
+
// a comma-joined list has no natural break of its own without a limit -
|
|
855
|
+
// unbounded, it would defeat the reason this summary replaced a bare
|
|
856
|
+
// "N omitted" count in the first place.
|
|
857
|
+
const OMITTED_SUMMARY_LINE_WIDTH = 100;
|
|
858
|
+
// The groups the cap above cut, condensed to one line per rule instead of
|
|
859
|
+
// dropped silently: `<rule>: <module> <count>, <module> <count>, ...`,
|
|
860
|
+
// each rule's own modules already in count-descending order (a subsequence
|
|
861
|
+
// of `ordered`, itself sorted that way). A rule's own listing truncates
|
|
862
|
+
// with "+N more" past OMITTED_SUMMARY_LINE_WIDTH, but always keeps at
|
|
863
|
+
// least its first entry, so a lone very-long module name still prints
|
|
864
|
+
// something rather than an empty line. The one `do:` drills into the
|
|
865
|
+
// single largest omitted group project-wide (`omitted[0]`, the first
|
|
866
|
+
// entry of a suffix of the already count-sorted `ordered`), not `--json`:
|
|
867
|
+
// naming the biggest remaining group is the one next step worth taking,
|
|
868
|
+
// not a request for the whole, unbounded report.
|
|
869
|
+
function summarizeOmittedGroups(omitted) {
|
|
870
|
+
const byRule = new Map();
|
|
871
|
+
for (const group of omitted) {
|
|
872
|
+
const groups = byRule.get(group.rule) ?? [];
|
|
873
|
+
groups.push(group);
|
|
874
|
+
byRule.set(group.rule, groups);
|
|
875
|
+
}
|
|
876
|
+
const lines = [];
|
|
877
|
+
for (const ruleId of [...byRule.keys()].sort()) {
|
|
878
|
+
const groups = byRule.get(ruleId);
|
|
879
|
+
const prefix = `${ruleId}: `;
|
|
880
|
+
const entries = [];
|
|
881
|
+
for (const group of groups) {
|
|
882
|
+
const entry = `${group.moduleName} ${group.violations.length}`;
|
|
883
|
+
const candidate = [...entries, entry].join(", ");
|
|
884
|
+
if (entries.length > 0 && prefix.length + candidate.length > OMITTED_SUMMARY_LINE_WIDTH)
|
|
885
|
+
break;
|
|
886
|
+
entries.push(entry);
|
|
887
|
+
}
|
|
888
|
+
const remainder = groups.length - entries.length;
|
|
889
|
+
lines.push(`${prefix}${entries.join(", ")}${remainder > 0 ? `, +${remainder} more` : ""}`);
|
|
890
|
+
}
|
|
891
|
+
lines.push(`do: archstrict check ${filterArgsFor(omitted[0])}`);
|
|
892
|
+
return lines;
|
|
893
|
+
}
|
|
894
|
+
function groupedViolationLines(result) {
|
|
895
|
+
const ordered = groupViolations(result.violations);
|
|
896
|
+
const lines = [
|
|
897
|
+
`violations: ${result.violations.length} in ${ordered.length} group${ordered.length === 1 ? "" : "s"}`,
|
|
898
|
+
...(result.dominantModule !== undefined ? dominantModuleLines(result) : surfaceLessNote(result)),
|
|
899
|
+
];
|
|
900
|
+
if (ordered.length === 1) {
|
|
901
|
+
appendSingleGroupDetail(lines, ordered[0]);
|
|
902
|
+
return lines;
|
|
903
|
+
}
|
|
904
|
+
const blocks = ordered.map((group) => [
|
|
905
|
+
`[${group.rule}] module '${group.moduleName}': ${group.violations.length} violation(s)`,
|
|
906
|
+
" example:",
|
|
907
|
+
...formatViolation(group.violations[0]),
|
|
908
|
+
` do: archstrict check ${filterArgsFor(group)}`,
|
|
909
|
+
]);
|
|
910
|
+
// First pass: a cheap 2-line placeholder for the footer (the common
|
|
911
|
+
// case - most cuts omit groups sharing one, or a handful of, rules).
|
|
912
|
+
let shown = 0;
|
|
913
|
+
let total = lines.length;
|
|
914
|
+
for (const block of blocks) {
|
|
915
|
+
const reserve = shown + 1 < ordered.length ? 2 : 0;
|
|
916
|
+
if (total + block.length + reserve > CHECK_DETAIL_LINE_CAP)
|
|
917
|
+
break;
|
|
918
|
+
total += block.length;
|
|
919
|
+
shown++;
|
|
920
|
+
}
|
|
921
|
+
// Every group fit: no footer at all, so the placeholder's guess never
|
|
922
|
+
// gets checked against a real one (there is no omitted group to summarize).
|
|
923
|
+
if (shown === ordered.length) {
|
|
924
|
+
lines.push(...blocks.flat());
|
|
925
|
+
return lines;
|
|
926
|
+
}
|
|
927
|
+
// The real footer (one line per distinct omitted rule, plus one `do:`)
|
|
928
|
+
// can need more than the 2-line placeholder once omitted groups span
|
|
929
|
+
// several rules - shrink `shown` until the real total fits, rather than
|
|
930
|
+
// let the placeholder's own guess push past the cap.
|
|
931
|
+
while (shown > 0) {
|
|
932
|
+
const footer = summarizeOmittedGroups(ordered.slice(shown));
|
|
933
|
+
const shownLines = blocks.slice(0, shown).flat();
|
|
934
|
+
if (lines.length + shownLines.length + footer.length <= CHECK_DETAIL_LINE_CAP) {
|
|
935
|
+
lines.push(...shownLines, ...footer);
|
|
936
|
+
return lines;
|
|
937
|
+
}
|
|
938
|
+
shown--;
|
|
939
|
+
}
|
|
940
|
+
// Nothing at all fit alongside the header/note - the omitted summary
|
|
941
|
+
// still prints alone, rather than the header claiming groups exist with
|
|
942
|
+
// no detail about any of them.
|
|
943
|
+
lines.push(...summarizeOmittedGroups(ordered));
|
|
944
|
+
return lines;
|
|
945
|
+
}
|
|
946
|
+
// Up to 20 violations print in full, unchanged from before grouping
|
|
947
|
+
// existed: a `check <file>` run rarely exceeds this, and a project's first
|
|
948
|
+
// whole-project run past it is exactly the shape grouping exists for.
|
|
949
|
+
export function formatText(result) {
|
|
950
|
+
const lines = result.violations.length <= 20
|
|
951
|
+
? result.violations.flatMap(formatViolation)
|
|
952
|
+
: groupedViolationLines(result);
|
|
953
|
+
if (result.violations.length <= 20) {
|
|
954
|
+
lines.push(...(result.dominantModule !== undefined ? dominantModuleLines(result) : surfaceLessNote(result)));
|
|
955
|
+
}
|
|
956
|
+
for (const s of result.suggestions) {
|
|
957
|
+
lines.push(`[${s.rule}] ${s.path}:${s.line}:${s.column}`);
|
|
958
|
+
lines.push(` ${s.evidence}`);
|
|
959
|
+
lines.push(` do: ${s.do}`);
|
|
960
|
+
}
|
|
961
|
+
// One fact per line, not a comma-joined blob: an agent reading text
|
|
962
|
+
// output (not JSON) reads lines, and each of these is a count where a
|
|
963
|
+
// 0 could otherwise look like an omission rather than a checked fact,
|
|
964
|
+
// so it is always printed, even when it's 0.
|
|
965
|
+
lines.push(`modules: ${result.modules}`);
|
|
966
|
+
lines.push(`modules without a public surface: ${result.modulesWithoutSurface}`);
|
|
967
|
+
lines.push(`edges: ${result.edges}`);
|
|
968
|
+
lines.push(`not covered by any declared module: ${result.outsideFiles}`);
|
|
969
|
+
if (result.nonTsSourceFiles > 0) {
|
|
970
|
+
lines.push(`non-.ts source files present, not analyzed: ${result.nonTsSourceFiles}`);
|
|
971
|
+
}
|
|
972
|
+
lines.push(`unresolved specifiers: ${result.unresolvedSpecifiers}`);
|
|
973
|
+
if (result.unresolvedSpecifierBreakdown.length > 0) {
|
|
974
|
+
const breakdown = result.unresolvedSpecifierBreakdown.map((b) => `${b.prefix} (${b.count})`).join(", ");
|
|
975
|
+
lines.push(` top unresolved prefixes: ${breakdown}`);
|
|
976
|
+
}
|
|
977
|
+
lines.push(`unsupported syntax: ${result.unsupportedSyntax}`);
|
|
978
|
+
lines.push(result.typeLeaks === null
|
|
979
|
+
? `type leaks: not checked (${result.typeLeaksSkippedFile} is not a module surface file)`
|
|
980
|
+
: `type leaks: ${result.typeLeaks}`);
|
|
981
|
+
lines.push(`todo: ${result.todo}`);
|
|
982
|
+
for (const note of result.notes ?? [])
|
|
983
|
+
lines.push(`note: ${note}`);
|
|
984
|
+
// Before the finding's own do: line, so the last line stays the one
|
|
985
|
+
// concrete action for this run's findings.
|
|
986
|
+
if (result.nextSteps !== undefined) {
|
|
987
|
+
lines.push(`summary: ${result.nextSteps.summary}`);
|
|
988
|
+
for (const step of result.nextSteps.do)
|
|
989
|
+
lines.push(`do: ${step}`);
|
|
990
|
+
}
|
|
991
|
+
// A do: line only when there is a concrete next action - a clean
|
|
992
|
+
// check has none, and telling the reader to re-run the command that
|
|
993
|
+
// just produced this clean result is circular, unlike every other
|
|
994
|
+
// do: this tool ever prints (each names the one thing to actually
|
|
995
|
+
// do about a real finding). While any uncovered-module violation
|
|
996
|
+
// exists, `archstrict todo` would be circular too: todo's first run
|
|
997
|
+
// freezes every OTHER freezable violation, and a file matching no
|
|
998
|
+
// declared module can never be frozen into a module's own todo (it
|
|
999
|
+
// has no module to freeze it into) - so the fix is to cover the file
|
|
1000
|
+
// first, not to run todo.
|
|
1001
|
+
if (result.violations.some((v) => v.rule === "uncovered-module")) {
|
|
1002
|
+
lines.push(`do: add each uncovered-module file to declaredModules or exclude in archstrict.config.ts, then run archstrict check`);
|
|
1003
|
+
}
|
|
1004
|
+
else if (result.dominantModule !== undefined && result.violations.some((v) => v.rule !== "config-meaning" && !v.frozen)) {
|
|
1005
|
+
lines.push(`do: split module '${result.dominantModule.name}' before archstrict todo`);
|
|
1006
|
+
}
|
|
1007
|
+
else if (result.violations.some((v) => v.rule !== "config-meaning" && !v.frozen)) {
|
|
1008
|
+
lines.push(`do: archstrict todo`);
|
|
1009
|
+
}
|
|
1010
|
+
return lines.join("\n") + "\n";
|
|
1011
|
+
}
|