archstrict 0.0.0 → 0.1.0

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