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,522 @@
1
+ // Responsibility: the `init` verb. On a fresh project (no
2
+ // archstrict.config.ts yet), walks the project's own real files with
3
+ // check's own eligibility rule (module-graph.ts's listAnalyzedFiles) and
4
+ // declares one module per top-level directory that holds an analyzed .ts
5
+ // file, and one single-file module per loose top-level .ts file - so every
6
+ // file the first `check` analyzes already belongs to exactly one module,
7
+ // by construction. On a re-run, init never touches an existing config: it
8
+ // only re-reads it and regenerates archstrict.types.ts (the ModuleName
9
+ // union) from its own declaredModules names.
10
+ // Boundary: file I/O, argument parsing, and text generation only. Grouping
11
+ // files into modules and naming them is module-candidates.ts's job (a pure
12
+ // function of the file list); init only decides WHICH files and anchors
13
+ // that function sees, and writes what it returns.
14
+ //
15
+ // Singletons over one catch-all module or a menu of shapes: a project
16
+ // whose first init already covers every analyzed file needs no
17
+ // uncovered-module violation todo could never freeze away (an unfreezable
18
+ // violation, since a file matching no module has no module directory to
19
+ // freeze it into) - the trap an inventory-only design (declare directories,
20
+ // leave loose files uncovered) falls into on a real, unconventional
21
+ // codebase. A single catch-all module for every loose file was rejected
22
+ // too: it produces a degenerate public surface (every loose file's own
23
+ // exports at once) and a glob whose own base directory is the project
24
+ // root, which - with a real node_modules present - puts node_modules
25
+ // itself inside rule 6's own type-leak boundary.
26
+ import { existsSync, readdirSync, statSync, writeFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { ANALYZED_EXTENSIONS, DEFAULT_SURFACE, listAnalyzedFiles, moduleForDeclaredFile, toProjectRelativePosix, } from "../module-graph.js";
29
+ import { groupAnalyzedFiles, nameCandidates, declaredModuleEntryText, suggestUncovered, } from "../module-candidates.js";
30
+ import { compileGlob } from "../classify.js";
31
+ import { SCHEMA_VERSION } from "../config.js";
32
+ import { ReportError } from "../report-error.js";
33
+ import { loadConfig } from "./check.js";
34
+ const DO_INIT = "archstrict init";
35
+ const DO_CHECK = "archstrict check";
36
+ // The re-run's own uncovered-file scan: every analyzed file the config's
37
+ // own exclude and declaredModules leave uncovered, grouped and named the
38
+ // same way rule 3's do: and a fresh init's own groups are - so pasting
39
+ // every line this prints, verbatim, leaves 0 uncovered-module (Hegel P7).
40
+ // `moduleForDeclaredFile` is the exact predicate buildModuleGraph itself
41
+ // uses to decide `outsideFiles` (module-graph.ts's own prepareGraph), so
42
+ // this list agrees with what the next real check would report, without
43
+ // building a whole ts.Program just to ask that question.
44
+ function uncoveredGroups(projectRoot, declaredModules, exclude, surface) {
45
+ const files = listAnalyzedFiles(projectRoot, exclude, declaredModules, surface);
46
+ const uncovered = files.filter((f) => moduleForDeclaredFile(f, projectRoot, declaredModules) === undefined);
47
+ const rel = uncovered.map((f) => toProjectRelativePosix(f, projectRoot));
48
+ return suggestUncovered(rel, declaredModules);
49
+ }
50
+ function fail(message, doText) {
51
+ throw new ReportError(message, doText);
52
+ }
53
+ // A name must be near-universally non-source across ordinary TypeScript
54
+ // projects, not merely something one specific project happened to use
55
+ // (docs/, migrations/, and this project's own plugin/skills directories
56
+ // are real source in some real projects, so they stay out) - init only
57
+ // ever excludes a name from this list when it finds a real top-level
58
+ // directory of that name on disk, never blindly. dist/ is deliberately
59
+ // absent: listAnalyzedFiles already drops every path with a dist segment,
60
+ // so a "dist/**" exclude entry would change nothing real, only add a line
61
+ // nobody ever needs to remove.
62
+ const NOISE_DIR_CANDIDATES = ["test", "tests", "example", "examples", "spike", "build", "coverage", "fixtures", "e2e", "tmp"];
63
+ const OWN_FILES = ["archstrict.config.ts", "archstrict.types.ts"];
64
+ // tsc's own default `include` already skips every hidden path; ts.sys's
65
+ // own readDirectory does not, which floods a first check with files from
66
+ // tool-state directories (.git, an editor's own cache) that were never
67
+ // really project source. These two patterns are the same on every
68
+ // machine (unlike naming a specific hidden directory found on disk, which
69
+ // would put a local, one-machine name into a committed config) - one for
70
+ // a hidden directory at the project root, one for a hidden directory at
71
+ // any deeper level.
72
+ const HIDDEN_EXCLUDE = [".*/**", "**/.*/**"];
73
+ // A colocated test file imports across module boundaries as a fixture.
74
+ // Measured across 50 popular TypeScript codebases: about 7.6% of public-
75
+ // surface-bypass findings trace to one of these three naming conventions
76
+ // alone (*.test.ts, *.spec.ts, __tests__/) - a separate, larger share
77
+ // traces to a test/ or tests/ directory (init excludes a top-level one as
78
+ // noise) - and roughly two of every five
79
+ // test files sit beside the production file they test, not in a
80
+ // directory of their own. So init excludes each real test-file naming
81
+ // convention it finds on disk, the same never-blindly discipline
82
+ // NOISE_DIR_CANDIDATES already follows.
83
+ // One entry per (test|spec) suffix crossed with every analyzed extension,
84
+ // plus the `__tests__/` directory convention. `root` and `nested` are
85
+ // always both added: compileGlob's own `**/` needs a literal slash, so
86
+ // `**/*.test.ts` alone never matches a file sitting at the project root
87
+ // (see HIDDEN_EXCLUDE's own two-pattern split, the same reason).
88
+ const TEST_FILE_EXCLUDE_CANDIDATES = [
89
+ ...["test", "spec"].flatMap((suffix) => ANALYZED_EXTENSIONS.map((ext) => ({
90
+ label: `*.${suffix}${ext}`,
91
+ root: `*.${suffix}${ext}`,
92
+ nested: `**/*.${suffix}${ext}`,
93
+ }))),
94
+ { label: "__tests__/", root: "__tests__/**", nested: "**/__tests__/**" },
95
+ ];
96
+ // Which real test-file conventions the walk found, and how many analyzed
97
+ // files (root or nested) each one matches - computed against `files`
98
+ // BEFORE these globs are folded into the exclude used for grouping, since
99
+ // that's the file set the convention needs to be real against. Never
100
+ // blind: a convention this project never used (say, `.spec.cts` in a
101
+ // project with no `.cts` file at all) adds nothing.
102
+ function findTestFileExcludes(files) {
103
+ const result = [];
104
+ for (const c of TEST_FILE_EXCLUDE_CANDIDATES) {
105
+ const rootTest = compileGlob(c.root).test;
106
+ const nestedTest = compileGlob(c.nested).test;
107
+ const fileCount = files.filter((f) => rootTest(f) || nestedTest(f)).length;
108
+ if (fileCount > 0)
109
+ result.push({ label: c.label, exclude: [c.root, c.nested], fileCount });
110
+ }
111
+ return result;
112
+ }
113
+ function isRealDirectory(path) {
114
+ return existsSync(path) && statSync(path).isDirectory();
115
+ }
116
+ function findNoiseDirs(projectRoot, keptOpen) {
117
+ // A container named on the command line is real source the caller
118
+ // asked to open, never treated as noise - `archstrict init test` opens
119
+ // test/ and does not also exclude it.
120
+ // Matched against the exact entries readdirSync returns, not
121
+ // existsSync(join(projectRoot, name)) - existsSync resolves through a
122
+ // case-insensitive filesystem, so a real `Test/` would otherwise match
123
+ // the candidate name "test" and init would exclude a directory that
124
+ // isn't there under that spelling (and declare it a module too, since
125
+ // the walk itself finds "Test/" by its real name).
126
+ const onDisk = new Set(readdirSync(projectRoot));
127
+ return NOISE_DIR_CANDIDATES.filter((name) => name !== keptOpen && onDisk.has(name) && statSync(join(projectRoot, name)).isDirectory());
128
+ }
129
+ // The argument table's own syntax rules - stripping a trailing "/*" or
130
+ // "/", rejecting a leftover glob character, a leftover "/", a hidden name,
131
+ // or a name init never analyzes anyway. Applied on every run, fresh or
132
+ // re-run: a re-run ignores a valid directory argument (see the re-run
133
+ // section below), but a syntactically invalid one is still an error, not
134
+ // silently ignored. Returns "" for "no container" (the project root
135
+ // alone), and undefined when no argument was given at all.
136
+ //
137
+ // `verb` names the command in every message and `do:` line - "init" by
138
+ // default, so init's own text stays byte-identical. recommend reuses this
139
+ // same walk for its own directory argument (no config yet, so no
140
+ // declaredModules to fall back on) and passes "recommend" instead, so a
141
+ // bad argument there is never told to run a command that isn't the one
142
+ // the reader typed.
143
+ export function normalizeDirArg(raw, verb = "init") {
144
+ if (raw === undefined)
145
+ return undefined;
146
+ if (raw === "." || raw === "./" || raw === "*")
147
+ return "";
148
+ const doVerb = `archstrict ${verb}`;
149
+ const stripped = raw.replace(/\/\*$/, "").replace(/\/+$/, "");
150
+ if (/[*?[\]{}]/.test(stripped))
151
+ fail(`${verb} takes a directory name, not the glob '${raw}'`, doVerb);
152
+ if (stripped.includes("/"))
153
+ fail(`${verb} takes one top-level directory name, not '${raw}'`, doVerb);
154
+ if (stripped.startsWith(".")) {
155
+ fail(`${verb} does not open the hidden directory '${stripped}': the exclude that init writes skips hidden directories`, doVerb);
156
+ }
157
+ if (stripped === "node_modules" || stripped === "dist") {
158
+ fail(`${verb} does not open '${stripped}': check never analyzes it`, doVerb);
159
+ }
160
+ return stripped;
161
+ }
162
+ function plural(n, one, many) {
163
+ return `${n} ${n === 1 ? one : many}`;
164
+ }
165
+ function countLabel(groups) {
166
+ const dirs = groups.filter((g) => g.kind === "dir").length;
167
+ const files = groups.filter((g) => g.kind === "file").length;
168
+ return `${plural(dirs, "directory", "directories")}, ${plural(files, "file", "files")}`;
169
+ }
170
+ const q = JSON.stringify;
171
+ function configText(opened, containerGroups, topGroups, exclude, noiseDirs, testFileExcludes) {
172
+ const lines = [];
173
+ if (containerGroups.length > 0) {
174
+ lines.push(` // Each directory and TypeScript source file directly in ${opened}/.`);
175
+ lines.push(...containerGroups.map((g) => ` ${declaredModuleEntryText(g.entry)},`));
176
+ }
177
+ if (topGroups.length > 0) {
178
+ lines.push(opened !== ""
179
+ ? ` // Each other top-level directory that holds TypeScript source, and each top-level TypeScript source file.`
180
+ : ` // Each top-level directory that holds TypeScript source, and each top-level TypeScript source file.`);
181
+ lines.push(...topGroups.map((g) => ` ${declaredModuleEntryText(g.entry)},`));
182
+ }
183
+ const noiseComment = noiseDirs.length > 0
184
+ ? `\n // - common noise directories that init found on disk (${noiseDirs.join(", ")}).\n // Remove one of these entries if that directory holds module content.`
185
+ : "";
186
+ const testComment = testFileExcludes.length > 0
187
+ ? `\n // - colocated test files, found on disk (${testFileExcludes.map((t) => t.label).join(", ")}).\n // A test file imports across modules as a fixture; boundary rules read production code.\n // Remove both matching entries below (root and nested form) if that file must stay analyzed.`
188
+ : "";
189
+ return `import type { Config } from "./archstrict.types.js";
190
+
191
+ // Public surface: other modules may import a directory module only through
192
+ // its own surface file (named by \`surface\` below), or through the files its own
193
+ // package.json exports map names. An import that reaches any other file in
194
+ // the directory is a violation. A directory module with no such file is
195
+ // entirely private. A module whose glob names one file is that file, so its
196
+ // entry names the file itself as its surface.
197
+ export default {
198
+ schemaVersion: ${SCHEMA_VERSION},
199
+ surface: [${DEFAULT_SURFACE.map((s) => q(s)).join(", ")}],
200
+ // Kept out of analysis entirely:
201
+ // - archstrict's own two files, which are never module content;
202
+ // - hidden directories at any depth (.git, tool state), which tsc's own
203
+ // default include also skips;${noiseComment}${testComment}
204
+ exclude: [
205
+ ${exclude.map((e) => ` ${q(e)},`).join("\n")}
206
+ ],
207
+ // init declared one module per directory that holds TypeScript source and
208
+ // one per TypeScript source file, so every file that check analyzes
209
+ // belongs to exactly one module. Merge, rename, or remove entries freely:
210
+ // init never rewrites this file. After an edit, run archstrict init to
211
+ // regenerate archstrict.types.ts.
212
+ declaredModules: [
213
+ ${lines.join("\n")}
214
+ ],
215
+ because: "archstrict init: one module per directory that holds TypeScript source and per TypeScript source file, so the first check covers every file it analyzes",
216
+ } satisfies Config;
217
+ `;
218
+ }
219
+ function generatedFileContents(moduleNames) {
220
+ const union = moduleNames.length > 0 ? moduleNames.map((n) => JSON.stringify(n)).join(" | ") : "never";
221
+ return `// Generated by archstrict init from archstrict.config.ts's own
222
+ // declaredModules. Do not edit this file directly: after adding, removing,
223
+ // or renaming a declaredModules entry, run archstrict init again to
224
+ // regenerate this union to match.
225
+ export type ModuleName = ${union};
226
+
227
+ // configPath is added by the loader, not written here - a config file
228
+ // cannot know its own path.
229
+ export type Config = {
230
+ // The schema this config was written for. ${SCHEMA_VERSION} is the only
231
+ // value archstrict reads. Omit it and the loader treats the file as
232
+ // schema ${SCHEMA_VERSION}.
233
+ schemaVersion?: ${SCHEMA_VERSION};
234
+ surface?: string | readonly string[];
235
+ deprecated?: readonly {
236
+ from: ModuleName;
237
+ to: ModuleName;
238
+ count: number;
239
+ because: string;
240
+ }[];
241
+ // Module names whose todo entries may only shrink, never gain a new one.
242
+ // check reports any existing entry for one of these modules as a
243
+ // violation in its own right.
244
+ strict?: readonly ModuleName[];
245
+ // A specific known cycle (naming any two modules in it, in either
246
+ // order) exempted from rule 2 - an entry naming a pair no longer in any
247
+ // real cycle is itself flagged (stale-cycle-exception).
248
+ ignoredCycles?: readonly (readonly [string, string])[];
249
+ // An analysis boundary narrower than the whole project - not yet read
250
+ // by any rule or verb (declared here for forward compatibility; wiring
251
+ // it in is separate, later work).
252
+ scope?: string;
253
+ // Glob patterns kept out of analysis entirely - not a member of any
254
+ // module, not a source of edges, not a target either. init writes one
255
+ // default: this project's own root-level files (archstrict.config.ts,
256
+ // archstrict.types.ts) are never module content.
257
+ exclude?: readonly string[];
258
+ // glob -> tags, most-specific-glob-wins. Independent of declaredModules
259
+ // below - tags classify any file; declaredModules says which files form
260
+ // an enforced module boundary.
261
+ classify?: readonly { glob: string; tags: readonly string[] }[];
262
+ // Ambient tagging by directory-name segment: the nearest path segment
263
+ // matching one of \`names\`, walking from the file outward, becomes
264
+ // \`\${tagNamespace}:\${name}\`. Independent of \`classify\` above - a file
265
+ // can carry tags from both mechanisms at once.
266
+ classifyByDirectoryName?: {
267
+ tagNamespace: string;
268
+ names: readonly string[];
269
+ };
270
+ // Declared modules - the source of truth for module boundaries.
271
+ // \`surface\` may itself be a glob (a module's public surface can be
272
+ // more than one file).
273
+ declaredModules: readonly {
274
+ name: ModuleName;
275
+ glob: string;
276
+ // A single glob, or several - a real package can publish more than
277
+ // one real, differently-shaped public entry point at once. Optional:
278
+ // when absent, a real package.json's own exports map at this
279
+ // module's own root is derived back to source instead, falling back
280
+ // to this project's own top-level surface default otherwise.
281
+ surface?: string | readonly string[];
282
+ // Rule 1's own "friend" exception: \`file\` (relative to this module,
283
+ // may itself be a glob) is public to exactly the importers \`from\`
284
+ // (a project-relative glob) matches, private to everyone else -
285
+ // unlike \`surface\`, which is public to every importer equally.
286
+ friends?: readonly { file: string; from: string; because: string }[];
287
+ }[];
288
+ // A directory that must hold no code at all (archspec's own
289
+ // "empty component" idea) - a violation is any file matching the glob.
290
+ mustBeEmpty?: readonly { glob: string; because: string }[];
291
+ // The constraint engine: allowDeny/order/point rules over classify
292
+ // tags, generalizing the fixed module vocabulary above. \`allowDeny\`'s
293
+ // own \`exceptions\`: a from/to glob or tag-predicate pair that overrides
294
+ // that rule either way for one specific edge - \`point\` has no
295
+ // exceptions of its own, its from/to predicates already being as
296
+ // explicit as a rule gets.
297
+ edges?: {
298
+ allowDeny?: readonly {
299
+ source: string;
300
+ targetNamespace: string;
301
+ allow?: readonly string[];
302
+ deny?: readonly string[];
303
+ exceptions?: readonly { from: string; to: string; because: string }[];
304
+ edgeType?: "value" | "type" | "both";
305
+ importForm?: "static" | "dynamic" | "both";
306
+ because: string;
307
+ }[];
308
+ order?: readonly {
309
+ tagNamespace: string;
310
+ within?: string;
311
+ sequence: Record<string, readonly string[]>;
312
+ direction: "downward-only";
313
+ edgeType?: "value" | "type" | "both";
314
+ importForm?: "static" | "dynamic" | "both";
315
+ because: string;
316
+ }[];
317
+ point?: readonly {
318
+ from: string | { tags: readonly string[]; exclude?: { tags: readonly string[] } };
319
+ to: string | { tags: readonly string[] };
320
+ edgeType?: "value" | "type" | "both";
321
+ importForm?: "static" | "dynamic" | "both";
322
+ because: string;
323
+ }[];
324
+ };
325
+ because: string;
326
+ };
327
+ `;
328
+ }
329
+ // The fresh-run walk: find every analyzed file under the seeded exclude,
330
+ // group it under the opened container (if any) plus the project root, and
331
+ // name every group - module-candidates.ts owns the grouping/naming rule
332
+ // itself, this only decides which files and anchors it sees.
333
+ // Exported so recommend's own no-config path can build a graph from the
334
+ // same groups and globs init would write, without writing any file - a
335
+ // second, independent walk could disagree with init about which files
336
+ // exist and how they group. `verb` names the command in every error and
337
+ // `do:` line here too, the same reason normalizeDirArg takes it.
338
+ export function freshRun(projectRoot, dir, verb = "init") {
339
+ const explicit = dir !== undefined;
340
+ const want = dir ?? "src";
341
+ const noiseDirs = findNoiseDirs(projectRoot, want);
342
+ const baseExclude = [...OWN_FILES, ...HIDDEN_EXCLUDE, ...noiseDirs.map((n) => `${n}/**`)];
343
+ // The test-file scan runs against the file set noise/hidden excludes
344
+ // leave behind, BEFORE folding its own globs in - findTestFileExcludes
345
+ // needs to see a real *.test.ts to decide the convention is real, and
346
+ // that file would otherwise already be gone.
347
+ const preTestFiles = listAnalyzedFiles(projectRoot, baseExclude).map((f) => toProjectRelativePosix(f, projectRoot));
348
+ const testFileExcludes = findTestFileExcludes(preTestFiles);
349
+ const exclude = [...baseExclude, ...testFileExcludes.flatMap((t) => t.exclude)];
350
+ // Project-relative, POSIX-separated: every anchor, glob, and stdout path
351
+ // below is project-relative too, and listAnalyzedFiles itself returns
352
+ // absolute, platform-separated paths (the same shape a real TypeScript
353
+ // program's own file names take). Re-scanned with the full exclude
354
+ // (noise, hidden, AND test-file globs) so a colocated test file is
355
+ // gone from grouping too, not just from this convention's own count.
356
+ const files = listAnalyzedFiles(projectRoot, exclude).map((f) => toProjectRelativePosix(f, projectRoot));
357
+ let opened = "";
358
+ let rootLabel;
359
+ if (want !== "") {
360
+ const holds = files.some((f) => f.startsWith(`${want}/`));
361
+ if (holds) {
362
+ opened = want;
363
+ }
364
+ else if (explicit) {
365
+ fail(`'${want}' is not a top-level directory that holds a .ts file check analyzes`, `archstrict ${verb}`);
366
+ }
367
+ else {
368
+ rootLabel = isRealDirectory(join(projectRoot, want))
369
+ ? `top level; ${want}/ holds no .ts file`
370
+ : `top level; there is no ${want}/ directory`;
371
+ }
372
+ }
373
+ else {
374
+ rootLabel = "top level";
375
+ }
376
+ const anchors = opened === "" ? [""] : ["", opened];
377
+ const groups = nameCandidates(groupAnalyzedFiles(files, anchors), new Set());
378
+ if (groups.length === 0) {
379
+ // Every analyzed .ts file the plain walk (no noise exclude applied)
380
+ // finds is inside a noise directory, or there is none at all. In the
381
+ // first case, naming that directory ("archstrict init test") is a
382
+ // real fix - opening it declares its files as modules instead of
383
+ // excluding them. In the second, there is nothing on disk to open.
384
+ const beforeNoiseExclude = listAnalyzedFiles(projectRoot, [...OWN_FILES, ...HIDDEN_EXCLUDE]).map((f) => toProjectRelativePosix(f, projectRoot));
385
+ const openable = noiseDirs.find((n) => beforeNoiseExclude.some((f) => f.startsWith(`${n}/`)));
386
+ fail(`found no .ts file to declare as a module in ${projectRoot} (init skips node_modules/, dist/, hidden directories, noise directories, and colocated test files)`, openable !== undefined
387
+ ? `archstrict ${verb} ${openable}`
388
+ : `add a .ts source file outside those directories, then run archstrict ${verb}`);
389
+ }
390
+ const containerGroups = groups.filter((g) => g.anchor !== "");
391
+ const topGroups = groups.filter((g) => g.anchor === "");
392
+ return {
393
+ opened,
394
+ rootLabel,
395
+ noiseDirs,
396
+ testFileExcludes,
397
+ exclude,
398
+ containerGroups,
399
+ topGroups,
400
+ declaredModules: [...containerGroups, ...topGroups].map((g) => g.entry),
401
+ };
402
+ }
403
+ // The root-level hidden directories that hold at least one analyzed file -
404
+ // used only for the stdout line naming them, never for anything the
405
+ // generated config depends on (the committed hidden-directory exclude
406
+ // patterns are fixed and machine-independent; see HIDDEN_EXCLUDE's own
407
+ // comment). A second scan, leaving the two hidden patterns out of the
408
+ // exclude list, is simpler than teaching the first scan to also report
409
+ // what it's about to exclude.
410
+ function findHiddenTopDirs(projectRoot, noiseDirs) {
411
+ const files = listAnalyzedFiles(projectRoot, [...OWN_FILES, ...noiseDirs.map((n) => `${n}/**`)]).map((f) => toProjectRelativePosix(f, projectRoot));
412
+ const names = new Set();
413
+ for (const f of files) {
414
+ const [first] = f.split("/");
415
+ if (first !== undefined && first.startsWith(".") && f.includes("/"))
416
+ names.add(first);
417
+ }
418
+ return [...names].sort();
419
+ }
420
+ export async function init(projectRoot, rawDir) {
421
+ const dir = normalizeDirArg(rawDir);
422
+ const configPath = join(projectRoot, "archstrict.config.ts");
423
+ const generatedPath = join(projectRoot, "archstrict.types.ts");
424
+ const configWritten = !existsSync(configPath);
425
+ const messageLines = [];
426
+ if (!configWritten) {
427
+ // A re-run never touches the config, and never runs the fresh-run
428
+ // walk at all - only its own syntax is validated above; a re-run has
429
+ // nowhere to open a container into anyway, since the config on disk
430
+ // already says what's declared.
431
+ messageLines.push(`${configPath} already exists, left untouched`);
432
+ const notes = [];
433
+ if (dir !== undefined) {
434
+ const note = `the directory argument applies only when init writes a new archstrict.config.ts`;
435
+ messageLines.push(note);
436
+ notes.push(note);
437
+ }
438
+ const config = await loadConfig(configPath, undefined, DO_INIT);
439
+ // `?? []` is for the type checker, not runtime defense: loadConfig
440
+ // itself now rejects any loaded config whose declaredModules is
441
+ // missing, non-array, or holds a malformed entry, so this line never
442
+ // actually sees a bad shape. Config's own `declaredModules` field
443
+ // stays typed optional regardless (other Config values exist that
444
+ // never went through loadConfig), so the fallback keeps typechecking.
445
+ const moduleNames = [...new Set((config.declaredModules ?? []).map((m) => m.name))].sort();
446
+ writeFileSync(generatedPath, generatedFileContents(moduleNames));
447
+ messageLines.push(`wrote ${generatedPath}: ${plural(moduleNames.length, "module name", "module names")}, read from archstrict.config.ts`);
448
+ const groups = uncoveredGroups(projectRoot, config.declaredModules ?? [], config.exclude ?? [], config.surface ?? DEFAULT_SURFACE);
449
+ let doText = DO_CHECK;
450
+ if (groups.length > 0) {
451
+ const totalFiles = groups.reduce((sum, g) => sum + g.fileCount, 0);
452
+ messageLines.push(`not covered by any declaredModules entry: ${plural(totalFiles, "path", "paths")} (check reports each file in them as uncovered-module)`);
453
+ for (const g of groups) {
454
+ messageLines.push(g.kind === "dir" ? ` ${g.rel}/ (${plural(g.fileCount, "file", "files")})` : ` ${g.rel}`);
455
+ messageLines.push(` declare: ${declaredModuleEntryText(g.entry)},`);
456
+ messageLines.push(` or exclude: ${JSON.stringify(g.excludeGlob)},`);
457
+ }
458
+ doText =
459
+ "add each declare line above to declaredModules in archstrict.config.ts, or its exclude line to exclude if that path is not module content; then run archstrict init";
460
+ }
461
+ return {
462
+ configPath,
463
+ generatedPath,
464
+ configWritten,
465
+ opened: "",
466
+ moduleNames,
467
+ hiddenDirs: [],
468
+ noiseDirs: [],
469
+ testFileExcludes: [],
470
+ uncovered: groups,
471
+ notes,
472
+ messageLines,
473
+ doText,
474
+ };
475
+ }
476
+ const { opened, rootLabel, noiseDirs, testFileExcludes, exclude, containerGroups, topGroups } = freshRun(projectRoot, dir);
477
+ writeFileSync(configPath, configText(opened, containerGroups, topGroups, exclude, noiseDirs, testFileExcludes));
478
+ const config = await loadConfig(configPath, undefined, DO_INIT);
479
+ const moduleNames = [...new Set((config.declaredModules ?? []).map((m) => m.name))].sort();
480
+ writeFileSync(generatedPath, generatedFileContents(moduleNames));
481
+ messageLines.push(`wrote ${configPath}`, `wrote ${generatedPath}`);
482
+ const allGroups = [...containerGroups, ...topGroups];
483
+ messageLines.push(`declared ${plural(allGroups.length, "module", "modules")}, one per directory that holds TypeScript source and one per TypeScript source file:`);
484
+ if (opened !== "")
485
+ messageLines.push(` ${opened}/: ${countLabel(containerGroups)}`);
486
+ if (topGroups.length > 0) {
487
+ const label = opened !== "" ? `outside ${opened}/` : rootLabel;
488
+ const names = topGroups.map((g) => g.entry.name).join(", ");
489
+ messageLines.push(` ./ (${label}): ${countLabel(topGroups)}: ${names}`);
490
+ }
491
+ const hiddenDirs = findHiddenTopDirs(projectRoot, noiseDirs);
492
+ if (hiddenDirs.length > 0) {
493
+ messageLines.push(`excluded ${plural(hiddenDirs.length, "hidden directory", "hidden directories")} that ${hiddenDirs.length === 1 ? "holds" : "hold"} TypeScript source: ${hiddenDirs.map((n) => `${n}/`).join(", ")}`);
494
+ }
495
+ if (noiseDirs.length > 0) {
496
+ messageLines.push(`excluded ${plural(noiseDirs.length, "noise directory", "noise directories")} found on disk: ${noiseDirs.map((n) => `${n}/`).join(", ")}`);
497
+ }
498
+ if (testFileExcludes.length > 0) {
499
+ const parts = testFileExcludes.map((t) => `${t.label} (${plural(t.fileCount, "file", "files")})`).join(", ");
500
+ messageLines.push(`excluded ${plural(testFileExcludes.length, "test-file pattern", "test-file patterns")} found on disk: ${parts}`);
501
+ }
502
+ const notes = [];
503
+ if (opened !== "" && containerGroups.length > 0 && containerGroups.every((g) => g.kind === "file")) {
504
+ const note = `${opened}/ holds only files, so each file is its own module. To check ${opened}/ as one module instead (then no import between two of its files is checked): delete archstrict.config.ts, then run archstrict init .`;
505
+ messageLines.push(note);
506
+ notes.push(note);
507
+ }
508
+ return {
509
+ configPath,
510
+ generatedPath,
511
+ configWritten,
512
+ opened,
513
+ moduleNames,
514
+ hiddenDirs,
515
+ noiseDirs,
516
+ testFileExcludes,
517
+ uncovered: [], // a fresh run's own groups always cover every analyzed file, by construction
518
+ notes,
519
+ messageLines,
520
+ doText: DO_CHECK,
521
+ };
522
+ }