archstrict 0.0.0 → 0.1.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 +69 -0
- package/CHANGELOG.md +38 -0
- package/README.ja.md +62 -0
- package/README.md +63 -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 +239 -0
- package/dist/config-pointer.js +251 -0
- package/dist/config.js +186 -0
- package/dist/edge-cache.js +530 -0
- package/dist/mcp-server.js +111 -0
- package/dist/module-candidates.js +118 -0
- package/dist/module-graph.js +2072 -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 +417 -0
- package/dist/rules/cycles.js +257 -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/type-leak.js +562 -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/verbs/agents.js +116 -0
- package/dist/verbs/check.js +957 -0
- package/dist/verbs/fix.js +170 -0
- package/dist/verbs/hotspots.js +261 -0
- package/dist/verbs/init.js +522 -0
- package/dist/verbs/recommend.js +800 -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 +163 -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 +128 -0
- package/docs/maintenance.md +82 -0
- package/docs/releasing.md +55 -0
- package/docs/rules-edge-cache.md +50 -0
- package/docs/todo-single-file-migration.md +58 -0
- package/llms.txt +19 -0
- package/package.json +57 -4
- package/skills/archstrict/SKILL.md +42 -0
- package/skills/archstrict/references/agents-verb.md +39 -0
- package/skills/archstrict/references/config.md +107 -0
- package/skills/archstrict/references/hook.md +57 -0
- package/skills/archstrict/references/path-rules.md +57 -0
- package/skills/archstrict/references/patterns.md +883 -0
- package/skills/archstrict/references/prove-rules.md +58 -0
- package/skills/archstrict/references/rearchitect.md +35 -0
- package/skills/archstrict/references/recommend.md +80 -0
- package/skills/archstrict/references/rules.md +146 -0
- package/skills/archstrict/references/simulate.md +109 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Responsibility: the `todo` verb (packwerk's shape). Freezes known
|
|
2
|
+
// violations into one project-root archstrict.todo.json, grouped by module
|
|
3
|
+
// name. First run adds every current freezable violation; every later run
|
|
4
|
+
// only prunes entries whose identity no longer matches a current violation
|
|
5
|
+
// — todo never adds again after the first run, so "run todo" cannot be
|
|
6
|
+
// used to accept a new violation quietly. Also folds in the old per-module
|
|
7
|
+
// layout the first time it finds one (todo-migration.ts), so a project
|
|
8
|
+
// that adopted archstrict before this file existed keeps its frozen debt.
|
|
9
|
+
// Boundary: freeze/prune/migrate policy only. The on-disk shape and
|
|
10
|
+
// fingerprint identity live in todo-store.ts, shared with check.ts, so
|
|
11
|
+
// both agree on the same identity for the same violation without
|
|
12
|
+
// depending on each other.
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { buildModuleGraphForRules } from "../module-graph.js";
|
|
15
|
+
import { ReportError } from "../report-error.js";
|
|
16
|
+
import { buildTodoEntry, buildTodoIndex, findMatchingEntry, readTodoFile, writeTodoFile, } from "../todo-store.js";
|
|
17
|
+
import { deleteLegacyTodoFiles, readLegacyTodoState } from "../todo-migration.js";
|
|
18
|
+
import { loadConfig, runRules } from "./check.js";
|
|
19
|
+
function isFreezable(v) {
|
|
20
|
+
return "todoModule" in v;
|
|
21
|
+
}
|
|
22
|
+
// An uncovered-module violation has no todoModule (module-graph.ts's
|
|
23
|
+
// outsideFiles matches no declared module at all, so there is no module
|
|
24
|
+
// name to freeze it under - see isFreezable's own comment) - it can never
|
|
25
|
+
// be frozen, first run or later. If the first run happens while one
|
|
26
|
+
// exists, it freezes every OTHER current violation and writes the file,
|
|
27
|
+
// after which "never add again" permanently forecloses freezing it once
|
|
28
|
+
// it's later declared: check would stay at exit 1 forever (measured on
|
|
29
|
+
// two real repository shapes: 12 and 52 such violations). Refusing the
|
|
30
|
+
// first run outright - no file written at all - keeps the freeze
|
|
31
|
+
// available until every file is actually covered. A later (prune-only)
|
|
32
|
+
// run is unaffected: pruning only ever shrinks, so an uncovered file
|
|
33
|
+
// already past the first run does not block it.
|
|
34
|
+
function refuseIfUncovered(violations) {
|
|
35
|
+
const uncovered = violations.filter((v) => v.rule === "uncovered-module");
|
|
36
|
+
if (uncovered.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
const noun = uncovered.length === 1 ? "file matches" : "files match";
|
|
39
|
+
throw new ReportError(`todo's first run refuses: ${uncovered.length} ${noun} no declared module`, "add each to declaredModules or exclude in archstrict.config.ts, then run archstrict todo");
|
|
40
|
+
}
|
|
41
|
+
export function freezeOrPrune(projectRoot, graph, config, violations) {
|
|
42
|
+
const strict = new Set(config.strict ?? []);
|
|
43
|
+
const freezable = violations.filter(isFreezable);
|
|
44
|
+
// graph.rootDir, not the raw projectRoot parameter: rootDir is
|
|
45
|
+
// realpath'd (module-graph.ts's own prepareGraph does this so every
|
|
46
|
+
// relative-path computation agrees with the paths TypeScript itself
|
|
47
|
+
// resolved to), while projectRoot may not be (e.g. macOS's own
|
|
48
|
+
// /tmp -> /private/tmp). Reading and normalizing an absolute stored path
|
|
49
|
+
// against the wrong base would silently fail to relativize it back to
|
|
50
|
+
// what a live violation's own (realpath'd) path relativizes to,
|
|
51
|
+
// un-matching an otherwise-identical entry - the same reasoning
|
|
52
|
+
// check.ts's own applyTodo already applies.
|
|
53
|
+
const rootDir = graph.rootDir;
|
|
54
|
+
const moduleDirs = new Map([...graph.modules].map(([name, m]) => [name, m.dir]));
|
|
55
|
+
const parsed = readTodoFile(rootDir);
|
|
56
|
+
// Only consulted when the new file itself is absent - a project already
|
|
57
|
+
// migrated (the new file exists) never re-reads the old layout, even if
|
|
58
|
+
// a stray legacy file somehow still sits on disk.
|
|
59
|
+
const legacy = parsed === undefined ? readLegacyTodoState(rootDir, moduleDirs) : undefined;
|
|
60
|
+
// "First run" is now the new file's own absence, AND no sign the old
|
|
61
|
+
// layout ever ran either - a project moving from the old layout to this
|
|
62
|
+
// one is a migration (folding in whatever the old layout had already
|
|
63
|
+
// frozen), not a fresh first run that would freeze today's live
|
|
64
|
+
// violations instead of what was actually already accepted as debt.
|
|
65
|
+
const firstRun = parsed === undefined && !(legacy?.present ?? false);
|
|
66
|
+
if (firstRun)
|
|
67
|
+
refuseIfUncovered(violations);
|
|
68
|
+
const currentByModule = new Map();
|
|
69
|
+
if (parsed !== undefined) {
|
|
70
|
+
for (const [name, entries] of parsed.modules)
|
|
71
|
+
currentByModule.set(name, entries);
|
|
72
|
+
}
|
|
73
|
+
else if (legacy !== undefined) {
|
|
74
|
+
for (const [name, entries] of legacy.entriesByModule)
|
|
75
|
+
currentByModule.set(name, entries);
|
|
76
|
+
}
|
|
77
|
+
let added = 0;
|
|
78
|
+
let pruned = 0;
|
|
79
|
+
const nextByModule = new Map();
|
|
80
|
+
for (const name of graph.modules.keys()) {
|
|
81
|
+
const current = currentByModule.get(name) ?? [];
|
|
82
|
+
const currentViolations = freezable.filter((v) => v.todoModule === name);
|
|
83
|
+
if (firstRun) {
|
|
84
|
+
// A strict module never gains an entry, not even on the first run:
|
|
85
|
+
// its violations simply stay reported by check, uncovered by any
|
|
86
|
+
// todo. Every other module's current violations all freeze at once.
|
|
87
|
+
const toFreeze = strict.has(name) ? [] : currentViolations;
|
|
88
|
+
const entries = toFreeze.map((v) => buildTodoEntry(v, graph.relativePath));
|
|
89
|
+
if (entries.length > 0) {
|
|
90
|
+
nextByModule.set(name, entries);
|
|
91
|
+
added += entries.length;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// Prune (and, on a migration run, fold in): keep an existing entry
|
|
96
|
+
// exactly when some current violation still names it (todo-store.ts's
|
|
97
|
+
// own findMatchingEntry, indexed once per module rather than
|
|
98
|
+
// rescanned per violation - see buildTodoIndex's own comment). A
|
|
99
|
+
// strict module's entries prune the same as any other module's -
|
|
100
|
+
// strict blocks this verb from adding, not from shrinking. It never
|
|
101
|
+
// hides an existing entry from check, though: check reports any
|
|
102
|
+
// entry in a strict module's todo as its own violation
|
|
103
|
+
// (clean-module-has-todo), so an entry that survives pruning here
|
|
104
|
+
// still fails check until it's fixed.
|
|
105
|
+
//
|
|
106
|
+
// Every surviving entry is rewritten from the live violation that
|
|
107
|
+
// matched it (todo-store.ts's own buildTodoEntry), not kept
|
|
108
|
+
// byte-for-byte - self-healing for every rule (a legacy entry
|
|
109
|
+
// migrated with an absolute path, or one frozen under an older
|
|
110
|
+
// fingerprint formula, is rewritten into today's shape the first
|
|
111
|
+
// time it's pruned again). One entry's own object identity in
|
|
112
|
+
// `current` (not its content, which duplicate rows can share)
|
|
113
|
+
// decides which live violation refreshes it, so a genuine duplicate
|
|
114
|
+
// stored row - runRules can report the same edge twice - collapses
|
|
115
|
+
// to the one `buildTodoIndex`'s own map can still reference, instead
|
|
116
|
+
// of being rewritten twice over.
|
|
117
|
+
const index = buildTodoIndex(current);
|
|
118
|
+
const matchedViolationByEntry = new Map();
|
|
119
|
+
for (const v of currentViolations) {
|
|
120
|
+
const entry = findMatchingEntry(index, v, graph.relativePath);
|
|
121
|
+
if (entry !== undefined && !matchedViolationByEntry.has(entry))
|
|
122
|
+
matchedViolationByEntry.set(entry, v);
|
|
123
|
+
}
|
|
124
|
+
const kept = current
|
|
125
|
+
.filter((e) => matchedViolationByEntry.has(e))
|
|
126
|
+
.map((e) => buildTodoEntry(matchedViolationByEntry.get(e), graph.relativePath));
|
|
127
|
+
pruned += current.length - kept.length;
|
|
128
|
+
if (kept.length > 0)
|
|
129
|
+
nextByModule.set(name, kept);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// An entry under a module name today's graph no longer declares (the
|
|
133
|
+
// module was renamed or removed since it was frozen) matches no current
|
|
134
|
+
// violation by construction - nothing this run's rules produced even
|
|
135
|
+
// claims that module name - so it can only ever be pruned, never kept or
|
|
136
|
+
// re-added.
|
|
137
|
+
for (const [name, entries] of currentByModule) {
|
|
138
|
+
if (graph.modules.has(name))
|
|
139
|
+
continue;
|
|
140
|
+
pruned += entries.length;
|
|
141
|
+
}
|
|
142
|
+
// Written unconditionally, even with an empty module map, so the file's
|
|
143
|
+
// own existence keeps meaning "todo has run" - see todo-store.ts's own
|
|
144
|
+
// writeTodoFile comment.
|
|
145
|
+
writeTodoFile(rootDir, nextByModule);
|
|
146
|
+
if (legacy !== undefined)
|
|
147
|
+
deleteLegacyTodoFiles(legacy.filesToDelete);
|
|
148
|
+
return { firstRun, added, pruned };
|
|
149
|
+
}
|
|
150
|
+
export async function todo(projectRoot) {
|
|
151
|
+
const configPath = join(projectRoot, "archstrict.config.ts");
|
|
152
|
+
const config = await loadConfig(configPath);
|
|
153
|
+
// See check.ts's own comment: declaredModules is the only source of
|
|
154
|
+
// scope now.
|
|
155
|
+
const graph = buildModuleGraphForRules({
|
|
156
|
+
projectRoot,
|
|
157
|
+
declaredModules: config.declaredModules,
|
|
158
|
+
exclude: config.exclude,
|
|
159
|
+
surface: config.surface,
|
|
160
|
+
});
|
|
161
|
+
const result = runRules(graph, config);
|
|
162
|
+
return freezeOrPrune(projectRoot, graph, config, result.violations);
|
|
163
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Responsibility: across repeated refresh calls in one long-lived process
|
|
2
|
+
// (the MCP server's check tool, and fix's apply-and-recheck loop), skip re-parsing a file whose
|
|
3
|
+
// content hasn't changed while resolving every specifier afresh - a
|
|
4
|
+
// resolution answer can go stale even when the importing file itself has
|
|
5
|
+
// not (an unrelated file elsewhere gaining, losing, or reordering a
|
|
6
|
+
// preferred target).
|
|
7
|
+
// Boundary: no process, socket, daemon, or CLI integration; this object
|
|
8
|
+
// holds no resolved edges, no ts.Program, and no parsed AST of any kind
|
|
9
|
+
// across calls - only each file's own small, syntactic import list. A
|
|
10
|
+
// refresh whose graph a caller then asks for `program`/`checker` (an MCP
|
|
11
|
+
// `check` call, unless it is scoped to a non-surface file; also search,
|
|
12
|
+
// fix)
|
|
13
|
+
// builds a whole-project ts.Program fresh, every time, and drops it again
|
|
14
|
+
// at the end of that one refresh - a real, paid cost each time rule 6
|
|
15
|
+
// runs, kept bounded (module-graph.ts's own header: a whole-project
|
|
16
|
+
// Program's own parsed SourceFile/Node trees are what dominate memory on
|
|
17
|
+
// a large codebase) by never carrying that Program into the next refresh.
|
|
18
|
+
import ts from "typescript";
|
|
19
|
+
import { statSync } from "node:fs";
|
|
20
|
+
import { join, resolve } from "node:path";
|
|
21
|
+
import { buildPreparedGraph, prepareGraph, graphBuildFingerprint, parseFileForImports } from "./module-graph.js";
|
|
22
|
+
function mtime(path) {
|
|
23
|
+
try {
|
|
24
|
+
return statSync(path).mtimeMs;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function createWarmGraph() {
|
|
31
|
+
// Keyed by absolute file path; holds the file's own syntactic import
|
|
32
|
+
// list (never its AST - see this module's own header) plus the mtime
|
|
33
|
+
// it was parsed at. A cache hit skips ts.createSourceFile and the
|
|
34
|
+
// import walk entirely; resolution still runs for every import record,
|
|
35
|
+
// hit or miss (module-graph.ts's own buildPreparedGraph does that part,
|
|
36
|
+
// outside this cache).
|
|
37
|
+
const cache = new Map();
|
|
38
|
+
let fingerprint;
|
|
39
|
+
return {
|
|
40
|
+
refresh(options) {
|
|
41
|
+
const prepared = prepareGraph(options);
|
|
42
|
+
// The CLI reads config afresh before each edge-cache lookup. This holder instead retains state across refresh calls.
|
|
43
|
+
// The config mtime therefore provides a separate signal that the architecture config changed since the previous refresh.
|
|
44
|
+
const currentFingerprint = JSON.stringify({ ...graphBuildFingerprint(options, prepared),
|
|
45
|
+
configMtime: mtime(join(prepared.projectRoot, "archstrict.config.ts")) });
|
|
46
|
+
// A changed fingerprint can mean new compiler options or package
|
|
47
|
+
// metadata - a parse cached under the old options (module kind,
|
|
48
|
+
// jsx setting, ...) is not safe to reuse under the new ones.
|
|
49
|
+
if (fingerprint !== currentFingerprint) {
|
|
50
|
+
cache.clear();
|
|
51
|
+
fingerprint = currentFingerprint;
|
|
52
|
+
}
|
|
53
|
+
const host = ts.createCompilerHost(prepared.compilerOptions);
|
|
54
|
+
const languageVersion = prepared.compilerOptions.target ?? ts.ScriptTarget.ESNext;
|
|
55
|
+
// One package.json lookup cache per refresh: a cold refresh parses
|
|
56
|
+
// every file, and each .ts/.tsx file's format depends on its nearest
|
|
57
|
+
// package.json "type". Without it, every file re-reads each
|
|
58
|
+
// package.json up its directory chain.
|
|
59
|
+
const packageJsonInfoCache = ts.createModuleResolutionCache(prepared.projectRoot, host.getCanonicalFileName.bind(host), prepared.compilerOptions).getPackageJsonInfoCache();
|
|
60
|
+
const fileWalk = (fileName) => {
|
|
61
|
+
const path = resolve(fileName);
|
|
62
|
+
const mtimeMs = mtime(path);
|
|
63
|
+
if (mtimeMs === undefined)
|
|
64
|
+
return undefined;
|
|
65
|
+
const cached = cache.get(path);
|
|
66
|
+
if (cached?.mtimeMs === mtimeMs)
|
|
67
|
+
return cached.walk;
|
|
68
|
+
const text = host.readFile(fileName);
|
|
69
|
+
if (text === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
const walk = parseFileForImports(fileName, text, languageVersion, host, prepared.compilerOptionsForFile(fileName), packageJsonInfoCache);
|
|
72
|
+
cache.set(path, { mtimeMs, walk });
|
|
73
|
+
return walk;
|
|
74
|
+
};
|
|
75
|
+
// No oldProgram, no held host reused as a Program-building host
|
|
76
|
+
// across calls: this refresh's graph builds its own Program (if
|
|
77
|
+
// anything asks for `program`/`checker` at all) from scratch, and
|
|
78
|
+
// that Program is this refresh's own business alone.
|
|
79
|
+
return buildPreparedGraph(prepared, { host, fileWalk });
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# A survey of existing boundary-checking configurations, and why archstrict does not ship a preset
|
|
2
|
+
|
|
3
|
+
This records a survey of existing boundary-checking configurations in
|
|
4
|
+
public repositories, done to answer one question: does archstrict need a
|
|
5
|
+
`--preset` flag? The answer is no. The survey's own findings became
|
|
6
|
+
[skills/archstrict/references/patterns.md](../skills/archstrict/references/patterns.md)
|
|
7
|
+
instead - a reference an agent reads before proposing a config, not code
|
|
8
|
+
that runs automatically.
|
|
9
|
+
|
|
10
|
+
## Method
|
|
11
|
+
|
|
12
|
+
The survey searched public code for existing boundary-checking
|
|
13
|
+
configuration files and read every one it kept. About 1,090 distinct
|
|
14
|
+
repositories came back across several targeted search queries; each got a
|
|
15
|
+
star count. A repository qualified to keep reading its config at 300 stars
|
|
16
|
+
from a general search query, or 100 stars from a query that already
|
|
17
|
+
selects for a real, chosen tag vocabulary (so the bar could be lower there
|
|
18
|
+
and still surface more than a handful). A tool's own source, its
|
|
19
|
+
documentation, its fixtures, and its tutorials were dropped, along with
|
|
20
|
+
one malformed config. Every kept configuration file was fetched and read
|
|
21
|
+
in full: 113 configuration files, of which 72 carried a real, project-
|
|
22
|
+
specific rule.
|
|
23
|
+
|
|
24
|
+
**What was counted.** A rule counts once per repository, by which shape it
|
|
25
|
+
expresses (a layer order, a runtime-environment split, a feature isolation
|
|
26
|
+
rule, and so on - fourteen shapes emerged, twelve of them expected going
|
|
27
|
+
in, two found only once the configs were actually read). A single
|
|
28
|
+
repository can count toward more than one shape; most real configurations
|
|
29
|
+
mix two to four of them.
|
|
30
|
+
|
|
31
|
+
**Two biases, both worth stating plainly.**
|
|
32
|
+
|
|
33
|
+
- **Most configuration files carry no project decision at all.** Across
|
|
34
|
+
the sample: a config-generating tool's own tag family, read in the
|
|
35
|
+
general query, was carrying its own scaffold's no-op rule (a wildcard
|
|
36
|
+
matching everything, added by the generator and never edited) in three
|
|
37
|
+
out of every four repositories that had one at all. A dependency-graph
|
|
38
|
+
tool's own default preset, applied by its own init command and left
|
|
39
|
+
untouched, accounted for a similar share of that tool's own configs. The
|
|
40
|
+
mere presence of a boundary tool in a repository is weak evidence of a
|
|
41
|
+
chosen pattern; the counts below come only from configs that carried a
|
|
42
|
+
real, hand-written rule.
|
|
43
|
+
- **Search results are ordered by relevance, not by stars, and only the
|
|
44
|
+
first page is visible.** The counts below are frequencies within this
|
|
45
|
+
sample, not frequencies in the population of all public repositories.
|
|
46
|
+
Read a count as "common", "uncommon", or "rare" - not as a percentage.
|
|
47
|
+
- **A third, smaller bias:** several of the richest, most clearly
|
|
48
|
+
commented configurations in the sample were recent, with self-
|
|
49
|
+
describing rule names and dated comments, and plausibly written with an
|
|
50
|
+
agent's help. They still show a real, chosen boundary - a comment
|
|
51
|
+
explaining a rule is evidence of intent regardless of who wrote the
|
|
52
|
+
comment.
|
|
53
|
+
|
|
54
|
+
## Pattern counts
|
|
55
|
+
|
|
56
|
+
| Pattern | Repositories | Evidence strength |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| Layered order (downward only) | 34 | strong |
|
|
59
|
+
| Runtime/platform environments | 23 | strong |
|
|
60
|
+
| Feature isolation with a shared kernel | 20 | strong |
|
|
61
|
+
| Public-entry-only (no deep imports) | 17 | strong |
|
|
62
|
+
| Leaf / pure kernel | 16 | strong (not anticipated before the survey) |
|
|
63
|
+
| External package confined to one area | 13 | medium (not anticipated before the survey) |
|
|
64
|
+
| Test code not imported by production code | 9 project-chosen, 10 more inherited from a tool's own default preset | medium; mostly inherited, not chosen |
|
|
65
|
+
| Domain/scope isolation | 9, plus one studied, unsurveyed real config (Prisma) | medium, seen only in one tag-based monorepo tool's own convention |
|
|
66
|
+
| Two tag axes combined | 7, plus the same studied config (three axes) | medium, seen only in that same tool family |
|
|
67
|
+
| App vs lib | 5 | thin as its own explicit rule; often implicit instead |
|
|
68
|
+
| Hexagonal / clean architecture | 5, one more partial | thin; every repository using it had well under 3,000 stars |
|
|
69
|
+
| Barrel-inverse (must not import own barrel) | 5 | thin but consistent in what it says |
|
|
70
|
+
| Type-only exception | 5 | thin; a modifier on another pattern, not a pattern of its own |
|
|
71
|
+
| Host/plugin inversion | 5 | thin |
|
|
72
|
+
|
|
73
|
+
## What each pattern's vocabulary and rule shape typically look like
|
|
74
|
+
|
|
75
|
+
Public-entry-only is the strongest single piece of evidence for
|
|
76
|
+
archstrict's own core model - a module as one directory, shown to the rest
|
|
77
|
+
of the codebase through one named surface file. The entry file name varies
|
|
78
|
+
across the sample (`index.ts` most often, then `public.ts`, `facade.ts`,
|
|
79
|
+
`contracts.ts`, one file per subpath export, or the package name itself
|
|
80
|
+
through a build alias) - which is exactly why archstrict's own `surface`
|
|
81
|
+
field is configurable per project and per module, rather than fixed to
|
|
82
|
+
`index.ts`.
|
|
83
|
+
|
|
84
|
+
A layered order is almost always one entry per layer, naming every layer
|
|
85
|
+
at or below itself as allowed - expressed either as an allow list (a
|
|
86
|
+
monorepo tool's own tag convention) or as a deny list naming everything
|
|
87
|
+
above (a dependency-graph tool, a path-restriction rule). A runtime-
|
|
88
|
+
environment split almost always exempts one directory (`common`,
|
|
89
|
+
`shared`) that every environment may use, and that itself may use
|
|
90
|
+
nothing else. Feature isolation is almost always two rules together: a
|
|
91
|
+
feature may not import a sibling feature, and the shared kernel may not
|
|
92
|
+
import any feature - composition happens one level up, in a router or an
|
|
93
|
+
app shell. A leaf/pure kernel is simpler than a full layer order and is
|
|
94
|
+
often the only rule a small project has at all: one directory that
|
|
95
|
+
imports nothing outside itself. An external-package-confined rule targets
|
|
96
|
+
an npm package or a node built-in by name, not an internal directory - the
|
|
97
|
+
same shape as an internal boundary, aimed outward instead of inward.
|
|
98
|
+
|
|
99
|
+
Domain isolation and its two-axis combination were both seen only inside
|
|
100
|
+
one tag-based monorepo tool's own tagging convention in this sample (and
|
|
101
|
+
in one further, separately studied real configuration, Prisma's own
|
|
102
|
+
`architecture.config.json`, which combines a domain axis, a layer axis
|
|
103
|
+
scoped per domain, and a third "plane" axis). Evidence for this shape
|
|
104
|
+
outside that one tool family is thin. Hexagonal/clean architecture,
|
|
105
|
+
barrel-inverse, the type-only exception, host/plugin inversion, and app-
|
|
106
|
+
vs-lib-as-an-explicit-rule are each real but thin: five or fewer
|
|
107
|
+
repositories each, and (for hexagonal specifically) every one under 3,000
|
|
108
|
+
stars. A proposal built on one of these five should say plainly that the
|
|
109
|
+
evidence for it is thin.
|
|
110
|
+
|
|
111
|
+
## A second sample: the 200 most-starred TypeScript repositories
|
|
112
|
+
|
|
113
|
+
The first survey found repositories through code search for the vocabulary of
|
|
114
|
+
dedicated boundary tools, so it can only measure frequency among repositories
|
|
115
|
+
that already adopted one. A second survey, done on 2026-09-25, instead
|
|
116
|
+
sampled by popularity: the 200 most-starred public TypeScript repositories on
|
|
117
|
+
GitHub, independent of which tool (if any) each one uses. Four of the 200
|
|
118
|
+
already appeared in the first survey's own table (all four enforce a
|
|
119
|
+
boundary); the other 196 were checked by fetching each repository's full file
|
|
120
|
+
tree and reading every file that looked like a boundary-tool config, a
|
|
121
|
+
general lint config, or a hand-written checker script by name (files under
|
|
122
|
+
`scripts/`, `tools/`, or a lint-configuration directory whose name mentions a
|
|
123
|
+
boundary, a layer, a restriction, or an architecture check), plus the root
|
|
124
|
+
package manifest and build config.
|
|
125
|
+
|
|
126
|
+
**What counted.** A repository counts as enforcing a boundary when at least
|
|
127
|
+
one rule names two areas of the project and forbids or allows an edge
|
|
128
|
+
between them, or confines an external package or capability to one named
|
|
129
|
+
area - the same bar as the first survey. A cycle-detection-only rule, a
|
|
130
|
+
package-hygiene rule with a named single replacement everywhere, a
|
|
131
|
+
deprecation-only rule, or a build system's own per-target dependency
|
|
132
|
+
declarations with no layer table do not count on their own.
|
|
133
|
+
|
|
134
|
+
**Headline.** 52 of the 200 repositories (26%) enforce a boundary this way;
|
|
135
|
+
47 (24%) after setting aside five repositories whose only rule is a
|
|
136
|
+
load-path rule (see the new LOAD pattern below). About three quarters do
|
|
137
|
+
not.
|
|
138
|
+
|
|
139
|
+
Enforcement rises sharply with codebase size, counted by `.ts`/`.tsx` file
|
|
140
|
+
count (excluding generated declaration files):
|
|
141
|
+
|
|
142
|
+
| TypeScript files | Enforces a boundary |
|
|
143
|
+
|---|---|
|
|
144
|
+
| under 100 | 0 of 18 |
|
|
145
|
+
| 100 to 999 | 6 of 82 |
|
|
146
|
+
| 1,000 to 4,999 | 24 of 67 |
|
|
147
|
+
| 5,000 or more | 18 of 29 |
|
|
148
|
+
|
|
149
|
+
The median enforcing repository has about 3,000 TypeScript files; the median
|
|
150
|
+
non-enforcing repository has about 400. Several very large, popular
|
|
151
|
+
repositories in the sample enforce nothing found by this method at all -
|
|
152
|
+
star count and popularity do not predict enforcement; size does.
|
|
153
|
+
|
|
154
|
+
**The tool mix inverts.** In the first survey, dedicated tag-and-constraint
|
|
155
|
+
tools (a monorepo tool's own tag graph, a dependency-graph analysis tool, a
|
|
156
|
+
path-restriction rule, a boundary-specific linter plugin) carried almost
|
|
157
|
+
every rule. In this sample, those same tools carry only about an eighth of
|
|
158
|
+
the 48 enforcing repositories combined; a general-purpose linter's built-in
|
|
159
|
+
"forbid importing this path" rule carries about three fifths, and a checker
|
|
160
|
+
the project wrote for itself - its own script, its own rule table, its own
|
|
161
|
+
message text - carries most of the rest. One repository's own hand-written
|
|
162
|
+
checker reimplements a monorepo tool's tag-constraint idea from scratch,
|
|
163
|
+
including a hard-coded tag map and an allow-list per tag, without adopting
|
|
164
|
+
the tool itself.
|
|
165
|
+
|
|
166
|
+
**Per-pattern counts, both samples side by side.** Sample 1's denominator
|
|
167
|
+
below is 82 repositories, from the first survey's own repository-level
|
|
168
|
+
table. The method section above instead counts 72 configuration files with
|
|
169
|
+
a project-chosen rule, from a per-tool pass over the same
|
|
170
|
+
search results; the two numbers come from two different passes over the
|
|
171
|
+
same underlying search, and the per-pattern counts below use the
|
|
172
|
+
repository-level table's own 82. Sample 2's denominator is 48
|
|
173
|
+
repositories. A repository can count toward more than one pattern in
|
|
174
|
+
either sample.
|
|
175
|
+
|
|
176
|
+
| Pattern | Sample 1 (of 82) | Sample 2 (of 48) |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| Public-entry-only | 17 | **22** |
|
|
179
|
+
| Layered order | **34** | 10 |
|
|
180
|
+
| Runtime/platform environments | 23 | 13 |
|
|
181
|
+
| Feature isolation with a shared kernel | 20 | 2 |
|
|
182
|
+
| Leaf / pure kernel | 16 | 7 |
|
|
183
|
+
| External package confined to one area | 13 | 14 |
|
|
184
|
+
| Type-only exception | 5 | 10 |
|
|
185
|
+
| Test code kept out of production | 9 | 4 |
|
|
186
|
+
| Scope/domain isolation | 9 | 2 |
|
|
187
|
+
| Barrel-inverse | 5 | 3 |
|
|
188
|
+
| App vs lib | 5 | 2 |
|
|
189
|
+
| Hexagonal / clean | 5 | 2 |
|
|
190
|
+
| Two tag axes combined | 7 | 1 |
|
|
191
|
+
| Host/plugin inversion | 5 | 4 |
|
|
192
|
+
| Load-path isolation | no category in sample 1 | 8 |
|
|
193
|
+
| Edition split | no category in sample 1 | 2 |
|
|
194
|
+
| Composition root | no category in sample 1 | 2 |
|
|
195
|
+
| Friend list | no category in sample 1 | 1 |
|
|
196
|
+
| Entry-graph budget | no category in sample 1 | 1 |
|
|
197
|
+
|
|
198
|
+
**What the difference means.** The two samples measure different
|
|
199
|
+
populations, not the same population twice. Sample 1's method can only find
|
|
200
|
+
a repository that already picked a dedicated boundary tool and used its own
|
|
201
|
+
vocabulary: a tag, an element type, a zone. That selection over-represents
|
|
202
|
+
configurations built on a scaffold meant to make a layer ladder or a
|
|
203
|
+
feature-isolation rule cheap to write - those are exactly the shapes a
|
|
204
|
+
dedicated tool's own vocabulary makes easy to write. Sample 2, ordered by
|
|
205
|
+
popularity alone, shows what large, established codebases enforce
|
|
206
|
+
regardless of tooling choice. That turns out to be, overwhelmingly, a
|
|
207
|
+
generic "forbid this import path" rule or a hand-written script, aimed at
|
|
208
|
+
one entry point or one runtime split rather than a whole layer stack. Read
|
|
209
|
+
the layered-order and feature-isolation counts in the first survey as
|
|
210
|
+
evidence about repositories that adopted a layering tool. They are not
|
|
211
|
+
evidence that layering is the most common shape among popular TypeScript
|
|
212
|
+
codebases in general.
|
|
213
|
+
|
|
214
|
+
Sample 2 also surfaces a reason for a rule that sample 1's own method could
|
|
215
|
+
not have found under its own name: cost. Several repositories forbid a
|
|
216
|
+
statically-imported heavy or side-effecting module purely to keep it off an
|
|
217
|
+
eager load path (bundle size, startup time), not for an architectural
|
|
218
|
+
reason at all. The first survey's own search terms had no way to single
|
|
219
|
+
this reason out from an ordinary external-package rule.
|
|
220
|
+
|
|
221
|
+
**An independent baseline mechanism.** Three unrelated large repositories in
|
|
222
|
+
the second sample each built their own mechanism, separately, for a rule
|
|
223
|
+
that fails only when the count of known violations grows past a committed
|
|
224
|
+
baseline, or a grow-only list of accepted exceptions that may only get
|
|
225
|
+
longer, never shorter by editing it directly. None of the three call it by
|
|
226
|
+
the same name, and nothing suggests one copied another. This is an
|
|
227
|
+
observation about a real, independently-arrived-at idea for managing
|
|
228
|
+
existing boundary debt over time, not a documented convention any tool
|
|
229
|
+
ships - and it is the same shape, arrived at from a different direction, as
|
|
230
|
+
archstrict's own todo file: a frozen list that can only shrink.
|
|
231
|
+
|
|
232
|
+
**Tags from sources other than a directory name (observations, not counted
|
|
233
|
+
patterns).** Two repositories in the second sample derive a file's tag from
|
|
234
|
+
something other than its path: one reads a runtime tag off a fixed filename
|
|
235
|
+
suffix (a file whose name ends a certain way is browser-only, another
|
|
236
|
+
ending marks it Node-only, another marks a web-worker file) - archstrict
|
|
237
|
+
already expresses this today, since a `classify` glob can match a literal
|
|
238
|
+
suffix directly. The other reads a tag from a field in a package's own
|
|
239
|
+
manifest, independent of any path at all; archstrict does not read package
|
|
240
|
+
manifests for tags today, so this is noted as an observation, not a
|
|
241
|
+
supported mechanism.
|
|
242
|
+
|
|
243
|
+
**Limits.** This sample counts declared rules only, not the real import
|
|
244
|
+
graph: it says nothing about how often a rule actually fires, whether the
|
|
245
|
+
codebase's real edges already comply, or whether a project keeps its
|
|
246
|
+
boundary by convention with no rule enforcing it at all (invisible to this
|
|
247
|
+
method either way). A rule living under an unexpected file name, inside a
|
|
248
|
+
CI configuration file, or inside a shared configuration package more than a
|
|
249
|
+
few directories deep could be missed; the "no rule found" count is a lower
|
|
250
|
+
bound, not a proof of absence. Per-package export maps and TypeScript
|
|
251
|
+
project references were not read as a source of boundary evidence in this
|
|
252
|
+
sample. As with the first survey, a count here is a frequency within this
|
|
253
|
+
sample, not a share of all public TypeScript code.
|
|
254
|
+
|
|
255
|
+
## What real import graphs keep
|
|
256
|
+
|
|
257
|
+
Both samples above read declared configuration files, not the code itself. A
|
|
258
|
+
third pass, done on 2026-09-25, instead measured the real import graph of the
|
|
259
|
+
50 most-starred public TypeScript repositories (star order; 5 skipped for not
|
|
260
|
+
qualifying as a real codebase, one not measured because analysis ran out of
|
|
261
|
+
memory), independent of whether each one declares any rule at all. Two of the
|
|
262
|
+
50 already appear by name elsewhere in this document (VS Code, in the sample
|
|
263
|
+
below) - every other repository is described only by size and shape, per this
|
|
264
|
+
project's own policy on naming other codebases.
|
|
265
|
+
|
|
266
|
+
**Method.** Each repository was cloned once, shallow (`--depth 1`); nothing
|
|
267
|
+
from it was installed, built, or run. To make a workspace's own internal
|
|
268
|
+
packages resolvable without running an install, `node_modules/<name>` was
|
|
269
|
+
symlinked to each package directory a repository's own manifest named;
|
|
270
|
+
third-party packages stayed unresolved throughout. A monorepo got one module
|
|
271
|
+
per workspace package; every other repository used archstrict's own init-walk
|
|
272
|
+
(one module per directory holding `.ts` under `src/` or the project root). A
|
|
273
|
+
production graph drops every edge whose importing file is test code, since a
|
|
274
|
+
test file importing a sibling package as a fixture is not the same claim as
|
|
275
|
+
production code doing it; cycles and layering are measured on this production,
|
|
276
|
+
value-import graph, with type-only imports counted as a separate, second
|
|
277
|
+
question rather than folded into the same count.
|
|
278
|
+
|
|
279
|
+
**Counts, with the declaring subset.**
|
|
280
|
+
|
|
281
|
+
| Pattern | Kept in the graph (of 50) | Of those, declares a rule |
|
|
282
|
+
|---|---|---|
|
|
283
|
+
| Layered order | 14 | 2 |
|
|
284
|
+
| Runtime/platform environments | 12 | 2 |
|
|
285
|
+
| Feature isolation with a shared kernel | 11 | 3 |
|
|
286
|
+
| Public entry only | 2 | 1 |
|
|
287
|
+
| Leaf / pure kernel | 14 | 3 |
|
|
288
|
+
| External package confined to one area | 43 | 9 |
|
|
289
|
+
| App vs lib | 5 | 0 |
|
|
290
|
+
| Test code kept out of production | 30 | 4 |
|
|
291
|
+
| Host/plugin inversion | 10 | 2 |
|
|
292
|
+
|
|
293
|
+
About 9 of the 50 declare any internal boundary rule at all, read by hand from
|
|
294
|
+
each repository's own root configuration files.
|
|
295
|
+
|
|
296
|
+
**The key contrast.** Public-entry-only was the single most-declared pattern
|
|
297
|
+
in the star-ordered sample of declared configs (22 of 48 repositories that
|
|
298
|
+
declare anything). In the real graph, it holds for only 2 of these 50
|
|
299
|
+
repositories - a config declaring it is enforcing something the graph does
|
|
300
|
+
not keep on its own as a byproduct of ordinary code organization. External-
|
|
301
|
+
package confinement (43 of 50) and test separation (30 of 50) are the
|
|
302
|
+
opposite case: the most common shapes the graph already keeps, whether or not
|
|
303
|
+
any config exists to say so.
|
|
304
|
+
|
|
305
|
+
**Shapes not on the pattern list above.**
|
|
306
|
+
|
|
307
|
+
- **Test code folds a clean layering into one cycle.** In 15 of the 50
|
|
308
|
+
repositories, more modules sit inside a value-import cycle once test files
|
|
309
|
+
count than in the production graph alone - a 329-module repository went
|
|
310
|
+
from 0 modules in cycles to 46; an 84-module repository went from 0 to 2;
|
|
311
|
+
VS Code went from 4 to 6 of its 10 modules. A test file importing a sibling
|
|
312
|
+
package as a fixture is the usual cause; judging layering on the production
|
|
313
|
+
graph, not the whole-file graph, avoids counting that as a real reverse
|
|
314
|
+
dependency.
|
|
315
|
+
- **One stray import turns an ordered pair into a mutual cycle.** 17 of the
|
|
316
|
+
50 repositories have a two-module cycle in production code, and it is
|
|
317
|
+
usually lopsided rather than balanced: VS Code's own two largest modules
|
|
318
|
+
pair 951 edges one way against 1 the other; a small repository's
|
|
319
|
+
configuration file and its library pairing showed 1 edge against 48; a
|
|
320
|
+
12-module repository showed two directories pairing 1 edge against 9. One
|
|
321
|
+
direction is the intended dependency; the handful of reverse edges read as
|
|
322
|
+
the exceptions worth removing, not evidence the pair has no order at all.
|
|
323
|
+
- **Type-only imports add back edges a value-only reading misses.** In 8 of
|
|
324
|
+
the 50 repositories, counting type-only imports puts more modules inside a
|
|
325
|
+
cycle than counting value imports alone: an 85-module repository went from
|
|
326
|
+
4 modules in cycles to 73; a 9-module repository went from 0 to 3; a
|
|
327
|
+
31-module repository went from 10 to 12.
|
|
328
|
+
- **Nearly disconnected workspaces.** 14 of the 50 repositories with at least
|
|
329
|
+
5 modules have at most one real production dependency for every two
|
|
330
|
+
modules: a 10-module repository had 3 dependency pairs; a 12-module
|
|
331
|
+
repository had 5; a 5-module repository had none at all.
|
|
332
|
+
- **One large cycle instead of a layer order.** 9 of the 50 repositories have
|
|
333
|
+
a single production value cycle covering at least 30% of their modules (a
|
|
334
|
+
19-module repository with 12 of them in one cycle; a 9-module repository
|
|
335
|
+
with 3; a 21-module repository with 8); at this granularity these
|
|
336
|
+
repositories show no layered order to propose at all.
|
|
337
|
+
|
|
338
|
+
**Limits.** One commit per repository, from a shallow clone with no history -
|
|
339
|
+
nothing here says whether a kept shape is a real, ongoing decision or a
|
|
340
|
+
snapshot of one moment. Only `.ts` files were parsed at the time of this
|
|
341
|
+
measurement; a repository where `.tsx` outnumbers `.ts` has a graph that
|
|
342
|
+
omits most of its UI code. Third-party imports were never resolved, so
|
|
343
|
+
external-package confinement reads the specifier text a file wrote, and an
|
|
344
|
+
unresolved path alias could be miscounted as a package. Granularity is a
|
|
345
|
+
choice archstrict's own init-walk makes, and a different granularity would
|
|
346
|
+
draw different module boundaries and could shift which patterns are visible
|
|
347
|
+
at all. Every threshold above is this survey's own choice, not a bar drawn by
|
|
348
|
+
any measured project; the per-repository numbers this survey produced allow
|
|
349
|
+
a different threshold to be applied later. Intent is always inferred from
|
|
350
|
+
structure, never confirmed by asking anyone who wrote the code.
|
|
351
|
+
|
|
352
|
+
## The decision: a reference for agents, not a preset
|
|
353
|
+
|
|
354
|
+
archstrict does not ship a `--preset` flag, and `init`/`recommend` never
|
|
355
|
+
apply one of these patterns automatically. Instead, this survey's own
|
|
356
|
+
findings became a skill reference
|
|
357
|
+
([skills/archstrict/references/patterns.md](../skills/archstrict/references/patterns.md)):
|
|
358
|
+
recognition cues (directory names, file names, import evidence) and a
|
|
359
|
+
tested, real archstrict config for each pattern, meant for an agent to
|
|
360
|
+
read before proposing a config to a project - never applied without a
|
|
361
|
+
human or an agent looking at the project's own tree first.
|
|
362
|
+
|
|
363
|
+
The reason is what archstrict itself is: a general tag-and-constraint
|
|
364
|
+
engine over `classify`/`classifyByDirectoryName` and `edges`
|
|
365
|
+
(`allowDeny`/`order`/`point`), not a tool with a fixed vocabulary of
|
|
366
|
+
layers or domains built in. A preset fixes a vocabulary - `type:app`,
|
|
367
|
+
`scope:shared`, `domain:core`, whatever a preset author chose - and a
|
|
368
|
+
project whose own real shape does not match that vocabulary either
|
|
369
|
+
distorts its own directory names to fit the preset, or abandons the
|
|
370
|
+
preset's own rules while keeping its scaffold (exactly the "scaffold, no
|
|
371
|
+
project decision" case this survey measured so often). A pattern an agent
|
|
372
|
+
proposes from a project's own observed layout, in the project's own
|
|
373
|
+
vocabulary, keeps the engine general while still giving every project the
|
|
374
|
+
benefit of a name for the shape it already has.
|