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.
@@ -12,7 +12,7 @@
12
12
  // caller (init's own walk, or a config-vs-graph consistency check) already
13
13
  // decided that and hands this module the resulting file list, anchor set,
14
14
  // and the config's own declaredModules (for the `taken`-name check).
15
- import { moduleGlobBaseDir } from "./module-graph.js";
15
+ import { moduleGlobBaseDir, moduleGlobList } from "./module-graph.js";
16
16
  const byteSort = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
17
17
  // Groups analyzed files by their nearest anchor: for a file `f`, the
18
18
  // longest anchor that contains it. A file sitting directly in its anchor is
@@ -89,15 +89,22 @@ export function declaredModuleEntryText(entry) {
89
89
  // as `taken` names. Anchors are the project root plus the parent directory
90
90
  // of each existing entry's own glob base - the same depth a fresh init
91
91
  // itself would have grouped that entry at, computed by string ops alone
92
- // (moduleGlobBaseDir already strips the glob down to its literal prefix;
93
- // only its own parent directory is needed here, not whether that prefix
94
- // names a real file or directory on disk).
92
+ // (moduleGlobBaseDir strips the glob down to its literal prefix; only
93
+ // that prefix's parent directory is the anchor, and a file list
94
+ // contributes one anchor per path).
95
95
  export function suggestUncovered(uncoveredRelFiles, declaredModules) {
96
96
  const anchors = new Set([""]);
97
97
  for (const dm of declaredModules) {
98
- const base = moduleGlobBaseDir(dm.glob);
99
- const slash = base.lastIndexOf("/");
100
- anchors.add(slash === -1 ? "" : base.slice(0, slash));
98
+ // The parent of each glob's literal base, the same depth a fresh init
99
+ // groups at. A directory glob `src/app/**` anchors at `src`, so a
100
+ // sibling directory is its own group. A file glob `src/sqlite.ts`
101
+ // anchors at `src`, so the file itself is a file group. A file list
102
+ // anchors at the shared directory, once per path.
103
+ for (const glob of moduleGlobList(dm.glob)) {
104
+ const base = moduleGlobBaseDir(glob);
105
+ const slash = base.lastIndexOf("/");
106
+ anchors.add(slash === -1 ? "" : base.slice(0, slash));
107
+ }
101
108
  }
102
109
  const taken = new Set(declaredModules.map((dm) => dm.name));
103
110
  return nameCandidates(groupAnalyzedFiles(uncoveredRelFiles, [...anchors]), taken);
@@ -54,9 +54,11 @@ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "n
54
54
  import { dirname, join, relative, sep } from "node:path";
55
55
  import { builtinModules } from "node:module";
56
56
  import { compileGlob, mostSpecificMatch } from "./classify.js";
57
+ import { ReportError } from "./report-error.js";
57
58
  import { buildTypeClosure, computeSyntacticNamedDeclarations } from "./type-closure.js";
58
- import { checkTypeLeaks } from "./rules/type-leak.js";
59
+ import { checkTypeLeaks } from "./type-leak.js";
59
60
  import { makeProjectRelativePosix } from "./project-path.js";
61
+ import { forcedBasesOf, gitignoreStackAbove, isPathGitignored, nextIgnoreState, withGitignoreFile } from "./gitignore.js";
60
62
  // A node builtin (`fs`, `node:fs`, ...) never has a real resolvedModule:
61
63
  // ts.resolveModuleName looks for an actual file, but @types/node's ambient
62
64
  // `declare module "node:fs"` is resolved by the checker's own ambient-module
@@ -122,6 +124,58 @@ export function moduleGlobBaseDir(glob) {
122
124
  const prefix = firstWildcard === -1 ? glob : glob.slice(0, firstWildcard);
123
125
  return prefix.replace(/\/+$/, "");
124
126
  }
127
+ export function moduleGlobList(glob) {
128
+ return typeof glob === "string" ? [glob] : glob;
129
+ }
130
+ // The directory a surface or friend path resolves against. A directory
131
+ // glob's own base (`src/build/**` -> `src/build`). A file glob's parent
132
+ // (`src/build/plan.ts` -> `src/build`), so several files in one flat
133
+ // directory share it. Syntactic: it does not look at the disk, because a
134
+ // file the change set has not written yet still belongs to that directory.
135
+ export function globResolutionDir(glob) {
136
+ const base = moduleGlobBaseDir(glob);
137
+ if (glob.includes("*"))
138
+ return base;
139
+ const slash = base.lastIndexOf("/");
140
+ return slash === -1 ? "" : base.slice(0, slash);
141
+ }
142
+ // The shared resolution directory, or undefined when the list is empty or
143
+ // the globs name more than one directory. One directory per entry is the
144
+ // rule: a module that spans directories is several entries.
145
+ export function sharedGlobResolutionDir(glob) {
146
+ const globs = moduleGlobList(glob);
147
+ if (globs.length === 0)
148
+ return undefined;
149
+ const dir = globResolutionDir(globs[0]);
150
+ for (const entry of globs) {
151
+ if (globResolutionDir(entry) !== dir)
152
+ return undefined;
153
+ }
154
+ return dir;
155
+ }
156
+ export function declaredModuleMembership(declaredModules) {
157
+ return declaredModules.flatMap((dm) => moduleGlobList(dm.glob).map((glob) => ({ glob, value: dm.name })));
158
+ }
159
+ function joinPosix(dir, relativePath) {
160
+ const joined = dir === "" ? relativePath : `${dir}/${relativePath}`;
161
+ return joined.replace(/\/{2,}/g, "/").replace(/^\//, "");
162
+ }
163
+ // A single glob keeps today's file-or-directory root. Several globs must
164
+ // share one resolution directory; the caller uses that directory for
165
+ // surface and friends, and each glob's own base as a type-leak root.
166
+ function moduleGlobShape(dm) {
167
+ const globs = moduleGlobList(dm.glob);
168
+ if (globs.length === 0) {
169
+ throw new ReportError(`declared module '${dm.name}' has an empty glob list`, `give '${dm.name}' a glob string or a non-empty array of paths in one directory, in archstrict.config.ts, then run archstrict check`);
170
+ }
171
+ if (globs.length === 1)
172
+ return { kind: "single", glob: globs[0] };
173
+ const dir = sharedGlobResolutionDir(dm.glob);
174
+ if (dir === undefined) {
175
+ throw new ReportError(`declared module '${dm.name}' lists globs that do not share one directory`, `list only files in one directory, or use one declaredModules entry per directory, in archstrict.config.ts, then run archstrict check`);
176
+ }
177
+ return { kind: "many", dir, globs };
178
+ }
125
179
  function pathIsFile(path) {
126
180
  try {
127
181
  return statSync(path).isFile();
@@ -206,10 +260,20 @@ export function surfaceGlobsFor(dm, projectRoot, globalDefaultSurface,
206
260
  // See moduleRelativeDir's own comment - simulate's overlay build passes
207
261
  // one here so a change set's own new file classifies correctly.
208
262
  fileExists = pathIsFile) {
209
- const moduleDir = join(projectRoot, moduleGlobBaseDir(dm.glob));
263
+ const shape = moduleGlobShape(dm);
264
+ // A file list does not derive a surface from a package.json sitting in
265
+ // the shared directory. That derivation belongs to a directory module,
266
+ // whose glob is one directory. A seam lists its surface by hand, or
267
+ // takes the project default (index.ts in that directory).
268
+ if (shape.kind === "many") {
269
+ const surface = dm.surface ?? globalDefaultSurface;
270
+ const entries = Array.isArray(surface) ? surface : [surface];
271
+ return entries.map((s) => joinPosix(shape.dir, s));
272
+ }
273
+ const moduleDir = join(projectRoot, moduleGlobBaseDir(shape.glob));
210
274
  const surface = effectiveSurface(dm, moduleDir, globalDefaultSurface);
211
275
  const entries = Array.isArray(surface) ? surface : [surface];
212
- return entries.map((s) => moduleRelativeGlob(projectRoot, dm.glob, s, fileExists));
276
+ return entries.map((s) => moduleRelativeGlob(projectRoot, shape.glob, s, fileExists));
213
277
  }
214
278
  function surfaceGlobsAllowingDts(declaredModules, projectRoot, globalDefaultSurface) {
215
279
  return declaredModules
@@ -502,7 +566,18 @@ function readDirEntries(dir) {
502
566
  // calls this replaces took about 41 ms; this one recursive descent takes
503
567
  // about 23 ms - roughly 1.8x faster, from walking every directory once
504
568
  // instead of four times.
505
- function walkProjectTree(projectRoot, excludeGlobs, dtsSurfaceGlobs, analysisOnly = false, relativePath = makeProjectRelativePosix(projectRoot)) {
569
+ //
570
+ // A gitignored path is not project source (scratch corpora, build output,
571
+ // local caches), so it leaves the analyzed list and the non-TS count, the
572
+ // same two outputs config.exclude governs. It stays in the resolvable set
573
+ // and the package.json list: a checked-in file can import gitignored
574
+ // codegen output, and the resolution fingerprint must still see it appear
575
+ // or vanish. Under `analysisOnly` nothing else is collected, so an ignored
576
+ // directory is not entered at all - unless a declared module's base lies
577
+ // inside it, since a declared module is the explicit request to analyze
578
+ // an ignored path (see gitignore.ts's nextIgnoreState).
579
+ function walkProjectTree(projectRoot, excludeGlobs, dtsSurfaceGlobs, analysisOnly = false, relativePath = makeProjectRelativePosix(projectRoot), declaredBases = []) {
580
+ const forced = forcedBasesOf(declaredBases);
506
581
  const analyzedFiles = [];
507
582
  let nonTsSourceFileCount = 0;
508
583
  const resolvableFiles = [];
@@ -510,8 +585,17 @@ function walkProjectTree(projectRoot, excludeGlobs, dtsSurfaceGlobs, analysisOnl
510
585
  const nodeModulesDirs = [];
511
586
  const distDirs = [];
512
587
  const visited = new Set();
513
- function visit(dir) {
588
+ function visit(dir, dirState, inherited) {
514
589
  const { files, dirs } = readDirEntries(dir);
590
+ let stack = inherited;
591
+ if (dirState === "kept" && files.some((entry) => entry.name === ".gitignore")) {
592
+ try {
593
+ stack = withGitignoreFile(stack, relativePath(dir), readFileSync(join(dir, ".gitignore"), "utf8"));
594
+ }
595
+ catch {
596
+ // An unreadable .gitignore ignores nothing, like any unreadable file here.
597
+ }
598
+ }
515
599
  for (const entry of files) {
516
600
  const full = join(dir, entry.name);
517
601
  if (entry.name === "package.json" && !analysisOnly)
@@ -519,6 +603,8 @@ function walkProjectTree(projectRoot, excludeGlobs, dtsSurfaceGlobs, analysisOnl
519
603
  if (!analysisOnly && isResolvableFile(entry.name))
520
604
  resolvableFiles.push(full);
521
605
  const rel = relativePath(full);
606
+ if (nextIgnoreState(dirState, stack, rel, false, forced.bases) === "ignored")
607
+ continue;
522
608
  if (excludeGlobs.some((glob) => compileGlob(glob).test(rel)))
523
609
  continue;
524
610
  if (NON_TS_SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
@@ -544,16 +630,22 @@ function walkProjectTree(projectRoot, excludeGlobs, dtsSurfaceGlobs, analysisOnl
544
630
  distDirs.push(full);
545
631
  continue; // never entered by this pass, unconditionally
546
632
  }
633
+ const rel = relativePath(full);
634
+ const state = nextIgnoreState(dirState, stack, rel, true, forced.bases);
635
+ if (state === "ignored" && analysisOnly && !forced.ancestors.has(rel))
636
+ continue;
547
637
  if (visited.has(real))
548
638
  continue;
549
639
  visited.add(real);
550
- visit(full);
640
+ visit(full, state, stack);
551
641
  }
552
642
  }
553
643
  const rootReal = realDirOf(projectRoot);
554
644
  if (rootReal !== undefined)
555
645
  visited.add(rootReal);
556
- visit(projectRoot);
646
+ // The project root itself is never tested against a pattern: a root the
647
+ // caller pointed at is analyzed even when it sits inside an ignored path.
648
+ visit(projectRoot, "kept", gitignoreStackAbove(projectRoot));
557
649
  // The second pass: every dist/ directory the first pass met, walked
558
650
  // separately for the resolvable set, the package.json list, and the
559
651
  // node_modules directory list only - never the analyzed list or the
@@ -591,6 +683,12 @@ function walkProjectTree(projectRoot, excludeGlobs, dtsSurfaceGlobs, analysisOnl
591
683
  }
592
684
  return { analyzedFiles, nonTsSourceFileCount, resolvableFiles, packageJsonFiles, nodeModulesDirs };
593
685
  }
686
+ // Each declared module's literal base (see moduleGlobBaseDir): the walk
687
+ // keeps a gitignored path at or under one, since declaring a module there
688
+ // is the project's explicit request to analyze it.
689
+ function declaredBasesOf(declaredModules) {
690
+ return declaredModules.flatMap((dm) => moduleGlobList(dm.glob).map(moduleGlobBaseDir));
691
+ }
594
692
  export function listAnalyzedFiles(projectRoot, excludeGlobs, declaredModules = [], globalDefaultSurface = DEFAULT_SURFACE) {
595
693
  // Computed once for the whole scan, not once per .d.ts candidate file:
596
694
  // surfaceGlobsAllowingDts itself derives every module's own surface from
@@ -613,7 +711,7 @@ export function listAnalyzedFiles(projectRoot, excludeGlobs, declaredModules = [
613
711
  // tree, plus the resolvable/package.json/node_modules collection) on
614
712
  // any project that has one.
615
713
  const dtsSurfaceGlobs = surfaceGlobsAllowingDts(declaredModules, projectRoot, globalDefaultSurface);
616
- return walkProjectTree(projectRoot, excludeGlobs, dtsSurfaceGlobs, true).analyzedFiles;
714
+ return walkProjectTree(projectRoot, excludeGlobs, dtsSurfaceGlobs, true, undefined, declaredBasesOf(declaredModules)).analyzedFiles;
617
715
  }
618
716
  // A proposed new path has never passed through walkProjectTree.
619
717
  // Export the eligibility predicate so callers can ask whether that path
@@ -621,7 +719,7 @@ export function listAnalyzedFiles(projectRoot, excludeGlobs, declaredModules = [
621
719
  // rules separate from directory traversal lets both paths agree before
622
720
  // the proposed file exists on disk.
623
721
  export function isEligibleSourceFile(file, projectRoot, excludeGlobs, declaredModules, globalDefaultSurface) {
624
- return isEligibleSourceFileWithDtsGlobs(file, projectRoot, excludeGlobs, surfaceGlobsAllowingDts(declaredModules, projectRoot, globalDefaultSurface));
722
+ return isEligibleSourceFileWithDtsGlobs(file, projectRoot, excludeGlobs, surfaceGlobsAllowingDts(declaredModules, projectRoot, globalDefaultSurface)) && !isPathGitignored(projectRoot, toProjectRelativePosix(file, projectRoot), forcedBasesOf(declaredBasesOf(declaredModules)).bases);
625
723
  }
626
724
  // Shared core: takes the already-derived .d.ts-allowing surface globs
627
725
  // rather than declaredModules directly, so a caller scanning many files at
@@ -646,20 +744,29 @@ function buildDeclaredModules(projectRoot, declaredModules, allFiles, globalDefa
646
744
  // for a proposed file the same way it already is for one that exists.
647
745
  const allFilesSet = new Set(allFiles);
648
746
  const existsForClassification = (path) => allFilesSet.has(path) || pathIsFile(path);
649
- const membership = declaredModules.map((dm) => ({ glob: dm.glob, value: dm.name }));
747
+ const membership = declaredModuleMembership(declaredModules);
650
748
  const modules = new Map(declaredModules.map((dm) => {
651
- const dir = join(projectRoot, moduleGlobBaseDir(dm.glob));
749
+ const shape = moduleGlobShape(dm);
750
+ const dir = join(projectRoot, shape.kind === "single" ? moduleGlobBaseDir(shape.glob) : shape.dir);
751
+ const boundaryRoots = shape.kind === "single"
752
+ ? [dir]
753
+ : shape.globs.map((glob) => join(projectRoot, moduleGlobBaseDir(glob)));
652
754
  return [
653
755
  dm.name,
654
756
  {
655
757
  name: dm.name,
656
758
  dir,
657
- rootIsFile: existsForClassification(dir),
759
+ rootIsFile: shape.kind === "single" && existsForClassification(dir),
760
+ boundaryRoots,
658
761
  files: [],
659
762
  surfaceFiles: [],
660
- surfaceName: effectiveSurface(dm, dir, globalDefaultSurface),
763
+ surfaceName: shape.kind === "single"
764
+ ? effectiveSurface(dm, dir, globalDefaultSurface)
765
+ : (dm.surface ?? globalDefaultSurface),
661
766
  friends: (dm.friends ?? []).map((f) => ({
662
- fileGlob: moduleRelativeGlob(projectRoot, dm.glob, f.file, existsForClassification),
767
+ fileGlob: shape.kind === "single"
768
+ ? moduleRelativeGlob(projectRoot, shape.glob, f.file, existsForClassification)
769
+ : joinPosix(shape.dir, f.file),
663
770
  from: f.from,
664
771
  because: f.because,
665
772
  })),
@@ -693,7 +800,7 @@ function buildDeclaredModules(projectRoot, declaredModules, allFiles, globalDefa
693
800
  }
694
801
  export function moduleForDeclaredFile(filePath, projectRoot, declaredModules) {
695
802
  const rel = toProjectRelativePosix(filePath, projectRoot);
696
- return mostSpecificMatch(rel, declaredModules.map((dm) => ({ glob: dm.glob, value: dm.name })), (a, b) => a === b);
803
+ return mostSpecificMatch(rel, declaredModuleMembership(declaredModules), (a, b) => a === b);
697
804
  }
698
805
  // parseJsonConfigFileContent's real job (`include`/`exclude` -> a file
699
806
  // list) is work this function throws away: it returns only `.options`.
@@ -823,7 +930,7 @@ export function prepareGraph(options) {
823
930
  // at no extra walk cost to a caller (simulate, fix, search) that never
824
931
  // touches them.
825
932
  const dtsSurfaceGlobs = surfaceGlobsAllowingDts(declaredModules, projectRoot, surface);
826
- const tree = walkProjectTree(projectRoot, exclude, dtsSurfaceGlobs, false, relativePath);
933
+ const tree = walkProjectTree(projectRoot, exclude, dtsSurfaceGlobs, false, relativePath, declaredBasesOf(declaredModules));
827
934
  let rootNames = tree.analyzedFiles;
828
935
  if (options.fileListOverride)
829
936
  rootNames = options.fileListOverride(rootNames);
@@ -839,7 +946,7 @@ export function prepareGraph(options) {
839
946
  // answer. Safe for the lifetime of one prepareGraph call: projectRoot
840
947
  // and declaredModules are both fixed for that call.
841
948
  const moduleForFileCache = new Map();
842
- const membership = declaredModules.map((dm) => ({ glob: dm.glob, value: dm.name }));
949
+ const membership = declaredModuleMembership(declaredModules);
843
950
  const resolveModuleForFile = (filePath) => {
844
951
  if (moduleForFileCache.has(filePath))
845
952
  return moduleForFileCache.get(filePath);
@@ -1632,7 +1739,7 @@ const CODE_VERSION_HASH = (() => {
1632
1739
  const TYPESCRIPT_VERSION = ts.version;
1633
1740
  function cacheMetadata(projectRoot, options) {
1634
1741
  const packages = [join(projectRoot, "package.json"), ...options.declaredModules
1635
- .map((dm) => join(projectRoot, moduleGlobBaseDir(dm.glob), "package.json"))];
1742
+ .flatMap((dm) => moduleGlobList(dm.glob).map((glob) => join(projectRoot, moduleGlobBaseDir(glob), "package.json")))];
1636
1743
  const lock = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock"]
1637
1744
  .map((name) => join(projectRoot, name)).find((path) => existsSync(path));
1638
1745
  if (lock !== undefined)
@@ -172,7 +172,9 @@ export function computeAllowDeny(graph, config, focus) {
172
172
  const targetValues = [...targetTags].filter((t) => t.startsWith(namespacePrefix));
173
173
  if (targetValues.length === 0)
174
174
  return; // no tag in this namespace: not this rule's concern
175
- evaluatedCounts[i]++; // this rule genuinely had a real edge to judge, whatever the verdict below
175
+ // Not `!++`: Babel 8 rejects an UpdateExpression whose argument is a
176
+ // non-null assertion, and the instrumenter builds exactly that.
177
+ evaluatedCounts[i] = evaluatedCounts[i] + 1; // this rule genuinely had a real edge to judge, whatever the verdict below
176
178
  let violatingTag;
177
179
  if (rule.allow !== undefined) {
178
180
  const allowed = new Set(rule.allow.map((v) => `${namespacePrefix}${v}`));
@@ -308,7 +310,7 @@ function computeOrder(graph, config, focus) {
308
310
  return; // this within-value has no declared sequence at all: out of scope, not an error
309
311
  assertSequenceListsValue(rule, withinValue, sequence, sourceLayer);
310
312
  assertSequenceListsValue(rule, withinValue, sequence, targetLayer);
311
- evaluatedCounts[i]++; // this rule genuinely had a real edge, within a real declared sequence, to judge
313
+ evaluatedCounts[i] = evaluatedCounts[i] + 1; // this rule genuinely had a real edge, within a real declared sequence, to judge
312
314
  const sourceIndex = sequence.indexOf(sourceLayer.slice(namespacePrefix.length));
313
315
  const targetIndex = sequence.indexOf(targetLayer.slice(namespacePrefix.length));
314
316
  // "downward-only": a source may depend on its own layer or one
@@ -372,7 +374,7 @@ function computePoint(graph, config, focus) {
372
374
  return;
373
375
  if (!matchesPredicate(rule.from, sourceRel, sourceTags))
374
376
  return;
375
- evaluatedCounts[i]++;
377
+ evaluatedCounts[i] = evaluatedCounts[i] + 1;
376
378
  if (!matchesPredicate(rule.to, targetRel, targetTags))
377
379
  return;
378
380
  if (focus !== undefined && edge.fromFile !== focus)
@@ -67,6 +67,27 @@ function lopsidedDo(pair, relativePath) {
67
67
  const fileEdges = [...new Set(pair.minorityEdges.map((e) => `${relativePath(e.fromFile)} -> ${relativePath(e.resolvedFile)}`))].sort().slice(0, MINORITY_FILE_EDGES_SHOWN).join(", ");
68
68
  return `remove the ${pair.minorityEdges.length} import(s) from ${pair.minorityFrom} to ${pair.minorityTo} (${pair.minorityTo} imports ${pair.minorityFrom} ${pair.majorityCount} times, so ${pair.minorityFrom} -> ${pair.minorityTo} is likely the unintended direction): ${fileEdges}`;
69
69
  }
70
+ // Edges named per side are capped: a side with dozens of imports is named
71
+ // by its count and first few edges, which is enough to find it.
72
+ const SIDE_FILE_EDGES_SHOWN = 3;
73
+ function sideEdges(edges, relativePath) {
74
+ const unique = [...new Set(edges.map((e) => `${relativePath(e.fromFile)} -> ${relativePath(e.resolvedFile)}`))].sort();
75
+ const more = unique.length > SIDE_FILE_EDGES_SHOWN ? ` (and ${unique.length - SIDE_FILE_EDGES_SHOWN} more)` : "";
76
+ return unique.slice(0, SIDE_FILE_EDGES_SHOWN).join(", ") + more;
77
+ }
78
+ // The two moves a rearchitecting pass uses on a two-module cycle: extract
79
+ // a shared contract, or invert the dependency. Checking the plan with
80
+ // simulate first is named here because a move that only relocates the
81
+ // cycle is common, and simulate shows it without touching the tree.
82
+ function twoModuleDo(a, b, edgesByPair, relativePath) {
83
+ const aToB = edgesByPair.get(`${a}->${b}`) ?? [];
84
+ const bToA = edgesByPair.get(`${b}->${a}`) ?? [];
85
+ return `${a} imports ${b} ${aToB.length} time(s): ${sideEdges(aToB, relativePath)}; ` +
86
+ `${b} imports ${a} ${bToA.length} time(s): ${sideEdges(bToA, relativePath)}; ` +
87
+ `either extract the part both sides use into a leaf module that ${a} and ${b} both import, ` +
88
+ `or pass the dependency in from the side that owns it, so the other side stops importing it; ` +
89
+ `run archstrict simulate on the planned change first`;
90
+ }
70
91
  // Smaller (fromFile, line, column) wins, by plain code-unit path compare -
71
92
  // a total order independent of which edge the walk happened to visit
72
93
  // first, so the SAME representative edge is picked for a given (from, to)
@@ -208,9 +229,16 @@ export function checkCycles(graph, config) {
208
229
  // cheap fix, so they lead the do:; the general break-the-cycle advice
209
230
  // stays as the fallback for when that guess is wrong.
210
231
  const lopsided = findMostLopsidedPair(component, valueEdgesByPair);
232
+ // A two-module cycle has one known pair of sides, so its do: can name
233
+ // the edges on each side and the two restructuring moves that remove a
234
+ // direction for good. A longer cycle keeps the general advice: which
235
+ // pair to restructure is a judgment the shortest cycle alone cannot make.
236
+ const generalDo = component.length === 2
237
+ ? twoModuleDo(sorted[0], sorted[1], valueEdgesByPair, graph.relativePath)
238
+ : breakCycleDo;
211
239
  const doText = lopsided === undefined
212
- ? breakCycleDo
213
- : `${lopsidedDo(lopsided, graph.relativePath)}; alternatively, ${breakCycleDo}`;
240
+ ? generalDo
241
+ : `${lopsidedDo(lopsided, graph.relativePath)}; alternatively, ${generalDo}`;
214
242
  violations.push({
215
243
  rule: "cycle",
216
244
  path: firstEdge.fromFile,
@@ -13,7 +13,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
13
  import { createHash } from "node:crypto";
14
14
  import { isAbsolute, join } from "node:path";
15
15
  import ts from "typescript";
16
- import { REFERENCED_BY_MARKER } from "./rules/type-leak.js";
16
+ import { REFERENCED_BY_MARKER } from "./type-leak.js";
17
17
  import { ReportError } from "./report-error.js";
18
18
  import { toProjectRelativePosix } from "./module-graph.js";
19
19
  // Identity for a violation across runs: rule, importing path, and the
@@ -479,7 +479,7 @@ export function buildTypeClosure(inputs) {
479
479
  // is dispatched. module-graph.ts's own safety net (a resolution failure
480
480
  // rule 6 itself reports while walking the closure Program this
481
481
  // function's own output becomes a root list for - see
482
- // rules/type-leak.ts's own ReportUnresolvedReference) is a separate
482
+ // type-leak.ts's own ReportUnresolvedReference) is a separate
483
483
  // pass over the real Program, not this syntactic walk; a net built from
484
484
  // the same rules it is meant to catch a gap in could never fire.
485
485
  //
@@ -19,9 +19,12 @@
19
19
  // rather than a separate rule each).
20
20
  // Boundary: pure predicate over a ModuleGraph (its shared program and
21
21
  // checker) and a boundary root. No I/O, no output formatting.
22
+ // Lives next to the graph builder, not under src/rules: module-graph.ts
23
+ // calls checkTypeLeaks while it builds the closure. A rules-module home
24
+ // would make that call a cycle with every other rule that reads the graph.
22
25
  import ts from "typescript";
23
26
  import { relative } from "node:path";
24
- import { declarationKey, sourceFileKey } from "../type-closure.js";
27
+ import { declarationKey, sourceFileKey } from "./type-closure.js";
25
28
  function declaredIn(symbol) {
26
29
  return symbol.getDeclarations()?.[0]?.getSourceFile().fileName;
27
30
  }
@@ -493,6 +496,21 @@ function groupByInternalType(findings, surfacePath) {
493
496
  }
494
497
  return groups;
495
498
  }
499
+ // Two leaks look alike in evidence but need different fixes. A type this
500
+ // module owns needs only a name on this surface. A type owned by a module
501
+ // with no surface cannot get a public name from this surface at all: that
502
+ // module needs a surface, or this surface must stop exposing the type.
503
+ // Any other owner (a module with a surface that does not name the type,
504
+ // or a file no module declares) keeps the general three-way advice.
505
+ function leakDo(moduleName, group, relativeInternalFile, owner, modules, exportsShown) {
506
+ if (owner === moduleName) {
507
+ return `'${group.type}' belongs to module '${moduleName}': export '${group.type}' by name from ${group.path} (it's declared in ${relativeInternalFile})`;
508
+ }
509
+ if (owner !== undefined && (modules.get(owner)?.surfaceFiles.length ?? 0) === 0) {
510
+ return `${exportsShown} reaches '${group.type}', owned by module '${owner}', which has no surface: give '${owner}' a surface that exports '${group.type}', or drop ${exportsShown} from this surface`;
511
+ }
512
+ return `export '${group.type}' by name from ${group.path} (it's declared in ${relativeInternalFile}), change the referencing exports to not expose it, or add ${relativeInternalFile} to this module's own surface`;
513
+ }
496
514
  export function checkTypeLeaks(graph, options = {}) {
497
515
  // Forces graph.program/graph.checker first (as this always did): on a
498
516
  // graph whose Program was not built yet, that is what populates
@@ -505,13 +523,23 @@ export function checkTypeLeaks(graph, options = {}) {
505
523
  // Every declared module's own directory, not the whole project root -
506
524
  // see detectTypeLeaks' own comment on why a single, broad boundary was
507
525
  // measured wrong.
508
- const moduleBoundaries = [...graph.modules.values()].map((m) => m.dir);
526
+ // boundaryRoots, when present, is the multi-file seam's own files.
527
+ // Falling back to dir keeps a caller that built a module the old way
528
+ // (one root, the directory or the single file) on the same answer.
529
+ const moduleBoundaries = [...graph.modules.values()].flatMap((m) => m.boundaryRoots ?? [m.dir]);
509
530
  // Every declaration named by ANY declared module's own surface, computed
510
531
  // once for the whole check (not per module): a type a consumer can
511
532
  // already import from module B is not a leak in module A's surface
512
533
  // either - see collectNamedDeclarations' own header comment.
513
534
  const allSurfaceFiles = [...graph.modules.values()].flatMap((m) => m.surfaceFiles);
514
535
  const namedDeclarations = collectNamedDeclarations(graph.program, graph.checker, allSurfaceFiles, options.report);
536
+ // Which declared module owns each analyzed file, for the do: line: a
537
+ // type owned by this module needs only a name on this surface, while a
538
+ // type owned by a module with no surface cannot get one from here.
539
+ const ownerOf = new Map();
540
+ for (const [name, module] of graph.modules)
541
+ for (const file of module.files ?? [])
542
+ ownerOf.set(file, name);
515
543
  for (const [name, module] of graph.modules) {
516
544
  // The focused Program owns only one module's surface closure. Walking other
517
545
  // modules is refused because their absent source files cannot give safe results.
@@ -552,7 +580,7 @@ export function checkTypeLeaks(graph, options = {}) {
552
580
  column: group.column,
553
581
  evidence: `'${group.type}', declared in '${relativeInternalFile}', is never exported by name from module '${name}'${REFERENCED_BY_MARKER}${shown}${more}`,
554
582
  because: BECAUSE,
555
- do: `export '${group.type}' by name from ${group.path} (it's declared in ${relativeInternalFile}), change the referencing exports to not expose it, or add ${relativeInternalFile} to this module's own surface`,
583
+ do: leakDo(name, group, relativeInternalFile, ownerOf.get(group.file), graph.modules, shown + more),
556
584
  todoModule: name,
557
585
  leak: { internalType: group.type, internalFile: group.file, exportedAs: names },
558
586
  });
@@ -7,7 +7,8 @@
7
7
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
8
8
  import { resolve, sep } from "node:path";
9
9
  import ts from "typescript";
10
- import { buildModuleGraphForRules } from "../module-graph.js";
10
+ import { buildModuleGraphForRules, globResolutionDir } from "../module-graph.js";
11
+ import { dominantBypassModule, dominantBypassSentence } from "./map-shape.js";
11
12
  import { assertEdgesShapeValid, assertGlobsSupported, assertSchemaVersion, describeShape } from "../config.js";
12
13
  import { ReportError } from "../report-error.js";
13
14
  import { checkPublicSurfaceBypass } from "../rules/public-surface.js";
@@ -15,7 +16,7 @@ import { checkCycles, checkStaleCycleExceptions, } from "../rules/cycles.js";
15
16
  import { checkUncoveredModules } from "../rules/uncovered.js";
16
17
  import { checkEmptyRuleSet } from "../rules/empty-rule.js";
17
18
  import { checkDeprecatedEdges, } from "../rules/deprecated.js";
18
- import { checkTypeLeaks } from "../rules/type-leak.js";
19
+ import { checkTypeLeaks } from "../type-leak.js";
19
20
  import { checkMustBeEmpty } from "../rules/must-be-empty.js";
20
21
  import { checkAllowDeny, checkEdgesCoverage, checkOrder, checkPoint, } from "../rules/constraints.js";
21
22
  import { checkConfigMeaning } from "../rules/config-meaning.js";
@@ -26,6 +27,16 @@ export function formatConfigPointerLines(config) {
26
27
  const pointers = Array.isArray(config) ? config : [config];
27
28
  return pointers.map((pointer) => ` config: ${pointer.path}:${pointer.line}:${pointer.column} ${pointer.pointer} (${pointer.role})`);
28
29
  }
30
+ export const NO_EDGES_SUMMARY = "this config freezes today's import graph, not a target architecture; no edges rule is configured yet";
31
+ export const NO_EDGES_DO = [
32
+ "archstrict recommend",
33
+ "archstrict hotspots",
34
+ "read node_modules/archstrict/skills/archstrict/references/rearchitect.md",
35
+ ];
36
+ function hasEdgesRule(config) {
37
+ const edges = config.edges;
38
+ return (edges?.allowDeny?.length ?? 0) + (edges?.order?.length ?? 0) + (edges?.point?.length ?? 0) > 0;
39
+ }
29
40
  // declaredModules replaces modules/kinds as the required field, the same
30
41
  // class of config error as a missing kinds used to be: check/todo build
31
42
  // their graph from declaredModules unconditionally now, so a config
@@ -129,9 +140,15 @@ function assertDeclaredModulesShapeValid(configPath, raw, verb) {
129
140
  throw new ReportError(`${configPath} field 'declaredModules[${i}].name' must be a non-empty string, not ${describeShape(name)}`, `give 'declaredModules[${i}]' a non-empty string 'name' in ${configPath}, then run ${verb}`);
130
141
  }
131
142
  const glob = entry.glob;
132
- if (typeof glob !== "string") {
133
- throw new ReportError(`${configPath} field 'declaredModules[${i}].glob' must be a string, not ${describeShape(glob)}`, `give 'declaredModules[${i}]' a string 'glob' in ${configPath}, then run ${verb}`);
143
+ if (typeof glob === "string")
144
+ return;
145
+ if (Array.isArray(glob) && glob.length > 0 && glob.every((item) => typeof item === "string" && item.length > 0)) {
146
+ const dir = globResolutionDir(glob[0]);
147
+ if (glob.every((item) => globResolutionDir(item) === dir))
148
+ return;
149
+ throw new ReportError(`${configPath} field 'declaredModules[${i}].glob' lists paths that do not share one directory`, `list only files in one directory, or use one declaredModules entry per directory, in ${configPath}, then run ${verb}`);
134
150
  }
151
+ throw new ReportError(`${configPath} field 'declaredModules[${i}].glob' must be a string or a non-empty array of strings, not ${describeShape(glob)}`, `give 'declaredModules[${i}]' a string 'glob', or an array of file paths in one directory, in ${configPath}, then run ${verb}`);
135
152
  });
136
153
  }
137
154
  function allProjectRelativeFiles(graph) {
@@ -688,6 +705,22 @@ export async function check(projectRoot, focusFile, options = {}) {
688
705
  includeFrozen: options.frozen,
689
706
  });
690
707
  const focused = focusFile === undefined ? result : filterToFile(result, focusFile);
708
+ if (focusFile === undefined && !hasEdgesRule(config)) {
709
+ focused.nextSteps = { summary: NO_EDGES_SUMMARY, do: [...NO_EDGES_DO] };
710
+ }
711
+ // Count bypasses before todo suppression, so a project that already froze
712
+ // the mega-module still hears that the freeze recorded one bucket.
713
+ if (focusFile === undefined) {
714
+ const bypassesByModule = new Map();
715
+ for (const violation of evaluated.violations) {
716
+ if (violation.rule !== "public-surface-bypass" || !("todoModule" in violation))
717
+ continue;
718
+ bypassesByModule.set(violation.todoModule, (bypassesByModule.get(violation.todoModule) ?? 0) + 1);
719
+ }
720
+ const dominant = dominantBypassModule([...graph.modules.values()].map((module) => ({ name: module.name, files: module.files.length })), bypassesByModule);
721
+ if (dominant !== undefined)
722
+ focused.dominantModule = dominant;
723
+ }
691
724
  return applyFilters(focused, options);
692
725
  }
693
726
  // The output must always carry: rule id, path:line:col, evidence, because,
@@ -738,6 +771,16 @@ const UNGROUPED_MODULE = "<project>";
738
771
  // root cause (no module in the project chose a surface at all), which "add
739
772
  // a surface file" fixes project-wide, not one violation at a time.
740
773
  const SURFACE_LESS_NOTE_THRESHOLD = 0.8;
774
+ function dominantModuleLines(result) {
775
+ const dominant = result.dominantModule;
776
+ if (dominant === undefined)
777
+ return [];
778
+ return [
779
+ `note: ${dominantBypassSentence(dominant)}. Freezing them records one bucket, and hotspots then report one score.`,
780
+ `do: split '${dominant.name}' into directories that change together before archstrict todo`,
781
+ "do: archstrict recommend",
782
+ ];
783
+ }
741
784
  function surfaceLessNote(result) {
742
785
  const missing = new Set(result.modulesWithoutSurfaceNames);
743
786
  const bypasses = result.violations.filter((violation) => violation.rule === "public-surface-bypass");
@@ -852,7 +895,7 @@ function groupedViolationLines(result) {
852
895
  const ordered = groupViolations(result.violations);
853
896
  const lines = [
854
897
  `violations: ${result.violations.length} in ${ordered.length} group${ordered.length === 1 ? "" : "s"}`,
855
- ...surfaceLessNote(result),
898
+ ...(result.dominantModule !== undefined ? dominantModuleLines(result) : surfaceLessNote(result)),
856
899
  ];
857
900
  if (ordered.length === 1) {
858
901
  appendSingleGroupDetail(lines, ordered[0]);
@@ -907,8 +950,9 @@ export function formatText(result) {
907
950
  const lines = result.violations.length <= 20
908
951
  ? result.violations.flatMap(formatViolation)
909
952
  : groupedViolationLines(result);
910
- if (result.violations.length <= 20)
911
- lines.push(...surfaceLessNote(result));
953
+ if (result.violations.length <= 20) {
954
+ lines.push(...(result.dominantModule !== undefined ? dominantModuleLines(result) : surfaceLessNote(result)));
955
+ }
912
956
  for (const s of result.suggestions) {
913
957
  lines.push(`[${s.rule}] ${s.path}:${s.line}:${s.column}`);
914
958
  lines.push(` ${s.evidence}`);
@@ -937,6 +981,13 @@ export function formatText(result) {
937
981
  lines.push(`todo: ${result.todo}`);
938
982
  for (const note of result.notes ?? [])
939
983
  lines.push(`note: ${note}`);
984
+ // Before the finding's own do: line, so the last line stays the one
985
+ // concrete action for this run's findings.
986
+ if (result.nextSteps !== undefined) {
987
+ lines.push(`summary: ${result.nextSteps.summary}`);
988
+ for (const step of result.nextSteps.do)
989
+ lines.push(`do: ${step}`);
990
+ }
940
991
  // A do: line only when there is a concrete next action - a clean
941
992
  // check has none, and telling the reader to re-run the command that
942
993
  // just produced this clean result is circular, unlike every other
@@ -950,6 +1001,9 @@ export function formatText(result) {
950
1001
  if (result.violations.some((v) => v.rule === "uncovered-module")) {
951
1002
  lines.push(`do: add each uncovered-module file to declaredModules or exclude in archstrict.config.ts, then run archstrict check`);
952
1003
  }
1004
+ else if (result.dominantModule !== undefined && result.violations.some((v) => v.rule !== "config-meaning" && !v.frozen)) {
1005
+ lines.push(`do: split module '${result.dominantModule.name}' before archstrict todo`);
1006
+ }
953
1007
  else if (result.violations.some((v) => v.rule !== "config-meaning" && !v.frozen)) {
954
1008
  lines.push(`do: archstrict todo`);
955
1009
  }