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.
- package/AGENTS.md +14 -2
- package/CHANGELOG.md +39 -0
- package/README.ja.md +89 -9
- package/README.md +89 -9
- package/dist/cli.js +5 -1
- package/dist/config.js +9 -1
- package/dist/gitignore.js +271 -0
- package/dist/module-candidates.js +14 -7
- package/dist/module-graph.js +125 -18
- package/dist/rules/constraints.js +5 -3
- package/dist/rules/cycles.js +30 -2
- package/dist/todo-store.js +1 -1
- package/dist/type-closure.js +1 -1
- package/dist/{rules/type-leak.js → type-leak.js} +31 -3
- package/dist/verbs/check.js +61 -7
- package/dist/verbs/init.js +20 -4
- package/dist/verbs/map-shape.js +78 -0
- package/dist/verbs/recommend.js +85 -22
- package/dist/verbs/todo.js +18 -1
- package/docs/init-singleton-modules.md +9 -4
- package/docs/maintenance.md +27 -0
- package/docs/releasing.md +7 -4
- package/llms.txt +8 -2
- package/package.json +5 -1
- package/skills/archstrict/SKILL.md +15 -3
- package/skills/archstrict/references/config.md +10 -1
- package/skills/archstrict/references/hook.md +1 -1
- package/skills/archstrict/references/patterns.md +32 -0
- package/skills/archstrict/references/rearchitect.md +31 -0
- package/skills/archstrict/references/recommend.md +23 -5
- package/skills/archstrict/references/rules.md +6 -3
|
@@ -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
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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);
|
package/dist/module-graph.js
CHANGED
|
@@ -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 "./
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
747
|
+
const membership = declaredModuleMembership(declaredModules);
|
|
650
748
|
const modules = new Map(declaredModules.map((dm) => {
|
|
651
|
-
const
|
|
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:
|
|
763
|
+
surfaceName: shape.kind === "single"
|
|
764
|
+
? effectiveSurface(dm, dir, globalDefaultSurface)
|
|
765
|
+
: (dm.surface ?? globalDefaultSurface),
|
|
661
766
|
friends: (dm.friends ?? []).map((f) => ({
|
|
662
|
-
fileGlob:
|
|
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
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
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]
|
|
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)
|
package/dist/rules/cycles.js
CHANGED
|
@@ -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
|
-
?
|
|
213
|
-
: `${lopsidedDo(lopsided, graph.relativePath)}; alternatively, ${
|
|
240
|
+
? generalDo
|
|
241
|
+
: `${lopsidedDo(lopsided, graph.relativePath)}; alternatively, ${generalDo}`;
|
|
214
242
|
violations.push({
|
|
215
243
|
rule: "cycle",
|
|
216
244
|
path: firstEdge.fromFile,
|
package/dist/todo-store.js
CHANGED
|
@@ -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 "./
|
|
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
|
package/dist/type-closure.js
CHANGED
|
@@ -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
|
-
//
|
|
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 "
|
|
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
|
-
|
|
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:
|
|
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
|
});
|
package/dist/verbs/check.js
CHANGED
|
@@ -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 "../
|
|
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
|
|
133
|
-
|
|
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
|
}
|