archstrict 0.1.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.
@@ -29,6 +29,7 @@ import { ANALYZED_EXTENSIONS, DEFAULT_SURFACE, listAnalyzedFiles, moduleForDecla
29
29
  import { groupAnalyzedFiles, nameCandidates, declaredModuleEntryText, suggestUncovered, } from "../module-candidates.js";
30
30
  import { compileGlob } from "../classify.js";
31
31
  import { SCHEMA_VERSION } from "../config.js";
32
+ import { dominantByFiles } from "./map-shape.js";
32
33
  import { ReportError } from "../report-error.js";
33
34
  import { loadConfig } from "./check.js";
34
35
  const DO_INIT = "archstrict init";
@@ -206,13 +207,16 @@ ${exclude.map((e) => ` ${q(e)},`).join("\n")}
206
207
  ],
207
208
  // init declared one module per directory that holds TypeScript source and
208
209
  // one per TypeScript source file, so every file that check analyzes
209
- // belongs to exactly one module. Merge, rename, or remove entries freely:
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:
210
214
  // init never rewrites this file. After an edit, run archstrict init to
211
215
  // regenerate archstrict.types.ts.
212
216
  declaredModules: [
213
217
  ${lines.join("\n")}
214
218
  ],
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",
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",
216
220
  } satisfies Config;
217
221
  `;
218
222
  }
@@ -272,7 +276,10 @@ export type Config = {
272
276
  // more than one file).
273
277
  declaredModules: readonly {
274
278
  name: ModuleName;
275
- glob: string;
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[];
276
283
  // A single glob, or several - a real package can publish more than
277
284
  // one real, differently-shaped public entry point at once. Optional:
278
285
  // when absent, a real package.json's own exports map at this
@@ -501,10 +508,19 @@ export async function init(projectRoot, rawDir) {
501
508
  }
502
509
  const notes = [];
503
510
  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 .`;
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.`;
505
512
  messageLines.push(note);
506
513
  notes.push(note);
507
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);
508
524
  return {
509
525
  configPath,
510
526
  generatedPath,
@@ -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
+ }
@@ -5,7 +5,8 @@ import { relative, resolve, sep } from "node:path";
5
5
  import { applyTodo, loadConfig, runRules } from "./check.js";
6
6
  import { createConfigLocator } from "../config-pointer.js";
7
7
  import { fingerprintOf, relativizeForTodo } from "../todo-store.js";
8
- import { buildModuleGraphForRules, DEFAULT_SURFACE, moduleGlobBaseDir } from "../module-graph.js";
8
+ import { buildModuleGraphForRules, DEFAULT_SURFACE, moduleGlobBaseDir, moduleGlobList } from "../module-graph.js";
9
+ import { dominantByFiles, filePerModuleCluster } from "./map-shape.js";
9
10
  // Without a config, recommend previews init's own walk in memory (same
10
11
  // argument rules, same groups and globs) instead of running its own
11
12
  // single-level "src/*" discovery - the two could disagree about which
@@ -18,6 +19,12 @@ import { freshRun, normalizeDirArg } from "./init.js";
18
19
  // (7 covered of 9) never rounds the wrong way against a float constant.
19
20
  const SURFACE_COVERAGE_NUMERATOR = 4;
20
21
  const SURFACE_COVERAGE_DENOMINATOR = 5;
22
+ function classifyEntries(declaredModules, name, tags) {
23
+ return moduleGlobList(declaredModules.find((entry) => entry.name === name).glob).map((glob) => ({ glob, tags }));
24
+ }
25
+ function moduleMatchesSegment(declaredModules, name, pattern) {
26
+ return moduleGlobList(declaredModules.find((entry) => entry.name === name).glob).some((glob) => matchesAnySegment(glob, pattern));
27
+ }
21
28
  // Pure and exported so a property test can drive it directly with
22
29
  // synthetic importer counts, without building a real filesystem and
23
30
  // compiler graph for every case. `counts` must already be sorted densest
@@ -74,6 +81,8 @@ function proposeSurfaces(graph) {
74
81
  }
75
82
  if (pairs.size === 0)
76
83
  continue;
84
+ const totalFiles = [...graph.modules.values()].reduce((sum, entry) => sum + entry.files.length, 0);
85
+ const fileDominant = dominantByFiles([...graph.modules.values()].map((entry) => ({ name: entry.name, files: entry.files.length })));
77
86
  const candidates = [...importersByFile.entries()]
78
87
  .map(([file, importers]) => ({ file: moduleRelative(module, file), importers: importers.size }))
79
88
  .sort((a, b) => b.importers - a.importers || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
@@ -91,7 +100,9 @@ function proposeSurfaces(graph) {
91
100
  choices: [
92
101
  `do: set { name: ${JSON.stringify(module.name)}, ..., surface: ${JSON.stringify(proposedSurface)} } in declaredModules to retire ${coveredImports} of ${totalImports} bypasses into '${module.name}', leaving ${totalImports - coveredImports}`,
93
102
  `do: add a barrel file re-exporting from ${proposedSurface[0] ?? "a chosen entry file"}, then name it as this module's surface instead`,
94
- `do: leave '${module.name}' entirely private and run archstrict todo to freeze its bypasses as debt instead`,
103
+ fileDominant?.name === module.name
104
+ ? `do: split '${module.name}' before archstrict todo: it holds ${fileDominant.files} of ${totalFiles} analyzed files, and freezing its bypasses records one bucket`
105
+ : `do: leave '${module.name}' entirely private and run archstrict todo to freeze its bypasses as debt instead`,
95
106
  ],
96
107
  });
97
108
  }
@@ -133,7 +144,7 @@ const PROVE_RULES_DO = "run archstrict simulate --json with a change set that ad
133
144
  function partitionClassify(declaredModules, defaultTag, distinguishedNames, distinguishedTag) {
134
145
  return [
135
146
  { glob: "**", tags: [defaultTag] },
136
- ...distinguishedNames.map(name => ({ glob: moduleGlob(declaredModules, name), tags: [distinguishedTag] })),
147
+ ...distinguishedNames.flatMap(name => classifyEntries(declaredModules, name, [distinguishedTag])),
137
148
  ];
138
149
  }
139
150
  // A general layered-order detector: it does not name a project's own
@@ -191,7 +202,7 @@ function detectLayeredOrder(modules, counts, declaredModules, baseConfig, graph)
191
202
  }
192
203
  }
193
204
  const support = forward / (forward + reverse);
194
- const classify = order.map(name => ({ glob: declaredModules.find(d => d.name === name).glob, tags: [`role:${name}`] }));
205
+ const classify = order.flatMap(name => classifyEntries(declaredModules, name, [`role:${name}`]));
195
206
  const because = `${forward} of ${forward + reverse} directed edges between these modules already match this order`;
196
207
  const orderRule = { tagNamespace: "role", sequence: { "": order }, direction: "downward-only", because };
197
208
  const configFragment = [
@@ -241,13 +252,13 @@ function detectLeafKernels(modules, graph, declaredModules, baseConfig) {
241
252
  const inCount = incoming.get(module.name) ?? 0;
242
253
  if (inCount === 0 || (outgoing.get(module.name) ?? 0) > 0)
243
254
  continue;
244
- const glob = declaredModules.find(d => d.name === module.name).glob;
245
255
  const tag = `kind:${module.name}`;
256
+ const classify = classifyEntries(declaredModules, module.name, [tag]);
246
257
  const because = `'${module.name}' is imported by ${inCount} real edge(s) and imports no other declared module today`;
247
258
  const allowDenyRule = { source: tag, targetNamespace: "kind", allow: [], because };
248
259
  const proposedConfig = {
249
260
  ...baseConfig,
250
- classify: [...(baseConfig.classify ?? []), { glob, tags: [tag] }],
261
+ classify: [...(baseConfig.classify ?? []), ...classify],
251
262
  edges: { ...baseConfig.edges, allowDeny: [...(baseConfig.edges?.allowDeny ?? []), allowDenyRule] },
252
263
  };
253
264
  proposals.push({
@@ -256,7 +267,7 @@ function detectLeafKernels(modules, graph, declaredModules, baseConfig) {
256
267
  support: 1,
257
268
  evidence: [`'${module.name}': 0 outgoing edges to another declared module; ${inCount} other module(s) import it`],
258
269
  configFragment: [
259
- "classify: [", ` { glob: ${JSON.stringify(glob)}, tags: ${JSON.stringify([tag])} },`, "],",
270
+ "classify: [", ...classify.map(c => ` { glob: ${JSON.stringify(c.glob)}, tags: ${JSON.stringify(c.tags)} },`), "],",
260
271
  "edges: { allowDeny: [", ` { source: ${JSON.stringify(tag)}, targetNamespace: "kind", allow: [], because: ${JSON.stringify(because)} },`, "] },",
261
272
  ].join("\n"),
262
273
  addedViolations: countAddedViolations(graph, baseConfig, proposedConfig, "tag-boundary"),
@@ -307,8 +318,16 @@ function moduleSegments(glob) {
307
318
  function matchesAnySegment(glob, pattern) {
308
319
  return moduleSegments(glob).some(segment => pattern.test(segment));
309
320
  }
310
- function moduleGlob(declaredModules, name) {
311
- return declaredModules.find(d => d.name === name).glob;
321
+ function globsOf(declaredModules, name) {
322
+ return moduleGlobList(declaredModules.find(d => d.name === name).glob);
323
+ }
324
+ function moduleFeatureKey(declaredModules, name) {
325
+ for (const glob of globsOf(declaredModules, name)) {
326
+ const key = featureContainerKey(glob);
327
+ if (key !== undefined)
328
+ return key;
329
+ }
330
+ return undefined;
312
331
  }
313
332
  // Sums real cross-module edges from every member of `from` to every
314
333
  // member of `to` - the shared unit every grouped detector below reads a
@@ -356,7 +375,7 @@ function freeTagNamespace(config, preferred) {
356
375
  // sight in a way an arbitrary majority-direction graph is not.
357
376
  const APP_SEGMENT_PATTERN = /^(app|apps|cli|cmd)$/;
358
377
  function detectAppOverLibrary(modules, counts, declaredModules, baseConfig, graph) {
359
- const appNames = modules.filter(m => matchesAnySegment(moduleGlob(declaredModules, m.name), APP_SEGMENT_PATTERN)).map(m => m.name);
378
+ const appNames = modules.filter(m => moduleMatchesSegment(declaredModules, m.name, APP_SEGMENT_PATTERN)).map(m => m.name);
360
379
  const libNames = modules.map(m => m.name).filter(n => !appNames.includes(n));
361
380
  if (appNames.length === 0 || libNames.length === 0)
362
381
  return undefined;
@@ -475,7 +494,7 @@ function detectExternalPackageConfined(modules, graph, declaredModules, baseConf
475
494
  // zero edges either way is not a kept habit, it is silence.
476
495
  const TEST_SEGMENT_PATTERN = /^(tests?|__tests__|test-utils|fixtures?|mocks?|helpers?)$/;
477
496
  function detectTestCodeIsolation(modules, counts, declaredModules, baseConfig, graph) {
478
- const testNames = modules.filter(m => matchesAnySegment(moduleGlob(declaredModules, m.name), TEST_SEGMENT_PATTERN)).map(m => m.name);
497
+ const testNames = modules.filter(m => moduleMatchesSegment(declaredModules, m.name, TEST_SEGMENT_PATTERN)).map(m => m.name);
479
498
  const prodNames = modules.map(m => m.name).filter(n => !testNames.includes(n));
480
499
  if (prodNames.length === 0)
481
500
  return [];
@@ -524,8 +543,8 @@ function detectTestCodeIsolation(modules, counts, declaredModules, baseConfig, g
524
543
  const HOST_SEGMENT_PATTERN = /^(core|host)$/;
525
544
  const PLUGIN_SEGMENT_PATTERN = /^(plugins?|extensions?)$/;
526
545
  function detectHostPluginInversion(modules, counts, declaredModules, baseConfig, graph) {
527
- const hostNames = modules.filter(m => matchesAnySegment(moduleGlob(declaredModules, m.name), HOST_SEGMENT_PATTERN)).map(m => m.name);
528
- const pluginNames = modules.filter(m => matchesAnySegment(moduleGlob(declaredModules, m.name), PLUGIN_SEGMENT_PATTERN)).map(m => m.name);
546
+ const hostNames = modules.filter(m => moduleMatchesSegment(declaredModules, m.name, HOST_SEGMENT_PATTERN)).map(m => m.name);
547
+ const pluginNames = modules.filter(m => moduleMatchesSegment(declaredModules, m.name, PLUGIN_SEGMENT_PATTERN)).map(m => m.name);
529
548
  if (hostNames.length === 0 || pluginNames.length === 0)
530
549
  return undefined;
531
550
  const pluginToHost = edgesBetweenGroups(counts, pluginNames, hostNames);
@@ -535,8 +554,8 @@ function detectHostPluginInversion(modules, counts, declaredModules, baseConfig,
535
554
  const total = pluginToHost + hostToPlugin;
536
555
  const ns = freeTagNamespace(baseConfig, "kind");
537
556
  const classify = [
538
- ...hostNames.map(name => ({ glob: moduleGlob(declaredModules, name), tags: [`${ns}:host`] })),
539
- ...pluginNames.map(name => ({ glob: moduleGlob(declaredModules, name), tags: [`${ns}:plugin`] })),
557
+ ...hostNames.flatMap(name => classifyEntries(declaredModules, name, [`${ns}:host`])),
558
+ ...pluginNames.flatMap(name => classifyEntries(declaredModules, name, [`${ns}:plugin`])),
540
559
  ];
541
560
  const because = `plugin -> host: ${pluginToHost} edge(s); host -> plugin: ${hostToPlugin} edge(s)`;
542
561
  const allowDenyRule = { source: `${ns}:host`, targetNamespace: ns, deny: ["plugin"], because };
@@ -582,13 +601,13 @@ function featureContainerKey(glob) {
582
601
  function detectFeatureIsolation(modules, counts, declaredModules, baseConfig, graph) {
583
602
  const groups = new Map();
584
603
  for (const module of modules) {
585
- const key = featureContainerKey(moduleGlob(declaredModules, module.name));
604
+ const key = moduleFeatureKey(declaredModules, module.name);
586
605
  if (key === undefined)
587
606
  continue;
588
607
  (groups.get(key) ?? groups.set(key, []).get(key)).push(module.name);
589
608
  }
590
- const kernelNames = modules.filter(m => matchesAnySegment(moduleGlob(declaredModules, m.name), KERNEL_SEGMENT_PATTERN)
591
- && featureContainerKey(moduleGlob(declaredModules, m.name)) === undefined).map(m => m.name);
609
+ const kernelNames = modules.filter(m => moduleMatchesSegment(declaredModules, m.name, KERNEL_SEGMENT_PATTERN)
610
+ && moduleFeatureKey(declaredModules, m.name) === undefined).map(m => m.name);
592
611
  const proposals = [];
593
612
  for (const [, featureNames] of groups) {
594
613
  if (featureNames.length < 2 || kernelNames.length === 0)
@@ -603,8 +622,8 @@ function detectFeatureIsolation(modules, counts, declaredModules, baseConfig, gr
603
622
  const featureNs = freeTagNamespace(baseConfig, "feature");
604
623
  const kernelNs = freeTagNamespace(baseConfig, "kind");
605
624
  const classify = [
606
- ...featureNames.map(name => ({ glob: moduleGlob(declaredModules, name), tags: [`${featureNs}:${name}`] })),
607
- { glob: moduleGlob(declaredModules, kernelName), tags: [`${kernelNs}:shared`] },
625
+ ...featureNames.flatMap(name => classifyEntries(declaredModules, name, [`${featureNs}:${name}`])),
626
+ ...classifyEntries(declaredModules, kernelName, [`${kernelNs}:shared`]),
608
627
  ];
609
628
  const because = `features -> kernel ('${kernelName}'): ${featureToKernel} edge(s); features -> each other: ${crossFeature} edge(s)`;
610
629
  const allowDenyRules = featureNames.map(name => ({ source: `${featureNs}:${name}`, targetNamespace: featureNs, allow: [], because }));
@@ -699,6 +718,8 @@ export async function recommend(projectRoot, dir, surface = DEFAULT_SURFACE) {
699
718
  counts.set(edge.fromModule, targets);
700
719
  }
701
720
  const surfaceProposals = proposeSurfaces(graph);
721
+ // Graph module.dir paths are realpath-resolved, so use the same root for relative paths.
722
+ const mapNotes = mapNotesFor(graph.rootDir, modules);
702
723
  // A placeholder Config for the no-config path (freshRun's own plan, not
703
724
  // a file on disk): detectPatterns only ever reads classify/edges/because
704
725
  // off it and passes it straight to runRules, which needs a well-formed
@@ -707,7 +728,8 @@ export async function recommend(projectRoot, dir, surface = DEFAULT_SURFACE) {
707
728
  const detected = detectPatterns(modules, graph, counts, declaredModules, baseConfig, surfaceProposals);
708
729
  return {
709
730
  modules: modules.length,
710
- proposedClassify: modules.map(module => ({ glob: declaredModules.find(d => d.name === module.name).glob, tags: [`role:${module.name}`] })),
731
+ mapNotes,
732
+ proposedClassify: modules.flatMap(module => classifyEntries(declaredModules, module.name, [`role:${module.name}`])),
711
733
  detected: detected.length,
712
734
  patternProposals: detected.slice(0, PATTERN_PROPOSAL_CAP),
713
735
  surfaceProposals,
@@ -721,6 +743,38 @@ export async function recommend(projectRoot, dir, surface = DEFAULT_SURFACE) {
721
743
  // project with dozens of modules must not turn one `recommend` run's own
722
744
  // text into hundreds of lines; `--json` always carries every module,
723
745
  // every candidate, every name.
746
+ function fileModuleParent(projectRoot, module) {
747
+ const rel = relative(projectRoot, module.dir).split(sep).join("/");
748
+ const slash = rel.lastIndexOf("/");
749
+ return slash === -1 ? "." : rel.slice(0, slash);
750
+ }
751
+ function mapNotesFor(projectRoot, modules) {
752
+ const notes = [];
753
+ const dominant = dominantByFiles(modules.map((module) => ({ name: module.name, files: module.files.length })));
754
+ if (dominant !== undefined) {
755
+ notes.push({
756
+ kind: "mega-module",
757
+ evidence: `'${dominant.name}' holds ${dominant.files} of ${dominant.totalFiles} analyzed files. Hotspots and a frozen bypass list then collapse into one bucket.`,
758
+ do: `split '${dominant.name}' into directories that change together before archstrict todo, and add an edges rule so import direction is checked`,
759
+ });
760
+ }
761
+ // A directory that holds one file is still a directory seam, not a
762
+ // file-per-module inventory. Only rootIsFile modules count as singles.
763
+ const cluster = filePerModuleCluster(modules.map((module) => ({
764
+ files: module.rootIsFile ? 1 : 0,
765
+ parent: fileModuleParent(projectRoot, module),
766
+ })));
767
+ if (cluster !== undefined) {
768
+ const where = cluster.parent === "." ? "the project root" : `'${cluster.parent}'`;
769
+ const sampleDir = cluster.parent === "." ? "" : `${cluster.parent}/`;
770
+ notes.push({
771
+ kind: "file-per-module",
772
+ evidence: `${cluster.count} of ${cluster.total} modules are single files under ${where}. Each file is public as itself, so an import between them is checked, but no growth seam is named.`,
773
+ do: `group files that change together into one module, for example { name: "seam", glob: ["${sampleDir}a.ts", "${sampleDir}b.ts"], surface: "a.ts" }, then add an edges rule. archstrict hotspots shows which files change together once history exists`,
774
+ });
775
+ }
776
+ return notes;
777
+ }
724
778
  const TEXT_LIST_CAP = 5;
725
779
  // Truncates a comma-separated list to its first `TEXT_LIST_CAP` items,
726
780
  // appending how many were left out - `items.length` alone (not a fixed
@@ -769,6 +823,14 @@ export function formatRecommendText(result) {
769
823
  const shownSurfaceProposals = result.surfaceProposals.slice(0, TEXT_LIST_CAP);
770
824
  return [
771
825
  `${result.modules} modules; ${result.detected} pattern(s) detected, ${result.patternProposals.length} shown`,
826
+ ...(result.mapNotes.length === 0 ? [] : [
827
+ "",
828
+ "map:",
829
+ ...result.mapNotes.flatMap(note => [
830
+ ` ${note.kind}: ${note.evidence}`,
831
+ ` do: ${note.do}`,
832
+ ]),
833
+ ]),
772
834
  ...(result.patternProposals.length === 0 ? [] : [
773
835
  "", "pattern proposals, ranked by evidence:",
774
836
  ...result.patternProposals.flatMap(proposal => [
@@ -786,12 +848,13 @@ export function formatRecommendText(result) {
786
848
  // proposeSurfaces's own `choices`, still complete in JSON).
787
849
  " do: set { name, ..., surface: [...] } in declaredModules, per module below, to retire its listed bypasses",
788
850
  " do: or add a barrel file re-exporting from a chosen entry file, and name that as the module's surface instead",
789
- " do: or leave a module entirely private and run archstrict todo to freeze its bypasses as debt instead",
851
+ " do: or leave a small module entirely private and run archstrict todo; split a module that holds most of the files before freezing it",
790
852
  ...shownSurfaceProposals.flatMap(proposal => [
791
853
  ` ${proposal.module}: ${quote(proposal.proposedSurface)} covers ${proposal.coveredImports} of ${proposal.totalImports} bypasses, ${proposal.remainingImports} remaining`,
792
854
  ...proposal.candidates.slice(0, SURFACE_CANDIDATE_TEXT_CAP).map(c => ` ${c.file} (${c.importers} importer(s))`),
793
855
  ...(proposal.candidates.length > SURFACE_CANDIDATE_TEXT_CAP ? [` ... ${proposal.candidates.length - SURFACE_CANDIDATE_TEXT_CAP} more candidate(s); see --json`] : []),
794
856
  ` ${proposal.choices[0]}`,
857
+ ...(proposal.choices[2]?.startsWith("do: split ") ? [` ${proposal.choices[2]}`] : []),
795
858
  ]),
796
859
  ...(result.surfaceProposals.length > TEXT_LIST_CAP ? [` ... ${result.surfaceProposals.length - TEXT_LIST_CAP} more surface-less module(s); see --json`] : []),
797
860
  ]),
@@ -16,6 +16,7 @@ import { ReportError } from "../report-error.js";
16
16
  import { buildTodoEntry, buildTodoIndex, findMatchingEntry, readTodoFile, writeTodoFile, } from "../todo-store.js";
17
17
  import { deleteLegacyTodoFiles, readLegacyTodoState } from "../todo-migration.js";
18
18
  import { loadConfig, runRules } from "./check.js";
19
+ import { dominantBypassModule, dominantBypassSentence } from "./map-shape.js";
19
20
  function isFreezable(v) {
20
21
  return "todoModule" in v;
21
22
  }
@@ -145,7 +146,23 @@ export function freezeOrPrune(projectRoot, graph, config, violations) {
145
146
  writeTodoFile(rootDir, nextByModule);
146
147
  if (legacy !== undefined)
147
148
  deleteLegacyTodoFiles(legacy.filesToDelete);
148
- return { firstRun, added, pruned };
149
+ const bypassesByModule = new Map();
150
+ for (const violation of violations) {
151
+ if (violation.rule !== "public-surface-bypass" || !("todoModule" in violation))
152
+ continue;
153
+ bypassesByModule.set(violation.todoModule, (bypassesByModule.get(violation.todoModule) ?? 0) + 1);
154
+ }
155
+ const dominant = dominantBypassModule([...graph.modules.values()].map((module) => ({ name: module.name, files: module.files.length })), bypassesByModule);
156
+ if (dominant === undefined)
157
+ return { firstRun, added, pruned };
158
+ return {
159
+ firstRun,
160
+ added,
161
+ pruned,
162
+ notes: [
163
+ `${dominantBypassSentence(dominant)}. Freezing them records one bucket. Split '${dominant.name}' into directories that change together before treating this freeze as done.`,
164
+ ],
165
+ };
149
166
  }
150
167
  export async function todo(projectRoot) {
151
168
  const configPath = join(projectRoot, "archstrict.config.ts");
@@ -82,10 +82,15 @@ rules read production code) right beside the pattern it excluded.
82
82
  ## Zero-directory and zero-candidate cases
83
83
 
84
84
  A container holding only loose files (no directories at all) still
85
- declares one module per file; `init` prints one extra line, naming the
86
- alternative (checking the whole container as one module instead) without
87
- writing it - that shape stays a hand-edit, since a `do:` line names one
88
- action, not a menu.
85
+ declares one module per file. `init` prints a line saying each file is
86
+ public as itself, and that grouping files which change together is a
87
+ `glob` array of those paths with one `surface`. One module over the whole
88
+ container hides which seams move, so the line does not recommend that
89
+ shape. `archstrict init .` can still declare the container as one
90
+ directory module, for a caller who asks for that walk. The `do:` line
91
+ stays `archstrict check`, one action. Every fresh run also prints that
92
+ the map is an inventory: name seams, then add an `edges` rule.
93
+ `archstrict recommend` proposes both.
89
94
 
90
95
  A project with no analyzed source file anywhere - under the seeded
91
96
  exclude - has nothing for `init` to declare. It writes neither file and
@@ -26,11 +26,38 @@ scratch copy of a local clone, converting each project's own real config with th
26
26
  [AGENTS.md](../AGENTS.md)'s Commands section for what each requires (a local clone, `pnpm install`
27
27
  for the Prisma one) and what each prints.
28
28
 
29
+ After `npm run build`, `node dist/cli.js check` analyzes this repository's own `src/` with the root
30
+ `archstrict.config.ts`. A clean run exits 0. `node dist/cli.js todo` creates `archstrict.todo.json`
31
+ on the first run and only prunes it afterward. Modules named in that config's `strict` list cannot
32
+ take on frozen debt. The skill under `skills/archstrict/` is what the package ships; it is not a
33
+ second copy of this config.
34
+
29
35
  `npm run ci:ts7-probe` measures which of the operations rule 6 needs work on whatever typescript 7
30
36
  happens to be installed. It never fails; an unsupported operation is the measurement, not an error.
31
37
  archstrict itself always analyzes with its own pinned `typescript` dependency, independent of this
32
38
  probe.
33
39
 
40
+ `npm run mutation` runs Stryker (`stryker run`) with the Vitest runner. It mutates `src/**/*.ts`
41
+ except `src/cli.ts` and `src/mcp-server.ts` — those two are process entry points, and several tests
42
+ drive the built `dist/cli.js` rather than the source file Stryker rewrites. The full set is large
43
+ (the suite is serial, and many tests build a real TypeScript program). A bounded look is
44
+ `npx stryker run --mutate src/<file>.ts`. `coverageAnalysis` stays `perTest`.
45
+
46
+ That bounded form is the one to use. The full mutate set is thousands of mutants, and the
47
+ Vitest `related` filter still selects most of the suite for a single source file, because tests
48
+ reach that file through other imports. The time goes to the suite — synchronous CLI spawns and
49
+ TypeScript startup — rather than to Stryker's instrumenter.
50
+
51
+ Stryker copies the project into a sandbox with `copyFile`. That call throws on the directory
52
+ symlinks `hooks` and `mcp`, and it would replace `.claude-plugin/plugin.json` with a regular file.
53
+ `ignorePatterns` leaves those three paths out of the copy. `test/global-setup.ts` recreates the
54
+ symlinks when they are missing, which is a no-op in a normal checkout.
55
+
56
+ The count increments in `src/rules/constraints.ts` are additions (`x = x! + 1`), not `x!++`.
57
+ The instrumenter's update-operator mutator rebuilds `++`/`--` with Babel's `updateExpression`,
58
+ and Babel 8 rejects a non-null assertion as that argument. Excluding the mutator does not help:
59
+ the mutator still runs, and the throw aborts the run before any mutant is tested.
60
+
34
61
  ## Dependencies and Node.js
35
62
 
36
63
  Do not add a dependency without the owner's approval. Pin every dependency to an exact version.
package/docs/releasing.md CHANGED
@@ -45,11 +45,14 @@ to work.
45
45
  the `package.json` version. Push the commit and tag. The tag push starts the workflow.
46
46
  5. Approve the `publish` environment for that Actions run. The workflow runs `npm ci`, the build,
47
47
  typecheck, and tests before `npm publish`. `prepublishOnly` repeats those checks.
48
- 6. Extract only that version's notes. `--notes-file CHANGELOG.md` would include every version.
49
- Use `awk '/^## 0.x.0/{in_version=1;next} /^## /{in_version=0} in_version' CHANGELOG.md > notes.md`.
50
- Then run `gh release create v0.x.0 --title v0.x.0 --notes-file notes.md`.
48
+ 6. After `npm publish` succeeds, the workflow's `release` job creates the GitHub release. It
49
+ extracts only that version's section from `CHANGELOG.md` (the whole file would carry every
50
+ version), and it skips a release that already exists, so re-running the tag is safe. If that job
51
+ fails, extract the section and create the release by hand:
52
+ `awk '/^## 0.x.0/{in_version=1;next} /^## /{in_version=0} in_version' CHANGELOG.md > notes.md`,
53
+ then `gh release create v0.x.0 --title v0.x.0 --notes-file notes.md`.
51
54
  7. Install or update the skill and plugin for the agents that use this repository, following
52
55
  whatever install path Claude Code and the agent's own tooling document for a plugin repository
53
56
  at that time; this repository names no fixed install command for that step.
54
57
 
55
- The owner performs the commit, tag, push, environment approval, release creation, and npm publish.
58
+ The owner performs the commit, tag, push, and environment approval. The workflow then publishes to npm and creates the GitHub release.
package/llms.txt CHANGED
@@ -8,12 +8,18 @@ Agent skill: [skills/archstrict/SKILL.md](skills/archstrict/SKILL.md)
8
8
 
9
9
  Installed package: `node_modules/archstrict/skills/archstrict/SKILL.md` (this file sits beside it at `node_modules/archstrict/llms.txt`).
10
10
 
11
+ ## Install
12
+
13
+ - Every host: `npm install -D archstrict` puts the CLI at `node_modules/.bin/archstrict`.
14
+ - Claude Code: install the plugin from this repository's marketplace with `/plugin marketplace add meganemura/archstrict` and `/plugin install archstrict@archstrict`. The plugin carries the skill, the PreToolUse and PostToolUse edit hooks, and the MCP server.
15
+ - Other agents (Cursor, Codex, cloud agents): `gh skill install meganemura/archstrict archstrict --agent <agent> --scope user` installs the skill once for the user. Copying `skills/archstrict` into every repository duplicates it. `npx archstrict agents` adds the per-project `AGENTS.md` section (a few command lines, separate from the skill). Run `npx archstrict check` in CI, since the edit hooks are Claude Code only.
16
+
11
17
  ## Commands
12
18
 
13
- - `archstrict init [dir] [--json]` — declare one module per top-level directory holding `.ts` and one per loose top-level `.ts` file, so the first `check` covers every file by construction; write `archstrict.types.ts` and, if missing, `archstrict.config.ts` (`schemaVersion: 1`)
19
+ - `archstrict init [dir] [--json]` — declare one module per top-level directory holding `.ts` and one per loose top-level `.ts` file, so the first `check` covers every file by construction; write `archstrict.types.ts` and, if missing, `archstrict.config.ts` (`schemaVersion: 1`). The map is an inventory. Group files that change together (`glob` may be an array of paths in one directory), split a directory that holds almost every file, then add `edges`. `recommend` names a mega-module and a file-per-module inventory.
14
20
  - `archstrict check [file] [--json] [--rule <id>] [--module <name>] [--frozen]` — report violations for the whole project; a file argument only scopes the report. Up to 20 violations print in full; more print grouped counts with one example each, plus a `do:` per group. `--rule`/`--module` narrow the printed and JSON violations (repeatable); the exit code reflects the filtered set once a filter is given. `--frozen` also includes todo-matched violations, marked `frozen: true` and exempt from the exit code, so a re-architecting agent can read a module's frozen debt through the same filters instead of opening its todo JSON by hand
15
21
  - `archstrict todo [--json]` — freeze current freezable violations on the first run, then only prune
16
- - `archstrict recommend [dir] [--json]` — `patternProposals`: at most 5 detected boundary patterns (layered order, app over library, leaf/pure kernel, external package confined to one area, test code kept out of production, public-entry-only, host/plugin inversion, feature isolation around a shared kernel), ranked by how strongly the real edges support each, with evidence, a pasteable config fragment, and how many violations adopting it would add today; `surfaceProposals`: for each surface-less module, a ranked list of the files other modules actually import from it and the smallest ranked prefix covering at least 80% of those imports, offered as a `surface` value alongside adding a barrel file or freezing the module private
22
+ - `archstrict recommend [dir] [--json]` — `mapNotes` names a mega-module (one module holds most files) and a file-per-module inventory; `patternProposals`: at most 5 detected boundary patterns (layered order, app over library, leaf/pure kernel, external package confined to one area, test code kept out of production, public-entry-only, host/plugin inversion, feature isolation around a shared kernel), ranked by how strongly the real edges support each, with evidence, a pasteable config fragment, and how many violations adopting it would add today; `surfaceProposals`: for each surface-less module, a ranked list of the files other modules actually import from it and the smallest ranked prefix covering at least 80% of those imports, offered as a `surface` value alongside adding a barrel file or freezing the module private
17
23
  - `archstrict hotspots [--since <git ref or date>] [--json]` — combine Git change frequency and co-change with module fan-in, fan-out, frozen debt, and active violations
18
24
 
19
25
  A config or missing-file failure prints a `do:` line naming the command to run. With `--json` that failure is `{ "error": "<message>", "do": "<command>" }`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archstrict",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "TypeScript module boundary checker: default-private module surfaces, layers, cycles, and a todo freeze that can only shrink.",
5
5
  "keywords": [
6
6
  "architecture",
@@ -44,6 +44,7 @@
44
44
  "build": "tsc -p tsconfig.build.json",
45
45
  "dogfood:nukadoko": "npm run build && nuka run features",
46
46
  "ci:ts7-probe": "node scripts/probe-typescript7.mjs",
47
+ "mutation": "stryker run",
47
48
  "prepare": "npm run build",
48
49
  "prepublishOnly": "npm run build && npm run typecheck && npm test"
49
50
  },
@@ -54,9 +55,12 @@
54
55
  "devDependencies": {
55
56
  "@hegeldev/hegel": "0.4.5",
56
57
  "@meganemura/depug": "0.1.3",
58
+ "@stryker-mutator/core": "10.0.0",
59
+ "@stryker-mutator/vitest-runner": "10.0.0",
57
60
  "@types/node": "26.5.1",
58
61
  "@vitest/coverage-v8": "5.0.0",
59
62
  "nukadoko": "0.12.0",
63
+ "stryker-agent-reporter": "0.1.0",
60
64
  "vitest": "5.0.0"
61
65
  },
62
66
  "engines": {