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,883 @@
|
|
|
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
|
+
## Friend list
|
|
738
|
+
|
|
739
|
+
No category for this in the tool-search survey; 1 of 48 repositories in the
|
|
740
|
+
star-ordered survey, matching the exact shape a `declaredModules[].friends`
|
|
741
|
+
entry already exists to express - see [config.md](config.md) and
|
|
742
|
+
[rules.md](rules.md#1-public-surface-bypass).
|
|
743
|
+
|
|
744
|
+
**Recognize it.** An internal file (not the module's own public surface)
|
|
745
|
+
that most of the codebase must never import directly, but a small, named
|
|
746
|
+
group of specific files is allowed to import anyway - not "everyone", the
|
|
747
|
+
way `surface` grants access, and not "no one", the way an unlisted private
|
|
748
|
+
file works by default.
|
|
749
|
+
|
|
750
|
+
**Config.**
|
|
751
|
+
|
|
752
|
+
```ts
|
|
753
|
+
declaredModules: [
|
|
754
|
+
{
|
|
755
|
+
name: "repo",
|
|
756
|
+
glob: "src/repo/**",
|
|
757
|
+
friends: [
|
|
758
|
+
{
|
|
759
|
+
file: "internal.ts",
|
|
760
|
+
from: "src/agents/allowed.ts",
|
|
761
|
+
because: "only allowed.ts may reach the internal repository directly",
|
|
762
|
+
},
|
|
763
|
+
],
|
|
764
|
+
},
|
|
765
|
+
{ name: "agents", glob: "src/agents/**" },
|
|
766
|
+
],
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
**Caveat.** `friends` has no `edges` entry and produces no
|
|
770
|
+
`edgeRuleCoverage` row: rule 1 (`public-surface-bypass`) checks it directly,
|
|
771
|
+
before reporting a bypass. Verify it by import, not by `evaluated` count -
|
|
772
|
+
confirm the named friend's own import stays clean and a second, unlisted
|
|
773
|
+
importer of the same internal file gets `public-surface-bypass`.
|
|
774
|
+
|
|
775
|
+
## Edition split
|
|
776
|
+
|
|
777
|
+
No category for this in the tool-search survey; 2 of 48 repositories in the
|
|
778
|
+
star-ordered survey.
|
|
779
|
+
|
|
780
|
+
**Recognize it.** Two parallel directories hold an open (or community)
|
|
781
|
+
edition and a paid (or enterprise) edition of the same product. The open
|
|
782
|
+
edition must never import the paid edition; the reverse is allowed, since
|
|
783
|
+
the paid edition legitimately extends or overrides the open one.
|
|
784
|
+
|
|
785
|
+
**Config.**
|
|
786
|
+
|
|
787
|
+
```ts
|
|
788
|
+
classify: [
|
|
789
|
+
{ glob: "src/ce/**", tags: ["edition:open"] },
|
|
790
|
+
{ glob: "src/ee/**", tags: ["edition:paid"] },
|
|
791
|
+
],
|
|
792
|
+
edges: {
|
|
793
|
+
allowDeny: [
|
|
794
|
+
{
|
|
795
|
+
source: "edition:open",
|
|
796
|
+
targetNamespace: "edition",
|
|
797
|
+
allow: [],
|
|
798
|
+
because: "the open edition must never import the paid edition",
|
|
799
|
+
},
|
|
800
|
+
],
|
|
801
|
+
},
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Only one rule is needed: `allowDeny` never restricts the direction it
|
|
805
|
+
wasn't given a `source` entry for, so `edition:paid` importing
|
|
806
|
+
`edition:open` passes with no rule of its own required.
|
|
807
|
+
|
|
808
|
+
**Caveats.**
|
|
809
|
+
|
|
810
|
+
- A real project usually has the paid edition importing the open edition
|
|
811
|
+
on purpose (it extends or overrides the open code). While an
|
|
812
|
+
open-to-paid edge still exists too - the edge this rule already forbids
|
|
813
|
+
- rule 2 (`cycle`) reports the same two modules as an ordinary module
|
|
814
|
+
cycle, alongside this rule's own finding. `archstrict todo` can freeze
|
|
815
|
+
both findings the same way. Fixing this rule's own violation (removing
|
|
816
|
+
the last open-to-paid edge) also removes the cycle; do not add a
|
|
817
|
+
standing `ignoredCycles` entry for it, since an entry that no longer
|
|
818
|
+
matches a real cycle is itself a violation
|
|
819
|
+
(`stale-cycle-exception`).
|
|
820
|
+
- A second real sub-shape in the survey inverts which side is restricted:
|
|
821
|
+
callers throughout the codebase must reach the paid edition's own
|
|
822
|
+
wrapper layer, never the open edition's internals directly, so the open
|
|
823
|
+
edition can be overridden without every caller knowing which edition is
|
|
824
|
+
active. That shape is pattern (f)'s public-entry-only, scoped to one
|
|
825
|
+
edition's own directory, not a new rule of its own.
|
|
826
|
+
|
|
827
|
+
## Entry-graph budget - not expressible
|
|
828
|
+
|
|
829
|
+
No category for this in the tool-search survey; 1 of 48 repositories in
|
|
830
|
+
the star-ordered survey. **archstrict cannot express this pattern**, and
|
|
831
|
+
no combination of today's fields gets closer than the note below - say
|
|
832
|
+
this plainly rather than proposing a config that only looks like it
|
|
833
|
+
works.
|
|
834
|
+
|
|
835
|
+
**What it is.** A package's own named entry point (an `exports` map key)
|
|
836
|
+
states a maximum number of files its own value-import graph may reach at
|
|
837
|
+
all. It also names a list of areas that graph must never reach, even
|
|
838
|
+
transitively - "entry points are cost contracts." Both halves are checked
|
|
839
|
+
over the whole transitive closure from that one entry, not over any single
|
|
840
|
+
direct edge.
|
|
841
|
+
|
|
842
|
+
**Why archstrict cannot express it.** Every `edges` rule - `allowDeny`,
|
|
843
|
+
`order`, `point` - judges one direct edge at a time. `edgeRuleCoverage`'s
|
|
844
|
+
own per-rule `evaluated` count is a count of edges judged, not a count of
|
|
845
|
+
files reached transitively. Nothing in the constraint engine sums a file
|
|
846
|
+
count across a whole subgraph, and nothing sets a numeric maximum on
|
|
847
|
+
anything. Rule 2 (`cycle`) is the only rule that walks a transitive chain
|
|
848
|
+
at all, and it only ever asks "does this chain return to where it
|
|
849
|
+
started" - never "how many files does it touch" or "which areas does it
|
|
850
|
+
eventually reach".
|
|
851
|
+
|
|
852
|
+
**Closest shape available today, and its own limit (verified against a
|
|
853
|
+
fixture).** Forbid the specific forbidden areas directly, from the entry
|
|
854
|
+
file's own tag, with `edgeType: "value"` (the survey's own budget also
|
|
855
|
+
ignores type-only imports) - this is pattern (f)'s external-package-
|
|
856
|
+
confined shape, aimed at an internal area instead of an npm package:
|
|
857
|
+
|
|
858
|
+
```ts
|
|
859
|
+
classify: [
|
|
860
|
+
{ glob: "src/entry/point.ts", tags: ["kind:entry"] },
|
|
861
|
+
{ glob: "src/mid/**", tags: ["kind:mid"] },
|
|
862
|
+
{ glob: "src/area/**", tags: ["kind:forbidden-area"] },
|
|
863
|
+
],
|
|
864
|
+
edges: {
|
|
865
|
+
allowDeny: [
|
|
866
|
+
{ source: "kind:entry", targetNamespace: "kind", deny: ["forbidden-area"], edgeType: "value", because: "the entry point must not reach the forbidden area directly" },
|
|
867
|
+
],
|
|
868
|
+
},
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
The rule does fire when it is given a direct edge: point the entry file
|
|
872
|
+
straight at the forbidden area and `check` reports the expected
|
|
873
|
+
`tag-boundary` violation. The limit shows up only once a hop is added. In
|
|
874
|
+
the fixture that proves it, `src/entry/point.ts` imports only
|
|
875
|
+
`src/mid/index.ts`; `src/mid/index.ts` in turn imports
|
|
876
|
+
`src/area/index.ts`, the forbidden area, one hop further out. With that
|
|
877
|
+
one hop in place, `check` reports zero violations: the rule judges the one
|
|
878
|
+
real edge it can see (the entry importing `mid`) and passes it, because
|
|
879
|
+
`mid` carries no forbidden tag itself. The entry point's own real,
|
|
880
|
+
transitive reach into the forbidden area - through `mid` - never gets
|
|
881
|
+
judged at all. This is the gap stated above, made concrete: a direct-edge
|
|
882
|
+
rule cannot see two hops out, and there is no config that recovers the "at
|
|
883
|
+
most N files reached" half of the real pattern.
|