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