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.
Files changed (67) hide show
  1. package/.agents/hooks/hooks.json +29 -0
  2. package/.agents/hooks/post-tool-use.mjs +107 -0
  3. package/.agents/hooks/pre-tool-use.mjs +182 -0
  4. package/.agents/mcp/server.mjs +71 -0
  5. package/.agents/plugin.json +19 -0
  6. package/AGENTS.md +81 -0
  7. package/CHANGELOG.md +77 -0
  8. package/README.ja.md +142 -0
  9. package/README.md +143 -2
  10. package/dist/augmentation-cache.js +65 -0
  11. package/dist/check-options.js +40 -0
  12. package/dist/classify.js +148 -0
  13. package/dist/cli.js +243 -0
  14. package/dist/config-pointer.js +251 -0
  15. package/dist/config.js +194 -0
  16. package/dist/edge-cache.js +530 -0
  17. package/dist/gitignore.js +271 -0
  18. package/dist/mcp-server.js +111 -0
  19. package/dist/module-candidates.js +125 -0
  20. package/dist/module-graph.js +2179 -0
  21. package/dist/project-path.js +59 -0
  22. package/dist/report-error.js +13 -0
  23. package/dist/rules/config-meaning.js +143 -0
  24. package/dist/rules/constraints.js +419 -0
  25. package/dist/rules/cycles.js +285 -0
  26. package/dist/rules/deprecated.js +67 -0
  27. package/dist/rules/empty-rule.js +101 -0
  28. package/dist/rules/moves.js +79 -0
  29. package/dist/rules/must-be-empty.js +52 -0
  30. package/dist/rules/public-surface.js +100 -0
  31. package/dist/rules/uncovered.js +75 -0
  32. package/dist/todo-migration.js +112 -0
  33. package/dist/todo-store.js +434 -0
  34. package/dist/type-closure.js +959 -0
  35. package/dist/type-leak.js +590 -0
  36. package/dist/verbs/agents.js +116 -0
  37. package/dist/verbs/check.js +1011 -0
  38. package/dist/verbs/fix.js +170 -0
  39. package/dist/verbs/hotspots.js +261 -0
  40. package/dist/verbs/init.js +538 -0
  41. package/dist/verbs/map-shape.js +78 -0
  42. package/dist/verbs/recommend.js +863 -0
  43. package/dist/verbs/rules.js +188 -0
  44. package/dist/verbs/search.js +109 -0
  45. package/dist/verbs/simulate.js +220 -0
  46. package/dist/verbs/todo.js +180 -0
  47. package/dist/warm-graph.js +82 -0
  48. package/docs/boundary-patterns.md +374 -0
  49. package/docs/calibrated-rules-design.md +124 -0
  50. package/docs/init-singleton-modules.md +133 -0
  51. package/docs/maintenance.md +109 -0
  52. package/docs/releasing.md +58 -0
  53. package/docs/rules-edge-cache.md +50 -0
  54. package/docs/todo-single-file-migration.md +58 -0
  55. package/llms.txt +25 -0
  56. package/package.json +61 -4
  57. package/skills/archstrict/SKILL.md +54 -0
  58. package/skills/archstrict/references/agents-verb.md +39 -0
  59. package/skills/archstrict/references/config.md +116 -0
  60. package/skills/archstrict/references/hook.md +57 -0
  61. package/skills/archstrict/references/path-rules.md +57 -0
  62. package/skills/archstrict/references/patterns.md +915 -0
  63. package/skills/archstrict/references/prove-rules.md +58 -0
  64. package/skills/archstrict/references/rearchitect.md +66 -0
  65. package/skills/archstrict/references/recommend.md +98 -0
  66. package/skills/archstrict/references/rules.md +149 -0
  67. package/skills/archstrict/references/simulate.md +109 -0
@@ -0,0 +1,180 @@
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
+ import { dominantBypassModule, dominantBypassSentence } from "./map-shape.js";
20
+ function isFreezable(v) {
21
+ return "todoModule" in v;
22
+ }
23
+ // An uncovered-module violation has no todoModule (module-graph.ts's
24
+ // outsideFiles matches no declared module at all, so there is no module
25
+ // name to freeze it under - see isFreezable's own comment) - it can never
26
+ // be frozen, first run or later. If the first run happens while one
27
+ // exists, it freezes every OTHER current violation and writes the file,
28
+ // after which "never add again" permanently forecloses freezing it once
29
+ // it's later declared: check would stay at exit 1 forever (measured on
30
+ // two real repository shapes: 12 and 52 such violations). Refusing the
31
+ // first run outright - no file written at all - keeps the freeze
32
+ // available until every file is actually covered. A later (prune-only)
33
+ // run is unaffected: pruning only ever shrinks, so an uncovered file
34
+ // already past the first run does not block it.
35
+ function refuseIfUncovered(violations) {
36
+ const uncovered = violations.filter((v) => v.rule === "uncovered-module");
37
+ if (uncovered.length === 0)
38
+ return;
39
+ const noun = uncovered.length === 1 ? "file matches" : "files match";
40
+ 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");
41
+ }
42
+ export function freezeOrPrune(projectRoot, graph, config, violations) {
43
+ const strict = new Set(config.strict ?? []);
44
+ const freezable = violations.filter(isFreezable);
45
+ // graph.rootDir, not the raw projectRoot parameter: rootDir is
46
+ // realpath'd (module-graph.ts's own prepareGraph does this so every
47
+ // relative-path computation agrees with the paths TypeScript itself
48
+ // resolved to), while projectRoot may not be (e.g. macOS's own
49
+ // /tmp -> /private/tmp). Reading and normalizing an absolute stored path
50
+ // against the wrong base would silently fail to relativize it back to
51
+ // what a live violation's own (realpath'd) path relativizes to,
52
+ // un-matching an otherwise-identical entry - the same reasoning
53
+ // check.ts's own applyTodo already applies.
54
+ const rootDir = graph.rootDir;
55
+ const moduleDirs = new Map([...graph.modules].map(([name, m]) => [name, m.dir]));
56
+ const parsed = readTodoFile(rootDir);
57
+ // Only consulted when the new file itself is absent - a project already
58
+ // migrated (the new file exists) never re-reads the old layout, even if
59
+ // a stray legacy file somehow still sits on disk.
60
+ const legacy = parsed === undefined ? readLegacyTodoState(rootDir, moduleDirs) : undefined;
61
+ // "First run" is now the new file's own absence, AND no sign the old
62
+ // layout ever ran either - a project moving from the old layout to this
63
+ // one is a migration (folding in whatever the old layout had already
64
+ // frozen), not a fresh first run that would freeze today's live
65
+ // violations instead of what was actually already accepted as debt.
66
+ const firstRun = parsed === undefined && !(legacy?.present ?? false);
67
+ if (firstRun)
68
+ refuseIfUncovered(violations);
69
+ const currentByModule = new Map();
70
+ if (parsed !== undefined) {
71
+ for (const [name, entries] of parsed.modules)
72
+ currentByModule.set(name, entries);
73
+ }
74
+ else if (legacy !== undefined) {
75
+ for (const [name, entries] of legacy.entriesByModule)
76
+ currentByModule.set(name, entries);
77
+ }
78
+ let added = 0;
79
+ let pruned = 0;
80
+ const nextByModule = new Map();
81
+ for (const name of graph.modules.keys()) {
82
+ const current = currentByModule.get(name) ?? [];
83
+ const currentViolations = freezable.filter((v) => v.todoModule === name);
84
+ if (firstRun) {
85
+ // A strict module never gains an entry, not even on the first run:
86
+ // its violations simply stay reported by check, uncovered by any
87
+ // todo. Every other module's current violations all freeze at once.
88
+ const toFreeze = strict.has(name) ? [] : currentViolations;
89
+ const entries = toFreeze.map((v) => buildTodoEntry(v, graph.relativePath));
90
+ if (entries.length > 0) {
91
+ nextByModule.set(name, entries);
92
+ added += entries.length;
93
+ }
94
+ }
95
+ else {
96
+ // Prune (and, on a migration run, fold in): keep an existing entry
97
+ // exactly when some current violation still names it (todo-store.ts's
98
+ // own findMatchingEntry, indexed once per module rather than
99
+ // rescanned per violation - see buildTodoIndex's own comment). A
100
+ // strict module's entries prune the same as any other module's -
101
+ // strict blocks this verb from adding, not from shrinking. It never
102
+ // hides an existing entry from check, though: check reports any
103
+ // entry in a strict module's todo as its own violation
104
+ // (clean-module-has-todo), so an entry that survives pruning here
105
+ // still fails check until it's fixed.
106
+ //
107
+ // Every surviving entry is rewritten from the live violation that
108
+ // matched it (todo-store.ts's own buildTodoEntry), not kept
109
+ // byte-for-byte - self-healing for every rule (a legacy entry
110
+ // migrated with an absolute path, or one frozen under an older
111
+ // fingerprint formula, is rewritten into today's shape the first
112
+ // time it's pruned again). One entry's own object identity in
113
+ // `current` (not its content, which duplicate rows can share)
114
+ // decides which live violation refreshes it, so a genuine duplicate
115
+ // stored row - runRules can report the same edge twice - collapses
116
+ // to the one `buildTodoIndex`'s own map can still reference, instead
117
+ // of being rewritten twice over.
118
+ const index = buildTodoIndex(current);
119
+ const matchedViolationByEntry = new Map();
120
+ for (const v of currentViolations) {
121
+ const entry = findMatchingEntry(index, v, graph.relativePath);
122
+ if (entry !== undefined && !matchedViolationByEntry.has(entry))
123
+ matchedViolationByEntry.set(entry, v);
124
+ }
125
+ const kept = current
126
+ .filter((e) => matchedViolationByEntry.has(e))
127
+ .map((e) => buildTodoEntry(matchedViolationByEntry.get(e), graph.relativePath));
128
+ pruned += current.length - kept.length;
129
+ if (kept.length > 0)
130
+ nextByModule.set(name, kept);
131
+ }
132
+ }
133
+ // An entry under a module name today's graph no longer declares (the
134
+ // module was renamed or removed since it was frozen) matches no current
135
+ // violation by construction - nothing this run's rules produced even
136
+ // claims that module name - so it can only ever be pruned, never kept or
137
+ // re-added.
138
+ for (const [name, entries] of currentByModule) {
139
+ if (graph.modules.has(name))
140
+ continue;
141
+ pruned += entries.length;
142
+ }
143
+ // Written unconditionally, even with an empty module map, so the file's
144
+ // own existence keeps meaning "todo has run" - see todo-store.ts's own
145
+ // writeTodoFile comment.
146
+ writeTodoFile(rootDir, nextByModule);
147
+ if (legacy !== undefined)
148
+ deleteLegacyTodoFiles(legacy.filesToDelete);
149
+ const bypassesByModule = new Map();
150
+ for (const violation of violations) {
151
+ if (violation.rule !== "public-surface-bypass" || !("todoModule" in violation))
152
+ continue;
153
+ bypassesByModule.set(violation.todoModule, (bypassesByModule.get(violation.todoModule) ?? 0) + 1);
154
+ }
155
+ const dominant = dominantBypassModule([...graph.modules.values()].map((module) => ({ name: module.name, files: module.files.length })), bypassesByModule);
156
+ if (dominant === undefined)
157
+ return { firstRun, added, pruned };
158
+ return {
159
+ firstRun,
160
+ added,
161
+ pruned,
162
+ notes: [
163
+ `${dominantBypassSentence(dominant)}. Freezing them records one bucket. Split '${dominant.name}' into directories that change together before treating this freeze as done.`,
164
+ ],
165
+ };
166
+ }
167
+ export async function todo(projectRoot) {
168
+ const configPath = join(projectRoot, "archstrict.config.ts");
169
+ const config = await loadConfig(configPath);
170
+ // See check.ts's own comment: declaredModules is the only source of
171
+ // scope now.
172
+ const graph = buildModuleGraphForRules({
173
+ projectRoot,
174
+ declaredModules: config.declaredModules,
175
+ exclude: config.exclude,
176
+ surface: config.surface,
177
+ });
178
+ const result = runRules(graph, config);
179
+ return freezeOrPrune(projectRoot, graph, config, result.violations);
180
+ }
@@ -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.