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