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.
Files changed (67) hide show
  1. package/.agents/hooks/hooks.json +29 -0
  2. package/.agents/hooks/post-tool-use.mjs +107 -0
  3. package/.agents/hooks/pre-tool-use.mjs +182 -0
  4. package/.agents/mcp/server.mjs +71 -0
  5. package/.agents/plugin.json +19 -0
  6. package/AGENTS.md +81 -0
  7. package/CHANGELOG.md +77 -0
  8. package/README.ja.md +142 -0
  9. package/README.md +143 -2
  10. package/dist/augmentation-cache.js +65 -0
  11. package/dist/check-options.js +40 -0
  12. package/dist/classify.js +148 -0
  13. package/dist/cli.js +243 -0
  14. package/dist/config-pointer.js +251 -0
  15. package/dist/config.js +194 -0
  16. package/dist/edge-cache.js +530 -0
  17. package/dist/gitignore.js +271 -0
  18. package/dist/mcp-server.js +111 -0
  19. package/dist/module-candidates.js +125 -0
  20. package/dist/module-graph.js +2179 -0
  21. package/dist/project-path.js +59 -0
  22. package/dist/report-error.js +13 -0
  23. package/dist/rules/config-meaning.js +143 -0
  24. package/dist/rules/constraints.js +419 -0
  25. package/dist/rules/cycles.js +285 -0
  26. package/dist/rules/deprecated.js +67 -0
  27. package/dist/rules/empty-rule.js +101 -0
  28. package/dist/rules/moves.js +79 -0
  29. package/dist/rules/must-be-empty.js +52 -0
  30. package/dist/rules/public-surface.js +100 -0
  31. package/dist/rules/uncovered.js +75 -0
  32. package/dist/todo-migration.js +112 -0
  33. package/dist/todo-store.js +434 -0
  34. package/dist/type-closure.js +959 -0
  35. package/dist/type-leak.js +590 -0
  36. package/dist/verbs/agents.js +116 -0
  37. package/dist/verbs/check.js +1011 -0
  38. package/dist/verbs/fix.js +170 -0
  39. package/dist/verbs/hotspots.js +261 -0
  40. package/dist/verbs/init.js +538 -0
  41. package/dist/verbs/map-shape.js +78 -0
  42. package/dist/verbs/recommend.js +863 -0
  43. package/dist/verbs/rules.js +188 -0
  44. package/dist/verbs/search.js +109 -0
  45. package/dist/verbs/simulate.js +220 -0
  46. package/dist/verbs/todo.js +180 -0
  47. package/dist/warm-graph.js +82 -0
  48. package/docs/boundary-patterns.md +374 -0
  49. package/docs/calibrated-rules-design.md +124 -0
  50. package/docs/init-singleton-modules.md +133 -0
  51. package/docs/maintenance.md +109 -0
  52. package/docs/releasing.md +58 -0
  53. package/docs/rules-edge-cache.md +50 -0
  54. package/docs/todo-single-file-migration.md +58 -0
  55. package/llms.txt +25 -0
  56. package/package.json +61 -4
  57. package/skills/archstrict/SKILL.md +54 -0
  58. package/skills/archstrict/references/agents-verb.md +39 -0
  59. package/skills/archstrict/references/config.md +116 -0
  60. package/skills/archstrict/references/hook.md +57 -0
  61. package/skills/archstrict/references/path-rules.md +57 -0
  62. package/skills/archstrict/references/patterns.md +915 -0
  63. package/skills/archstrict/references/prove-rules.md +58 -0
  64. package/skills/archstrict/references/rearchitect.md +66 -0
  65. package/skills/archstrict/references/recommend.md +98 -0
  66. package/skills/archstrict/references/rules.md +149 -0
  67. 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
+ }