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,915 @@
1
+ # Boundary patterns
2
+
3
+ This page names recurring shapes seen in existing boundary-checking
4
+ configurations in public repositories, and shows the archstrict config that
5
+ expresses each one. It does not ship as a preset: archstrict has no
6
+ `--preset` flag, and `init`/`recommend` never apply one of these
7
+ automatically. Read [config.md](config.md) and [rules.md](rules.md) first for
8
+ the exact field semantics this page assumes.
9
+
10
+ ## How to use this page
11
+
12
+ 1. Look at the project's own tree first. Directory names, file names, and
13
+ real import edges are the evidence - not a guess from the project's
14
+ framework or its `package.json` dependencies.
15
+ 2. Propose at most the patterns the evidence in that tree actually supports.
16
+ A project rarely matches only one pattern; most real configs combine two
17
+ or three.
18
+ 3. Show the proposed config to the user before writing it. Name the
19
+ `declaredModules`/`classify`/`edges` entries and the `because` for each.
20
+ 4. Positive-control every new `edges` rule before trusting a clean
21
+ `archstrict check`: inject a source file with one edge the rule should
22
+ forbid, run `check`, confirm the violation fires under the expected rule
23
+ id, then revert the injected file. `evaluated: 0` in `edgeRuleCoverage`
24
+ means the rule never judged a single real edge - not that the project
25
+ has none of that violation.
26
+
27
+ Every snippet below was run through the built CLI against a small fixture:
28
+ it loads without a config error, its `edges` rule shows `evaluated > 0` in
29
+ `edgeRuleCoverage`, and it fires on one deliberately forbidden edge while a
30
+ legitimate edge in the same fixture passes clean. The friend-list pattern
31
+ (FR) has no `edges` rule at all, so it was verified differently: a named
32
+ friend stays clean and a non-friend importer of the same file gets
33
+ `public-surface-bypass` - see that section.
34
+
35
+ ## How common is each pattern
36
+
37
+ [docs/boundary-patterns.md](../../../docs/boundary-patterns.md) records two
38
+ surveys: one that found repositories through code search for a dedicated
39
+ boundary tool's own vocabulary (82 repositories with a project-chosen rule),
40
+ and one that sampled the 200 most-starred public TypeScript repositories by
41
+ popularity alone (52 of them enforce a boundary; the per-pattern counts
42
+ below use 48, since 4 of the 52 already appeared in the first survey's own
43
+ 82). The two disagree on
44
+ which pattern is most common, because they measure different populations -
45
+ read both counts below, not just one, before calling a pattern rare.
46
+
47
+ | Pattern | Tool-search sample (of 82) | Star-ordered sample (of 48) | Kept in the real import graph (of 50) |
48
+ |---|---|---|---|
49
+ | Public-entry-only | 17 | **22**, the most common pattern in this sample | 2 |
50
+ | Layered order | **34**, the most common pattern in this sample | 10 | 14 |
51
+ | Runtime/platform environments | 23 | 13 | 12 |
52
+ | Feature isolation with a shared kernel | 20 | 2 | 11 |
53
+ | Leaf / pure kernel | 16 | 7 | 14 |
54
+ | External package confined to one area | 13 | 14 | **43**, the most common shape kept in the graph |
55
+ | Type-only exception | 5 | 10 | not measured this way |
56
+ | Host/plugin inversion | 5 | 4 | 10 |
57
+ | Hexagonal / clean | 5 | 2 | not measured this way |
58
+ | Test code kept out of production | 9 | 4 | 30 |
59
+ | Scope/domain isolation | 9 | 2 | not measured this way |
60
+ | Barrel-inverse | 5 | 3 | not measured this way |
61
+ | App vs lib | 5 | 2 | 5 |
62
+ | Two tag axes combined | 7 | 1 | not measured this way |
63
+ | Load-path isolation | no category in this sample | 8 | not measured this way |
64
+ | Edition split | no category in this sample | 2 | not measured this way |
65
+ | Composition root | no category in this sample | 2 | not measured this way |
66
+ | Friend list | no category in this sample | 1 | not measured this way |
67
+ | Entry-graph budget | no category in this sample | 1 | not measured this way |
68
+
69
+ A third measurement, done directly against the real import graph of 50
70
+ repositories rather than against declared configs, gives the fourth column
71
+ above (see
72
+ [docs/boundary-patterns.md](../../../docs/boundary-patterns.md) for its
73
+ method and limits). It flips the public-entry-only result: the pattern most
74
+ projects declare (22 of 48) is one the graph itself keeps in only 2 of 50
75
+ repositories - declaring it is enforcing something real, not writing down
76
+ what the code already does. External-package confinement and test
77
+ separation are the opposite case: the most common shapes kept in the graph
78
+ whether or not any project declares them, so a config for either is cheap to
79
+ add as a guard on an existing habit rather than a new constraint.
80
+
81
+ The same graph survey also turned up shapes worth a proposal's own guidance,
82
+ even though none of them is a distinct pattern to configure:
83
+
84
+ - A cycle that looks balanced at the module level is often lopsided at the
85
+ edge level - one direction carrying almost every edge, the other carrying
86
+ one or two. Read a lopsided cycle as "one direction is intended; remove
87
+ the few reverse edges", not as evidence the pair has no order.
88
+ - Judge a layer order on the production import graph, not the whole-file
89
+ graph. Test files routinely import a sibling module as a fixture, which
90
+ can turn a clean layering into a cycle only once tests are counted -
91
+ archstrict's own cycle and order rules already read the production graph
92
+ for this reason.
93
+ - A pattern most repositories already keep without declaring it - external
94
+ package confinement, test code kept out of production - is cheap to
95
+ propose as a guard: the codebase's own habit is already doing the work,
96
+ and the rule only needs to say so.
97
+
98
+ Public-entry-only and an external package confined to one area hold up or
99
+ strengthen across both samples - propose these with confidence when the
100
+ tree's own evidence supports them. Layered order, feature isolation, and
101
+ scope/domain isolation are common among repositories that already adopted a
102
+ dedicated tag-based tool, but drop sharply in the star-ordered sample:
103
+ propose them only when directory names and import edges support them
104
+ directly, not on the strength of these counts alone. Type-only exceptions
105
+ rise from 5 of 82 to 10 of 48 - a bigger share of a smaller sample, worth
106
+ noting but not proof the true rate tripled. Host/plugin inversion (5 of 82, 4 of 48) and hexagonal/clean architecture
107
+ (5 of 82, 2 of 48) stay small in both samples. Both include large, popular
108
+ repositories in the second sample, so "thin evidence" describes the count,
109
+ not the size of the repositories that use them.
110
+
111
+ ## (a) Layered order
112
+
113
+ **Recognize it.** Directory or package names that read as a ladder: for
114
+ example `routes`/`pages` above `features` above `components`/`ui` above
115
+ `lib`/`utils`. Or a monorepo library naming scheme with a small, fixed set
116
+ of category names attached to each package. Import evidence: a lower-named
117
+ directory's files never import from a higher-named one.
118
+
119
+ **Config.**
120
+
121
+ ```ts
122
+ classify: [
123
+ { glob: "src/core/**", tags: ["layer:core"] },
124
+ { glob: "src/mid/**", tags: ["layer:mid"] },
125
+ { glob: "src/top/**", tags: ["layer:top"] },
126
+ ],
127
+ edges: {
128
+ order: [
129
+ {
130
+ tagNamespace: "layer",
131
+ sequence: { "": ["core", "mid", "top"] },
132
+ direction: "downward-only",
133
+ because: "a lower layer must never depend on a higher one",
134
+ },
135
+ ],
136
+ },
137
+ ```
138
+
139
+ `sequence`'s array lists the foundation first, the outermost consumer last:
140
+ a source may depend on its own layer or an earlier one in the list, never a
141
+ later one.
142
+
143
+ **Caveats.**
144
+
145
+ - `downward-only` permits skipping a layer (`top` reaching `core` directly
146
+ passes). To forbid a skip, add a `point` or `allowDeny` rule naming that
147
+ specific pair.
148
+ - A same-layer edge always passes: `order` only constrains crossing
149
+ layers, never traffic within one.
150
+ - Every real value `classify`/`classifyByDirectoryName` assigns in the
151
+ `layer` namespace must appear somewhere in `sequence`, or `check` throws a
152
+ config error the first time an edge carries that value.
153
+ - `sequence` is `Record<string, string[]>`, keyed by the empty string
154
+ `""` for an unscoped rule - never a flat array on its own.
155
+
156
+ ## (c) Runtime/platform environments
157
+
158
+ **Recognize it.** Sibling directories named for a runtime: `common`/
159
+ `shared` alongside `browser`, `node`, `worker`, `electron-main`,
160
+ `electron-renderer`, or a client/server split with a `shared` folder
161
+ between them. The name often recurs at more than one depth in the tree
162
+ (any file under a directory literally named `browser/`, anywhere), not
163
+ just at the project root.
164
+
165
+ **Config.**
166
+
167
+ ```ts
168
+ classifyByDirectoryName: {
169
+ tagNamespace: "env",
170
+ names: ["common", "browser", "node", "worker"],
171
+ },
172
+ edges: {
173
+ allowDeny: [
174
+ { source: "env:common", targetNamespace: "env", allow: [], because: "common code must stay platform-neutral" },
175
+ { source: "env:browser", targetNamespace: "env", allow: ["common"], because: "browser code may use common code, never node or worker code" },
176
+ { source: "env:node", targetNamespace: "env", allow: ["common"], because: "node code may use common code, never browser or worker code" },
177
+ ],
178
+ },
179
+ ```
180
+
181
+ **Caveats.**
182
+
183
+ - `classifyByDirectoryName` matches by name only, blind to which package
184
+ the directory belongs to: a same-named directory elsewhere in the tree
185
+ for an unrelated reason (a test suite's own subdirectory happening to
186
+ share a name) gets the same tag. Use an explicit `classify` glob instead
187
+ when a name is not unique across the project.
188
+ - To also ban a platform's own npm packages or node builtins from a given
189
+ environment, add a second `allowDeny` entry with `targetNamespace: "pkg"`
190
+ - `deny: ["node"]` bans every node builtin at once (they all carry a
191
+ shared `pkg:node` tag alongside their own bare name), not just the
192
+ specific ones a rule author happened to think of.
193
+ - `allowDeny`'s own config check flags an `allow` list that, given the
194
+ edges actually present, happens to cover every real target value in that
195
+ namespace (`exhaustive-allow-list`) - a real trap in a small project
196
+ where one environment's own list currently matches everything it has
197
+ ever reached. It is a hint to re-examine the list, not a hard error.
198
+
199
+ ## (d) Feature isolation with a shared kernel
200
+
201
+ **Recognize it.** A `features/`, `modules/`, or `pages/` directory holding
202
+ several independent, same-shaped subdirectories, plus one directory that
203
+ looks like a kernel (`shared`, `core`, `common`, `lib`). Import evidence:
204
+ composition happens one level up (in a router, an app shell), not between
205
+ the feature directories themselves.
206
+
207
+ The common real shape needs no `edges` rule at all: declare each feature as
208
+ its own module (see pattern (f) below); rule 1 already forbids reaching
209
+ past a sibling feature's own surface file. The stricter shape below denies
210
+ a sibling feature outright, even through its surface.
211
+
212
+ **Config.**
213
+
214
+ ```ts
215
+ classify: [
216
+ { glob: "src/features/orders/**", tags: ["feature:orders"] },
217
+ { glob: "src/features/payments/**", tags: ["feature:payments"] },
218
+ { glob: "src/features/reports/**", tags: ["feature:reports"] },
219
+ { glob: "src/shared/**", tags: ["kind:shared"] },
220
+ ],
221
+ edges: {
222
+ allowDeny: [
223
+ { source: "feature:orders", targetNamespace: "feature", allow: [], because: "a feature may not import a sibling feature" },
224
+ { source: "feature:payments", targetNamespace: "feature", allow: [], because: "a feature may not import a sibling feature" },
225
+ { source: "kind:shared", targetNamespace: "feature", allow: [], because: "the shared kernel must not depend on any feature" },
226
+ ],
227
+ },
228
+ ```
229
+
230
+ An `allowDeny` rule automatically exempts a target sharing the source's own
231
+ tag value: `feature:orders` importing another file still tagged
232
+ `feature:orders` never violates this rule. An import into `kind:shared`
233
+ also passes untouched - it carries no tag in the `feature` namespace at
234
+ all, so this namespace-scoped rule says nothing about it.
235
+
236
+ **Caveats.**
237
+
238
+ - `source` is one exact tag value, never a wildcard: a project with many
239
+ features needs one `allowDeny` entry per feature, not one rule for the
240
+ whole namespace. Real configs in the survey do exactly this (one entry
241
+ per tag value).
242
+ - Nothing here forbids a cycle between two features formed through a third
243
+ file; rule 2 (`cycle`) already covers that separately, project-wide.
244
+
245
+ ## (f) Public entry only, leaf/pure kernel, external package confined to one area
246
+
247
+ ### Public entry only
248
+
249
+ **Recognize it.** Every cross-directory import in the tree reaches only
250
+ one file per directory - most often `index.ts`, sometimes a differently
251
+ named file (`public.ts`, `facade.ts`, `contracts.ts`), or a package's own
252
+ subpath export list in `package.json`.
253
+
254
+ **Config.** This needs no `edges` rule at all - it is exactly what a
255
+ declared module's own `surface` already enforces:
256
+
257
+ ```ts
258
+ declaredModules: [
259
+ { name: "widget", glob: "src/widget/**", surface: ["index.ts", "server.ts"] },
260
+ { name: "app", glob: "src/app/**" },
261
+ ],
262
+ ```
263
+
264
+ An import reaching `widget/internal.ts` from outside the module is
265
+ `public-surface-bypass`; reaching `widget/index.ts` or `widget/server.ts`
266
+ is not. `surface` as an array covers a package with more than one real,
267
+ sanctioned entry point (a client entry and a server entry, or a package's
268
+ own `exports` map) - every glob in the array is equally public.
269
+
270
+ **Caveat.** `public-surface-bypass` counts a type-only import the same as
271
+ a value import: reaching an internal file only for its types still
272
+ bypasses the surface. See pattern T below for the different, narrower
273
+ shape that lets a type-only import through a boundary.
274
+
275
+ ### Leaf / pure kernel
276
+
277
+ **Recognize it.** One directory - often `utils`, `lib`, `types`,
278
+ `constants`, or `helpers` - that every other area imports from, and that
279
+ never imports anything else in the project.
280
+
281
+ **Config.**
282
+
283
+ ```ts
284
+ classify: [
285
+ { glob: "src/util/**", tags: ["kind:util"] },
286
+ { glob: "src/app/**", tags: ["kind:app"] },
287
+ ],
288
+ edges: {
289
+ allowDeny: [
290
+ { source: "kind:util", targetNamespace: "kind", allow: [], because: "the leaf kernel must not depend on any other area" },
291
+ { source: "kind:util", targetNamespace: "pkg", allow: [], because: "the leaf kernel must stay pure: no npm packages, no node builtins" },
292
+ ],
293
+ },
294
+ ```
295
+
296
+ **Caveat.** A rule scoped to one namespace says nothing about an untagged
297
+ target. "Leaf" needs both an internal-project rule (`targetNamespace:
298
+ "kind"`) and, when "pure" also means no dependencies at all, a second rule
299
+ against `targetNamespace: "pkg"` - one rule alone leaves the other
300
+ namespace wide open.
301
+
302
+ ### External package confined to one area
303
+
304
+ **Recognize it.** A framework, ORM, or platform-specific npm package (or a
305
+ node builtin) imported from exactly one directory in the whole project -
306
+ often an "adapters" or "infrastructure" directory in an otherwise
307
+ framework-free core.
308
+
309
+ **Config.**
310
+
311
+ ```ts
312
+ classify: [
313
+ { glob: "src/core/**", tags: ["kind:core"] },
314
+ { glob: "src/adapters/**", tags: ["kind:adapters"] },
315
+ ],
316
+ edges: {
317
+ allowDeny: [
318
+ { source: "kind:core", targetNamespace: "pkg", deny: ["node"], because: "core must stay runtime-neutral; only adapters may touch node builtins" },
319
+ ],
320
+ },
321
+ ```
322
+
323
+ **Caveats.**
324
+
325
+ - A package resolving through its own `@types/<name>` shadow package (no
326
+ bundled types) is tagged under both identities at once
327
+ (`pkg:express` and `pkg:@types/express`) - a rule targeting either name
328
+ matches the same real edge.
329
+ - `pkg:node` bans every node builtin at once; naming individual builtins
330
+ one at a time under-protects against the next one nobody thought to add.
331
+
332
+ ## (b) Domain isolation
333
+
334
+ **Recognize it.** Business-noun directory names (`orders`, `payments`,
335
+ `sql`, `mongo`), each depending on a small, shared "core" or "framework"
336
+ domain, and rarely on each other directly. This shape was thin outside one
337
+ tag-based monorepo tool's own convention in the survey; Prisma's own
338
+ `architecture.config.json` is a public, real example of it (a domain axis
339
+ combined with a layer axis and a plane axis, each domain's own directed
340
+ allow list naming exactly which other domains it may reuse).
341
+
342
+ **Config.**
343
+
344
+ ```ts
345
+ classify: [
346
+ { glob: "src/domain/sql/**", tags: ["domain:sql"] },
347
+ { glob: "src/domain/mongo/**", tags: ["domain:mongo"] },
348
+ { glob: "src/domain/framework/**", tags: ["domain:framework"] },
349
+ ],
350
+ edges: {
351
+ allowDeny: [
352
+ { source: "domain:sql", targetNamespace: "domain", allow: ["framework"], because: "sql may reuse framework, nothing else" },
353
+ { source: "domain:mongo", targetNamespace: "domain", allow: ["framework"], because: "mongo may reuse framework, nothing else" },
354
+ { source: "domain:framework", targetNamespace: "domain", allow: [], because: "framework is the innermost domain; it depends on no other domain" },
355
+ ],
356
+ },
357
+ ```
358
+
359
+ **Caveat.** One entry per domain, the same as pattern (d): `source` never
360
+ takes a wildcard. A directed allow list (naming exactly which other
361
+ domains a given domain may reuse, not only a shared sink) is the richer,
362
+ less common variant; a plain "may only use itself and the shared domain"
363
+ list is the more common one.
364
+
365
+ ## Multi-axis tags
366
+
367
+ **Recognize it.** A path shape like `src/<domain>/<layer>/**`, where the
368
+ project layers its code the same way inside every domain. Import evidence:
369
+ a layer order (see pattern (a)) that repeats per domain, rather than one
370
+ global order for the whole project.
371
+
372
+ **Config.**
373
+
374
+ ```ts
375
+ classify: [
376
+ { glob: "src/orders/data-access/**", tags: ["domain:orders", "layer:data-access"] },
377
+ { glob: "src/orders/ui/**", tags: ["domain:orders", "layer:ui"] },
378
+ { glob: "src/payments/data-access/**", tags: ["domain:payments", "layer:data-access"] },
379
+ { glob: "src/payments/ui/**", tags: ["domain:payments", "layer:ui"] },
380
+ ],
381
+ edges: {
382
+ order: [
383
+ {
384
+ tagNamespace: "layer",
385
+ within: "domain",
386
+ sequence: {
387
+ orders: ["data-access", "ui"],
388
+ payments: ["data-access", "ui"],
389
+ },
390
+ direction: "downward-only",
391
+ because: "each domain keeps its own data-access-before-ui order; a domain's ui may not be imported by its own data-access",
392
+ },
393
+ ],
394
+ },
395
+ ```
396
+
397
+ One `classify` entry can carry more than one tag at once, in more than one
398
+ namespace - here `domain:*` and `layer:*` together. `classify` and
399
+ `classifyByDirectoryName` can also be combined (their results union): use
400
+ `classify` for one axis and ambient `classifyByDirectoryName` for the
401
+ other when the second axis's names already recur as literal directory
402
+ names throughout the tree.
403
+
404
+ **Caveat.** Every `edges` rule is evaluated independently, blind to every
405
+ other rule's own namespace: an edge violates if any one applicable rule
406
+ says no. Two axes are two independent questions, not one combined
407
+ decision - this is the `allowDeny`/`order`/`point` semantics of ANDing every
408
+ matching rule, not a special multi-axis mode.
409
+
410
+ ## Test code kept out of production
411
+
412
+ **Recognize it.** A `__tests__`, `test-utils`, or `mocks` directory that
413
+ only test files should ever import - and does not, in the tree's own
414
+ evidence, get imported by anything outside it.
415
+
416
+ **Config.**
417
+
418
+ ```ts
419
+ classify: [
420
+ { glob: "src/app/**", tags: ["kind:prod"] },
421
+ { glob: "src/__tests__/**", tags: ["kind:test"] },
422
+ ],
423
+ edges: {
424
+ point: [
425
+ { from: { tags: ["kind:prod"] }, to: { tags: ["kind:test"] }, because: "production code must not import test helpers" },
426
+ ],
427
+ },
428
+ ```
429
+
430
+ **Caveat.** `archstrict init` seeds a fresh config's own `exclude` with
431
+ common non-source directory names, including `test`-shaped ones, and with
432
+ every colocated test-file naming convention it finds on disk (`*.test.ts`,
433
+ `*.spec.tsx`, `__tests__/`, and similar). An excluded file is not a module
434
+ member, not an edge source, and not an edge target - a test directory this
435
+ pattern is meant to guard still needs its own `declaredModules` entry (or
436
+ at least stay out of `exclude`), or this rule's own `evaluated` count stays
437
+ at 0 no matter how it is written.
438
+
439
+ A `__tests__`/`test-utils`/`mocks` directory, guarded above, is a different
440
+ shape from a single test file colocated beside the production file it
441
+ tests (`payment.ts` next to `payment.test.ts`, same directory). Removing
442
+ that convention's own `exclude` entry brings the file back into analysis;
443
+ tag it with a glob sharing its own directory's full literal prefix -
444
+ `{ glob: "src/app/*.test.ts", tags: ["kind:test"] }` beside
445
+ `{ glob: "src/app/**", tags: ["kind:prod"] }` - so `classify`'s
446
+ most-specific-glob-wins precedence ties on prefix length and then prefers
447
+ the fewer-wildcard entry (one `*` beats `**`'s two), giving the test file
448
+ `kind:test` and every other file in the directory `kind:prod`. A
449
+ project-wide glob like `**/*.test.ts` does not work for this: its own
450
+ literal prefix is empty, so the directory's own production glob always
451
+ outranks it, and the file stays `kind:prod`. The point rule above,
452
+ unchanged, already reads `kind:test` from either shape once the file is
453
+ tagged that way. Removing the `exclude` entry also brings back the test
454
+ file's own `public-surface-bypass` findings (rule 1): any import in it
455
+ that reaches another module's internal file, rather than that module's
456
+ own surface, is reported again - the exact noise `init`'s default exclude
457
+ removes.
458
+
459
+ ## Type-only across a boundary
460
+
461
+ **Recognize it.** A boundary that otherwise forbids a dependency, with one
462
+ carved-out exception: a type may cross it, but a value (a function, a
463
+ class, a runtime constant) may not.
464
+
465
+ **Config.**
466
+
467
+ ```ts
468
+ classify: [
469
+ { glob: "src/client/**", tags: ["kind:client"] },
470
+ { glob: "src/server/**", tags: ["kind:server"] },
471
+ ],
472
+ edges: {
473
+ allowDeny: [
474
+ { source: "kind:client", targetNamespace: "kind", deny: ["server"], edgeType: "value", because: "client code may reach server code for types only, never at runtime" },
475
+ ],
476
+ },
477
+ ```
478
+
479
+ **Caveats.**
480
+
481
+ - `edgeType`/`importForm` exist on all three of `allowDeny`, `order`, and
482
+ `point`, with the same default (`"both"`) and the same meaning on each -
483
+ this is a modifier on an existing rule, not a pattern of its own.
484
+ - Rule 1 (`public-surface-bypass`) has no `edgeType` of its own: a
485
+ type-only import that reaches past a module's surface still violates
486
+ it, even when a separate `edges` rule would let that same type-only
487
+ import through.
488
+
489
+ ## Host/plugin inversion
490
+
491
+ **Recognize it.** A host or core area that never names a concrete plugin
492
+ by import, paired with plugins that reach the host only through one
493
+ named extension-point file or directory.
494
+
495
+ **Config.**
496
+
497
+ ```ts
498
+ classify: [
499
+ { glob: "src/core/**", tags: ["kind:core"] },
500
+ { glob: "src/plugin/**", tags: ["kind:plugin"] },
501
+ { glob: "src/extension-point/**", tags: ["kind:extension-point"] },
502
+ ],
503
+ edges: {
504
+ allowDeny: [
505
+ { source: "kind:core", targetNamespace: "kind", deny: ["plugin"], because: "the host must never import a concrete plugin" },
506
+ { source: "kind:plugin", targetNamespace: "kind", allow: ["extension-point"], because: "a plugin reaches the host only through its extension point" },
507
+ ],
508
+ },
509
+ ```
510
+
511
+ **Caveat.** This is two ordinary `allowDeny` rules facing opposite
512
+ directions over the same tag namespace, not a distinct rule shape - name
513
+ it as its own pattern in a proposal because the intent ("the host never
514
+ names a plugin") is easy to miss if it is only described as "another
515
+ allow/deny rule."
516
+
517
+ **How common.** 5 of 82 in the tool-search survey; 4 of 48 in the
518
+ star-ordered one, including two of the three most-starred enforcing
519
+ repositories in that sample - both applications with a plugin or
520
+ extension system, enforcing exactly this shape with a hand-written
521
+ checker or a general-purpose lint rule rather than a dedicated tool. The
522
+ count stays small in both samples, but the repositories using it are not
523
+ small; look for a host/plugin split in any project that ships an
524
+ extension system,
525
+ regardless of its own popularity.
526
+
527
+ ## Hexagonal
528
+
529
+ **Recognize it.** `domain`, `application`/`usecases`, `ports`,
530
+ `adapters`/`infrastructure` directory names, with a domain area that
531
+ imports no framework or I/O package at all. Thin in the tool-search survey -
532
+ every repository observed there using this shape had well under 3,000
533
+ stars - but the star-ordered survey found two large, popular repositories
534
+ enforcing this exact shape with their own hand-written checker: one names
535
+ its layers `domain`, `application`, `adapters` outright and lists the
536
+ database driver and ORM packages it confines to the `adapters` layer.
537
+ Small-project-only is no longer an accurate caveat; say instead that the
538
+ config below is the common shape once a project does adopt it, regardless
539
+ of size.
540
+
541
+ **Config.**
542
+
543
+ ```ts
544
+ classify: [
545
+ { glob: "src/domain/**", tags: ["layer:domain"] },
546
+ { glob: "src/ports/**", tags: ["layer:ports"] },
547
+ { glob: "src/adapters/**", tags: ["layer:adapters"] },
548
+ ],
549
+ edges: {
550
+ order: [
551
+ {
552
+ tagNamespace: "layer",
553
+ sequence: { "": ["domain", "ports", "adapters"] },
554
+ direction: "downward-only",
555
+ because: "domain depends on nothing; ports depend only on domain; adapters depend on ports or domain",
556
+ },
557
+ ],
558
+ allowDeny: [
559
+ { source: "layer:domain", targetNamespace: "pkg", allow: [], because: "domain stays framework-free: no npm package, no node builtin" },
560
+ ],
561
+ },
562
+ ```
563
+
564
+ This combines pattern (a)'s `order` with pattern (f)'s "pure kernel" `pkg`
565
+ rule - hexagonal is a layer order plus a purity constraint on its
566
+ innermost layer, not a new rule shape.
567
+
568
+ ## App vs lib
569
+
570
+ **Recognize it.** A project with one or more app directories and one or
571
+ more library directories, where an app may depend on a library but never
572
+ the reverse. Thin as its own explicit rule in the survey: most projects
573
+ that have this shape leave it implicit (a library-type ladder that simply
574
+ never lists an app as something importable), rather than writing it down.
575
+
576
+ **Config.**
577
+
578
+ ```ts
579
+ classify: [
580
+ { glob: "src/lib/**", tags: ["layer:lib"] },
581
+ { glob: "src/cli-app/**", tags: ["layer:app"] },
582
+ { glob: "src/web-app/**", tags: ["layer:app"] },
583
+ ],
584
+ edges: {
585
+ order: [
586
+ {
587
+ tagNamespace: "layer",
588
+ sequence: { "": ["lib", "app"] },
589
+ direction: "downward-only",
590
+ because: "a library never depends on an app; an app may depend on any library",
591
+ },
592
+ ],
593
+ },
594
+ ```
595
+
596
+ Two or more directories can share one tag value (`layer:app` here, for
597
+ both `cli-app` and `web-app`) - the constraint engine judges every edge by
598
+ its tags, never by which declared module a file belongs to.
599
+
600
+ ## Barrel-inverse
601
+
602
+ **Recognize it.** The opposite of "public entry only": code living inside
603
+ a directory must not import that same directory's own barrel file
604
+ (`index.ts`). The motive in the surveyed repositories was almost always
605
+ import cycles or tree-shaking, not a boundary against outsiders.
606
+
607
+ **Config.**
608
+
609
+ ```ts
610
+ declaredModules: [
611
+ { name: "widget", glob: "src/widget/**" },
612
+ ],
613
+ edges: {
614
+ point: [
615
+ { from: "src/widget/**", to: "src/widget/index.ts", because: "code inside widget must not import its own barrel" },
616
+ ],
617
+ },
618
+ ```
619
+
620
+ **Caveat.** This needs a glob-shaped `point` rule, not a tag-based one: a
621
+ tag-based rule scoped to the module's own tag would also forbid the
622
+ legitimate case pattern (f) exists to allow - an outside consumer
623
+ reaching the module through its own `index.ts`. `point`'s `from`/`to`
624
+ globs, matched against the real file path, let this rule apply only to
625
+ files genuinely inside the module, while an external importer (matching
626
+ no glob here at all) stays unaffected.
627
+
628
+ ## Load-path isolation
629
+
630
+ No category for this in the tool-search survey (its own rule shapes would
631
+ have folded a load-path rule into an external-package or type-only
632
+ finding); 8 of 48 repositories in the star-ordered survey name it as its
633
+ own, distinct reason.
634
+
635
+ **Recognize it.** A heavy or side-effecting module (a large third-party
636
+ library, a browser API that has a real cost to touch, an optional
637
+ dependency not every install has) imported by value from an eager,
638
+ top-level load path. The stated reason is bundle size or startup time, not
639
+ architecture - the rule's own message usually names a byte size or a
640
+ concrete cost ("pulls in a multi-megabyte bundle", "hoists a heavy
641
+ dependency into the eager load path"). The rule almost always carries the
642
+ type-only exception (a type import is free at runtime) and often a dynamic
643
+ `import()` exception too, since a lazily loaded module still incurs no
644
+ eager cost.
645
+
646
+ **Config.**
647
+
648
+ ```ts
649
+ classify: [
650
+ { glob: "src/app/**", tags: ["kind:app"] },
651
+ { glob: "src/heavy/**", tags: ["kind:heavy"] },
652
+ ],
653
+ edges: {
654
+ allowDeny: [
655
+ {
656
+ source: "kind:app",
657
+ targetNamespace: "kind",
658
+ deny: ["heavy"],
659
+ edgeType: "value",
660
+ importForm: "static",
661
+ because: "heavy must stay off the eager load path; a type-only or a dynamic import is fine",
662
+ },
663
+ ],
664
+ },
665
+ ```
666
+
667
+ `edgeType: "value"` lets a type-only import through untouched (`import
668
+ type` never counts as a value edge); `importForm: "static"` lets a dynamic
669
+ `import()` through untouched, since only a static import is evaluated
670
+ eagerly. Both filters apply before the rule's own `deny` list is checked -
671
+ narrowing what the rule can see at all, not adding an exemption after the
672
+ fact.
673
+
674
+ **Caveats.**
675
+
676
+ - Use `deny`, not an `allow` list, for this rule: on a small project, an
677
+ `allow` list naming every other real tag value quickly becomes
678
+ exhaustive by accident (see `exhaustive-allow-list` in
679
+ [rules.md](rules.md)), and a `deny` list never has that failure mode.
680
+ - This is an ordinary `allowDeny` rule with both filter fields set, not a
681
+ new rule shape - name it as its own pattern in a proposal anyway,
682
+ because "keep this off the eager load path" is a different intent from
683
+ an ordinary architectural boundary, even though the config looks similar
684
+ to pattern (f)'s external-package rule.
685
+ - A project that also wants the reverse (the heavy module must never even
686
+ be dynamically imported from a given area - a stricter "never touch this
687
+ at all") drops `importForm` entirely, going back to the plain `pkg`-rule
688
+ shape in pattern (f).
689
+
690
+ ## Composition root
691
+
692
+ No category for this in the tool-search survey; 2 of 48 repositories in the
693
+ star-ordered survey.
694
+
695
+ **Recognize it.** Exactly one named file (often the process entry point,
696
+ or a file named for wiring things together) is the only place in the
697
+ project allowed to import a concrete implementation, driver, or plugin;
698
+ every other file in that same area must go through it. The motivating
699
+ examples in the survey confine every concrete browser-engine driver, and
700
+ every use of process control at the entry point, to one file each.
701
+
702
+ **Config.**
703
+
704
+ ```ts
705
+ classify: [
706
+ { glob: "src/server/root.ts", tags: ["kind:server", "kind:root"] },
707
+ { glob: "src/server/**", tags: ["kind:server"] },
708
+ { glob: "src/engine/**", tags: ["kind:engine"] },
709
+ ],
710
+ edges: {
711
+ point: [
712
+ {
713
+ from: { tags: ["kind:server"], exclude: { tags: ["kind:root"] } },
714
+ to: { tags: ["kind:engine"] },
715
+ because: "only the composition root may reach a concrete engine",
716
+ },
717
+ ],
718
+ },
719
+ ```
720
+
721
+ `classify` gives the composition root file both its area's own tag
722
+ (`kind:server`) and a second, narrower tag (`kind:root`) that no other file
723
+ in the area carries - most-specific-glob-wins picks the file's own entry
724
+ over the directory-wide one. `point`'s `from.exclude` then reads as "every
725
+ file with `kind:server`, except one that also carries `kind:root`" - the
726
+ root file itself is invisible to this rule, so no rule exists to fire on
727
+ its own edge into `kind:engine`.
728
+
729
+ **Caveat.** A project with several concrete implementation areas the root
730
+ must reach can tag every one of them with the same value (`kind:engine`
731
+ here, even if the directories are unrelated otherwise) and keep this one
732
+ rule - `point`'s `to` matches on tags, not on a single directory, so one
733
+ shared tag value covers every area at once. Give each area a genuinely
734
+ different tag only when the root's own rule must distinguish between
735
+ them.
736
+
737
+ ## Flat directory seam
738
+
739
+ **Recognize it.** A directory that stays flat because a move is off the
740
+ table: `src/build/plan.ts`, `src/build/graph.ts`, and `src/build/emit.ts`
741
+ sit side by side, and two of them change together while the third does
742
+ not. One module per file makes each file public as itself. One module
743
+ over `src/build/**` makes every file in the directory private-or-public
744
+ together and hides the seam.
745
+
746
+ **Config.** `glob` is an array of paths in that one directory. `surface`
747
+ names the file other modules may import. `friends` names a file that one
748
+ caller may still reach.
749
+
750
+ ```ts
751
+ declaredModules: [
752
+ {
753
+ name: "plan",
754
+ glob: ["src/build/plan.ts", "src/build/graph.ts"],
755
+ surface: "plan.ts",
756
+ friends: [
757
+ { file: "graph.ts", from: "src/cli/main.ts", because: "the CLI reads the plan graph while it is built" },
758
+ ],
759
+ },
760
+ { name: "emit.ts", glob: "src/build/emit.ts", surface: "emit.ts" },
761
+ ],
762
+ ```
763
+
764
+ `plan.ts` is public. `graph.ts` is public only to `src/cli/main.ts`.
765
+ `emit.ts` is its own module. The type-leak boundary of `plan` is those
766
+ two files, so a type declared in `emit.ts` is outside it. Paths in two
767
+ directories are a config error. See [config.md](config.md).
768
+
769
+ ## Friend list
770
+
771
+ No category for this in the tool-search survey; 1 of 48 repositories in the
772
+ star-ordered survey, matching the exact shape a `declaredModules[].friends`
773
+ entry already exists to express - see [config.md](config.md) and
774
+ [rules.md](rules.md#1-public-surface-bypass).
775
+
776
+ **Recognize it.** An internal file (not the module's own public surface)
777
+ that most of the codebase must never import directly, but a small, named
778
+ group of specific files is allowed to import anyway - not "everyone", the
779
+ way `surface` grants access, and not "no one", the way an unlisted private
780
+ file works by default.
781
+
782
+ **Config.**
783
+
784
+ ```ts
785
+ declaredModules: [
786
+ {
787
+ name: "repo",
788
+ glob: "src/repo/**",
789
+ friends: [
790
+ {
791
+ file: "internal.ts",
792
+ from: "src/agents/allowed.ts",
793
+ because: "only allowed.ts may reach the internal repository directly",
794
+ },
795
+ ],
796
+ },
797
+ { name: "agents", glob: "src/agents/**" },
798
+ ],
799
+ ```
800
+
801
+ **Caveat.** `friends` has no `edges` entry and produces no
802
+ `edgeRuleCoverage` row: rule 1 (`public-surface-bypass`) checks it directly,
803
+ before reporting a bypass. Verify it by import, not by `evaluated` count -
804
+ confirm the named friend's own import stays clean and a second, unlisted
805
+ importer of the same internal file gets `public-surface-bypass`.
806
+
807
+ ## Edition split
808
+
809
+ No category for this in the tool-search survey; 2 of 48 repositories in the
810
+ star-ordered survey.
811
+
812
+ **Recognize it.** Two parallel directories hold an open (or community)
813
+ edition and a paid (or enterprise) edition of the same product. The open
814
+ edition must never import the paid edition; the reverse is allowed, since
815
+ the paid edition legitimately extends or overrides the open one.
816
+
817
+ **Config.**
818
+
819
+ ```ts
820
+ classify: [
821
+ { glob: "src/ce/**", tags: ["edition:open"] },
822
+ { glob: "src/ee/**", tags: ["edition:paid"] },
823
+ ],
824
+ edges: {
825
+ allowDeny: [
826
+ {
827
+ source: "edition:open",
828
+ targetNamespace: "edition",
829
+ allow: [],
830
+ because: "the open edition must never import the paid edition",
831
+ },
832
+ ],
833
+ },
834
+ ```
835
+
836
+ Only one rule is needed: `allowDeny` never restricts the direction it
837
+ wasn't given a `source` entry for, so `edition:paid` importing
838
+ `edition:open` passes with no rule of its own required.
839
+
840
+ **Caveats.**
841
+
842
+ - A real project usually has the paid edition importing the open edition
843
+ on purpose (it extends or overrides the open code). While an
844
+ open-to-paid edge still exists too - the edge this rule already forbids
845
+ - rule 2 (`cycle`) reports the same two modules as an ordinary module
846
+ cycle, alongside this rule's own finding. `archstrict todo` can freeze
847
+ both findings the same way. Fixing this rule's own violation (removing
848
+ the last open-to-paid edge) also removes the cycle; do not add a
849
+ standing `ignoredCycles` entry for it, since an entry that no longer
850
+ matches a real cycle is itself a violation
851
+ (`stale-cycle-exception`).
852
+ - A second real sub-shape in the survey inverts which side is restricted:
853
+ callers throughout the codebase must reach the paid edition's own
854
+ wrapper layer, never the open edition's internals directly, so the open
855
+ edition can be overridden without every caller knowing which edition is
856
+ active. That shape is pattern (f)'s public-entry-only, scoped to one
857
+ edition's own directory, not a new rule of its own.
858
+
859
+ ## Entry-graph budget - not expressible
860
+
861
+ No category for this in the tool-search survey; 1 of 48 repositories in
862
+ the star-ordered survey. **archstrict cannot express this pattern**, and
863
+ no combination of today's fields gets closer than the note below - say
864
+ this plainly rather than proposing a config that only looks like it
865
+ works.
866
+
867
+ **What it is.** A package's own named entry point (an `exports` map key)
868
+ states a maximum number of files its own value-import graph may reach at
869
+ all. It also names a list of areas that graph must never reach, even
870
+ transitively - "entry points are cost contracts." Both halves are checked
871
+ over the whole transitive closure from that one entry, not over any single
872
+ direct edge.
873
+
874
+ **Why archstrict cannot express it.** Every `edges` rule - `allowDeny`,
875
+ `order`, `point` - judges one direct edge at a time. `edgeRuleCoverage`'s
876
+ own per-rule `evaluated` count is a count of edges judged, not a count of
877
+ files reached transitively. Nothing in the constraint engine sums a file
878
+ count across a whole subgraph, and nothing sets a numeric maximum on
879
+ anything. Rule 2 (`cycle`) is the only rule that walks a transitive chain
880
+ at all, and it only ever asks "does this chain return to where it
881
+ started" - never "how many files does it touch" or "which areas does it
882
+ eventually reach".
883
+
884
+ **Closest shape available today, and its own limit (verified against a
885
+ fixture).** Forbid the specific forbidden areas directly, from the entry
886
+ file's own tag, with `edgeType: "value"` (the survey's own budget also
887
+ ignores type-only imports) - this is pattern (f)'s external-package-
888
+ confined shape, aimed at an internal area instead of an npm package:
889
+
890
+ ```ts
891
+ classify: [
892
+ { glob: "src/entry/point.ts", tags: ["kind:entry"] },
893
+ { glob: "src/mid/**", tags: ["kind:mid"] },
894
+ { glob: "src/area/**", tags: ["kind:forbidden-area"] },
895
+ ],
896
+ edges: {
897
+ allowDeny: [
898
+ { source: "kind:entry", targetNamespace: "kind", deny: ["forbidden-area"], edgeType: "value", because: "the entry point must not reach the forbidden area directly" },
899
+ ],
900
+ },
901
+ ```
902
+
903
+ The rule does fire when it is given a direct edge: point the entry file
904
+ straight at the forbidden area and `check` reports the expected
905
+ `tag-boundary` violation. The limit shows up only once a hop is added. In
906
+ the fixture that proves it, `src/entry/point.ts` imports only
907
+ `src/mid/index.ts`; `src/mid/index.ts` in turn imports
908
+ `src/area/index.ts`, the forbidden area, one hop further out. With that
909
+ one hop in place, `check` reports zero violations: the rule judges the one
910
+ real edge it can see (the entry importing `mid`) and passes it, because
911
+ `mid` carries no forbidden tag itself. The entry point's own real,
912
+ transitive reach into the forbidden area - through `mid` - never gets
913
+ judged at all. This is the gap stated above, made concrete: a direct-edge
914
+ rule cannot see two hops out, and there is no config that recovers the "at
915
+ most N files reached" half of the real pattern.