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,58 @@
1
+ # Prove that a new rule can report a violation
2
+
3
+ Run a positive control after you add or change a rule in `archstrict.config.ts`.
4
+ A clean result only has meaning after the rule reports the violation that it claims to prevent.
5
+
6
+ For each changed rule:
7
+
8
+ 1. Choose one proposed file that must violate only the behavior you want to prove.
9
+ 2. Send the complete proposed file content to `archstrict simulate --json`.
10
+ 3. Find an `added` entry with the expected rule id.
11
+ 4. Confirm that its `config.pointer` identifies the config entry you changed.
12
+ 5. Fix the config rule when either assertion fails. Do not weaken the positive control.
13
+
14
+ The examples below use modules named `app`, `domain`, `ui`, and `core`.
15
+ Their surfaces are `index.ts`.
16
+ The config classifies files with `role:app`, `role:domain`, and the `layer:core`, `layer:domain`, and `layer:ui` values.
17
+
18
+ ## allowDeny
19
+
20
+ This proposal must fire the `tag-boundary` rule whose source is `role:app` and whose deny list contains `domain`:
21
+
22
+ ```sh
23
+ printf '%s\n' '{"changes":[{"path":"src/app/allow-deny-proof.ts","content":"import { value } from \"../domain/index.js\"; export const proof = value;\n"}]}' | archstrict simulate --json
24
+ ```
25
+
26
+ Confirm that `added` contains `rule: "tag-boundary"` and a fired `config.pointer` of `edges.allowDeny[0].deny[0]`.
27
+
28
+ ## order
29
+
30
+ This proposal makes the earlier `core` layer depend on the later `ui` layer:
31
+
32
+ ```sh
33
+ printf '%s\n' '{"changes":[{"path":"src/core/order-proof.ts","content":"import { value } from \"../ui/index.js\"; export const proof = value;\n"}]}' | archstrict simulate --json
34
+ ```
35
+
36
+ Confirm that `added` contains `rule: "tag-order"` and `config.pointer: "edges.order[0].sequence"`.
37
+
38
+ ## point
39
+
40
+ This proposal creates the exact forbidden `src/app/**` to `src/core/internal.ts` edge:
41
+
42
+ ```sh
43
+ printf '%s\n' '{"changes":[{"path":"src/app/point-proof.ts","content":"import { hidden } from \"../core/internal.js\"; export const proof = hidden;\n"}]}' | archstrict simulate --json
44
+ ```
45
+
46
+ Confirm that `added` contains `rule: "point-rule"` and `config.pointer: "edges.point[0]"`.
47
+
48
+ ## declaredModules surface
49
+
50
+ This proposal reaches past the `domain` module's configured `index.ts` surface:
51
+
52
+ ```sh
53
+ printf '%s\n' '{"changes":[{"path":"src/app/surface-proof.ts","content":"import { hidden } from \"../domain/internal.js\"; export const proof = hidden;\n"}]}' | archstrict simulate --json
54
+ ```
55
+
56
+ Confirm that `added` contains `rule: "public-surface-bypass"` and `config.pointer: "declaredModules[1]"`.
57
+
58
+ Delete no files after these commands: simulation keeps every proposal in memory.
@@ -0,0 +1,66 @@
1
+ # Re-architecture workflow
2
+
3
+ Use this workflow once adoption has made `archstrict check` clean, with every existing violation frozen by `archstrict todo`. It goes from "archstrict is adopted" to "here is the one move to make and its expected effect", with the evidence to prove the move worked.
4
+
5
+ ## Steps
6
+
7
+ 1. **Adopt and freeze.** `archstrict init`, then `archstrict check`, then `archstrict todo`. Prove any new `classify`/`edges` rule can fire, following [prove-rules.md](prove-rules.md), before freezing. A clean `check` with a frozen todo is the baseline every later step measures against.
8
+ 2. **Run `archstrict hotspots [--since <ref>] [--json]`.** Each module carries a score (commits times fan-in), fan-in, fan-out, frozen debt by rule, and active violations by rule. Each pair carries a co-change count and both directional shares. A pair is a boundary hotspot when it crosses a current module edge and at least one directional share is 50% or more.
9
+ 3. **Pick a candidate.** Start from the highest-score module, or the boundary-hotspot pair with the largest co-change share. A module with high score and heavy fan-in is changed often by code that depends on it. A boundary-hotspot pair is two modules that keep changing together across an edge that is supposed to keep them independent.
10
+ 4. **Read the candidate's frozen debt.** `archstrict hotspots --json` reports `frozenDebtByRule` and `activeViolationsByRule` per module; `archstrict check --frozen --module <name>` lists each entry's rule, path, and evidence, marked `frozen: true`, through the same rule id and `do:` a live violation carries - no need to open `archstrict.todo.json` by hand. Read the imports behind the count: which files import the candidate, and through which rule (a surface bypass, an order violation, a cycle).
11
+ 5. **Name the move, and state its expected numeric effect before editing.** Common moves:
12
+ - Extract a shared contract into its own module with one surface, when other modules import it only for that contract.
13
+ - Give a module a surface and route importers through it, step by step. A frozen bypass is keyed by its importing file, specifier, and target file, not by the evidence text, so adding the surface leaves every unmigrated importer's frozen entry matched. Migrate importers in as many steps as the change needs: each migrated import now targets the surface, and `todo` prunes its entry.
14
+ - Invert a dependency behind an interface the stable side owns: pass the data or callback the unstable side needs as a parameter, instead of importing it.
15
+ - Merge modules that always change together.
16
+ - Split a module whose files change for unrelated reasons.
17
+ Name the score, fan-in, debt count, or pair share the move should change, and the expected new value, before touching a file.
18
+ 6. **Simulate the move before editing.** Build the change set (moved and edited files, any surface file, any config edit) and run `archstrict simulate --json` (or `--whole-project` when the move can affect a file it does not touch directly). Read `added` for a new cycle or violation the move would create. A move that looks safe from imports alone can still expose a cycle that was hidden behind the file being moved.
19
+ 7. **Make the move in small steps.** After each step: the project's tests, `archstrict check` clean, `archstrict todo` (it must prune, and it must never add). Record the verified reason in the config's `because` field.
20
+ 8. **Re-run `archstrict hotspots`.** Confirm the candidate's score, fan-in, debt, and pair shares moved the way step 5 predicted.
21
+
22
+ ## Worked example
23
+
24
+ A CLI module has 119 commits and fan-in 6, for a score of 714. Three library modules import a shared output-writer and error-formatter from it, for output contracts unrelated to command parsing. Those edges own 7 order violations and 9 surface bypasses in the CLI's frozen debt. The CLI also co-changes with one of those library modules in 47 of its 119 commits, a 39.5%/79.7% directional share and a boundary-hotspot pair.
25
+
26
+ The move: extract the output-writer and error-formatter into a new module with one surface. Expected effect: the CLI's fan-in drops from 6 to 3, and roughly 16 frozen entries prune, since the extracted contract stops being CLI-owned and its former importers now depend on the new module's surface instead.
27
+
28
+ Simulating the move first shows no new cycle. The move applies in one step: create the new module and its surface, move the two files, update every importer to use the new surface, add the module to `declaredModules`, and record the measured importer count and rule counts in its `because`. Tests pass, `check` stays clean, and `todo` prunes with nothing added.
29
+
30
+ Measured result: frozen debt went from 444 to 428, and the CLI's fan-in went from 6 to 3. A later `archstrict hotspots` run shows the extracted module's own fan-in at 4 and fan-out at 0, confirming the dependency moved with the code instead of merely being renamed.
31
+
32
+ ## Cuts that kept the signal
33
+
34
+ These are placements that showed up once a real import graph was drawn.
35
+ They are examples to copy when the same shape is in the tree, and they
36
+ are not presets.
37
+
38
+ **Peel the CLI and the MCP server above the verbs.** A command-line entry
39
+ and an MCP server call into check, todo, and the other verbs. If a verb
40
+ imports the CLI (to reuse a flag parser, a printer, a process exit), the
41
+ two modules cycle. Keep the verbs free of the CLI. The CLI imports the
42
+ verbs. The same cut applies to an MCP server that is only a transport
43
+ over those verbs.
44
+
45
+ **A helper the graph calls sits with the graph.** Rule 6 walks types from
46
+ a module surface. The module graph builds the program that walk needs, so
47
+ the graph calls the helper. Placing that helper in the rules package
48
+ makes the graph import the rules, and a rule that imports the graph then
49
+ cycles. A type the graph and the rules both return (a violation, a leak
50
+ finding) lives where the graph can import it without the rules importing
51
+ it back. After the cut, hotspots may still show the graph and the verbs
52
+ changing together. That pair is the API: the verbs are the callers. Read
53
+ it as the boundary working, and split it only when a second caller needs
54
+ a smaller surface.
55
+
56
+ **A frozen bypass list against one module is a bucket.** If `check` or
57
+ `todo` says one module holds most of the files and most of the
58
+ public-surface bypasses, splitting that module is the move. Freezing the
59
+ list records the bucket and `hotspots` then has one score to show.
60
+
61
+ ## Pitfalls
62
+
63
+ - **A mega-module makes hotspots one number.** One module over the whole tree (or one directory that holds almost every file) scores every commit against one fan-in. Split it into the directories that change for different reasons before reading the score as a seam.
64
+ - **File-per-module is coverage.** Each file is public as itself. Group the files that change together with a `glob` array in a flat directory, or move them into a directory, and give the group one surface. Then add an `edges` rule.
65
+ - **A move can expose a hidden cycle.** One planned move was moving a single file out of a hot module and behind a new surface. The file's own imports looked ordinary: type-only dependencies on two modules, and one value import from a third. Simulating the full change set reported a new cycle fingerprint the move would create: one of the file's own dependencies already imported services back from a module the move would newly route through. `archstrict simulate` found that fingerprint before any file changed, so step 6 caught it ahead of step 7. The move waited for a fix to the exposed cycle.
66
+ - **Breaking a cycle can require injecting a dependency first.** In a separate case, two modules depended on each other in both directions: one called a lookup function owned by the other, and the other separately imported a cleanup function the first module owned. The fix moved zero files. The function on the calling side took the data it needed as a parameter from its own caller, which already had that data on hand. The cleanup function moved to the module that already owned the resource it cleaned up. That change alone turned four frozen entries stale; `archstrict todo` pruned all four and added none, taking frozen debt from 428 to 424, before the originally planned move even started.
@@ -0,0 +1,98 @@
1
+ # Reading recommend's output
2
+
3
+ `archstrict recommend [dir] [--json]` proposes boundaries from the real
4
+ import graph: without a config it previews `init`'s own walk in memory (no
5
+ file written); with one, it proposes from the declared modules instead.
6
+ Read [patterns.md](patterns.md) first for what each detected pattern's own
7
+ config looks like, and [config.md](config.md) for `surface`'s exact
8
+ semantics.
9
+
10
+ ## `mapNotes`
11
+
12
+ Zero or more notes about the declared map itself, before any pattern.
13
+ `mega-module`: one module holds at least four fifths of the analyzed files
14
+ and at least eight files. Hotspots and a frozen bypass list then collapse
15
+ into one bucket. The `do` says to split that module into directories that
16
+ change together before `archstrict todo`, and to add an `edges` rule.
17
+ `file-per-module`: at least four fifths of the modules, and at least four
18
+ of them, are single files in one directory. Each file is public as itself,
19
+ so imports between them are checked, and no growth seam is named. The `do`
20
+ shows a `glob` array that groups two of those files into one module with
21
+ one surface. A directory that happens to hold one file is a directory
22
+ seam, and it is left out of this count. Text prints every note. JSON
23
+ always includes the array, empty when neither shape holds, so a missing
24
+ field is not how "the map is finished" is spelled.
25
+
26
+ ## `patternProposals`
27
+
28
+ At most 5 proposals, ranked by evidence - a project with more detectable
29
+ shapes than that sees only its strongest-evidenced ones. `detected` on the
30
+ top-level result is the count before that cut, so a capped list never reads
31
+ as "nothing else was found." Ranking is not `support` alone: a proposal with
32
+ almost no real evidence behind it (one importer, one edge) can still reach a
33
+ clean `support` of 1, so the rank shrinks `support` toward 0 by how little
34
+ evidence backs it - a real, mostly-clean fit backed by hundreds of edges
35
+ outranks a trivially clean one backed by a single edge, even though the
36
+ trivial one's own `support` field reads higher. `support` itself is
37
+ unchanged by this: it always reports the plain fraction, not the rank.
38
+ Each proposal carries:
39
+
40
+ - `pattern` - a stable id: `layered-order`, `app-over-library`, `leaf-kernel`,
41
+ `external-package-confined`, `test-code-isolation`, `public-entry-only`,
42
+ `host-plugin-inversion`, `feature-isolation`.
43
+ - `support` - 0 to 1, the fraction of the pattern's own measured evidence
44
+ that agrees with it. A `layered-order` proposal with three modules and
45
+ one reverse edge among otherwise-unanimous real edges reports well below
46
+ 1, not a bare pass/fail.
47
+ - `evidence` - the real edge counts behind the proposal, in numbers (for
48
+ example "3 of 4 directed edges between them match this order") - complete
49
+ in JSON; a name list past 5 entries truncates to "+N more" in text only.
50
+ - `configFragment` - a pasteable `classify`/`edges` (or `declaredModules`)
51
+ snippet, with a `because` drawn from the same evidence. When one group
52
+ covers every module but a small, named few (app over library, a package
53
+ confined to one area, test code kept out of production), `classify` is
54
+ one catch-all `"**"` glob for the default tag plus one entry per named
55
+ module - not one entry per module on the default side.
56
+ - `addedViolations` - how many violations of this proposal's own rule id
57
+ the rule pipeline reports today, run in memory against the real graph.
58
+ 0 means no real edge violates it yet - not "safe to adopt blindly."
59
+ - `do` - [prove-rules.md](prove-rules.md)'s own positive-control workflow,
60
+ or, for `public-entry-only` (which adds no new rule, only narrows an
61
+ existing one), the command to see which `public-surface-bypass`
62
+ violations the proposed surfaces would retire.
63
+
64
+ Detection reads real edges between declared modules; it never guesses from
65
+ a project's framework or its `package.json` dependencies. A pair with no
66
+ directional evidence, or a set of modules whose real edges cycle instead of
67
+ order, is left out rather than forced into a proposal.
68
+
69
+ ## `surfaceProposals`
70
+
71
+ One entry per declared module with no public surface file present today and
72
+ at least one real external importer - complete in JSON; text shows the top
73
+ 5 modules and a "+N more" note past that. `candidates` ranks every file
74
+ other modules actually import from it, densest first, by distinct importer
75
+ count - complete in JSON, truncated to the top 3 per shown module in text.
76
+ `proposedSurface` is the smallest ranked prefix covering at least 80% of the
77
+ module's own real imports (`coveredImports` of `totalImports`,
78
+ `remainingImports` left over), offered as a `surface` value to paste into
79
+ that module's `declaredModules` entry. `choices` gives the same alternative
80
+ every surface-less module finding does: set the proposed `surface`, add a
81
+ barrel file naming a different real entry, or leave a small module private
82
+ and freeze its bypasses with `archstrict todo`. When the module is the
83
+ `mega-module` from `mapNotes`, the third choice says to split it before
84
+ freezing, and text prints that line under the module. Text prints the
85
+ three ordinary choices once for the whole section, then one
86
+ module-specific `do:` line per shown module (the `surface` edit); `choices`
87
+ itself stays complete per module in JSON.
88
+
89
+ ## Reading a proposal before pasting it
90
+
91
+ 1. Confirm the evidence against the tree itself - `evidence`'s own numbers
92
+ should match what a quick read of the real imports shows.
93
+ 2. Paste `configFragment` into `archstrict.config.ts`, then follow the
94
+ proposal's own `do:` line: [prove-rules.md](prove-rules.md)'s positive
95
+ control for a new `classify`/`edges` rule, or a plain `archstrict check`
96
+ for a `surface` addition.
97
+ 3. Only after the positive control fires under the expected rule id, treat
98
+ a clean `archstrict check` as real - not before.
@@ -0,0 +1,149 @@
1
+ # The rules
2
+
3
+ Every rule's violation carries `rule`, `path`, `line`, `column`, `evidence`, `because`, `config`, and `do`. `config` is one pointer or an array of pointers. Each pointer has `{ path, pointer, value, line, column, role }`. `pointer` is a property path such as `edges.allowDeny[0].deny[1]`. `value` is the JSON value at that path. `line` and `column` locate its source text. `role` is `fired` when the value produced the violation, `governs` when the value makes the rule apply, or `edit-here` when the remediation names a different config location. An array keeps the `fired` pointer first. Text output prints the same data as `config: <path>:<line>:<column> <pointer> (<role>)` before `do:`. Rules 1, 2, 6, and 7 (the constraint engine) also carry `todoModule` - the module a violation belongs to, and the only rules `archstrict todo` can freeze (a violation with no `todoModule` names a module directory, a module pair, or the config file, none of which `todo` has anywhere to freeze it into).
4
+
5
+ ## 1. public-surface-bypass
6
+
7
+ An import from outside a module reaches a file other than that module's surface file (or the module has no surface file at all - every external import into it violates). Counts a type-only (`import type`) edge the same as a value edge, and an `import("./x").Y` type-position reference the same as either: reaching an internal file for its types alone still reaches past the public surface. A `declaredModules` entry's own `friends` (see below) is checked before a bypass is reported: an importer matching a `friends` entry's `from` glob, reaching that entry's own `file` glob, is exempt - every other importer of that file still violates.
8
+
9
+ - because: "a module's public surface is its only public surface; everything else is private"
10
+ - do: `add a <surface> to <module>/ naming what it exports`, or `import from <module>/<surface> instead, or add the needed export there`. When the module's glob names a single file, there is no directory to add a surface file into: `set surface on '<module>' to match <file>, or stop importing it; this module is that file, not a directory` (or `import from <file> instead, or add the needed export there` when a surface file is already present).
11
+ - `todoModule`: the module whose surface was bypassed (the import's target, not its source)
12
+
13
+ ### `friends` - a per-consumer exception
14
+
15
+ A module's `surface` is public to every importer equally, or private to all. `declaredModules[].friends` (`{ file, from, because }[]`) is narrower: `file` (relative to the module, may itself be a glob) is public to exactly the importers `from` (a project-relative glob) matches, private to everyone else. A real, motivating case: a large monorepo's own semi-private internal-utilities file documented two legitimate consumer classes (that package's own implementation code, plus a specific first-party group of other packages routed through one particular re-export) with different rules for each - a shape `surface` alone cannot express, since it only ever grants or denies visibility project-wide.
16
+
17
+ Decided as a field on `declaredModules[]` entries, not a new top-level `Config` field - this is a property of one module's own surface, not a project-wide rule.
18
+
19
+ Alternative refused: `edges.allowDeny`'s own `exceptions` field already has the right shape (`{ from, to, because }` glob pairs) and could in principle be read by rule 1 too, adding zero new schema. Refused because `exceptions` lives inside one specific `allowDeny` rule's own evaluation loop (confirmed: `isExemptedByGlobPair` in src/rules/constraints.ts is only ever called from within `computeAllowDeny`'s per-rule pass) - a project with no `edges` rules at all, but one real friend relationship, would have to author a vacuous `allowDeny` rule purely to host the exception. A dedicated field on `declaredModules[]` needs no such rule to exist first.
20
+
21
+ Not the same gap as a package's own multiple real entry points (see the `surface`-as-glob guidance in config.md): a public entry point covers everyone equally, and `surface` already handles that (a glob matching every entry point a package's own `exports` map names). `friends` is for a file that is genuinely private to most importers and public to a specific, named few - a narrower, different relationship.
22
+
23
+ ## 2. cycle
24
+
25
+ A module-level cycle: two or more modules import each other, directly or through a chain, forming a strongly-connected component. One violation per component, regardless of its size or how many edges it contains. Only non-type-only edges count - a type-only cycle has no runtime consequence, and TypeScript itself allows it.
26
+
27
+ - because: "modules that import each other cannot be reasoned about, tested, or replaced independently"
28
+ - evidence: the shortest simple cycle within the component, e.g. `a -> b -> c -> a`
29
+ - do, for a cycle of two modules: `<a> imports <b> <n> time(s): <edges>; <b> imports <a> <n> time(s): <edges>; either extract the part both sides use into a leaf module that <a> and <b> both import, or pass the dependency in from the side that owns it, so the other side stops importing it; run archstrict simulate on the planned change first`. Each side lists at most 3 file pairs, then `(and N more)`. The two moves are the ones [rearchitect.md](rearchitect.md) calls extracting a shared contract and inverting the dependency.
30
+ - do, for a cycle of three or more modules: `break the cycle at <from file> -> <to file> (module <m1> -> <m2>), or merge the modules involved - real import chain: <chain>`, e.g. `break the cycle at src/a/index.ts -> src/b/index.ts (module a -> b), or merge the modules involved - real import chain: src/a/index.ts -> src/b/index.ts, src/b/index.ts -> src/a/index.ts`
31
+ - `todoModule`: the name-first module among the ones in the component
32
+
33
+ A pair of modules in the component is lopsided when it has value edges in both directions, one direction has at most 3 edges, and the other has at least 3 times as many. The small side is usually the accidental import. The pair can be any two modules in the component, not only the two next to each other in the evidence cycle. When a component has a lopsided pair, `do:` names that pair's minority imports first, then gives the text above as the alternative, e.g. `remove the 1 import(s) from b to a (a imports b 6 times, so b -> a is likely the unintended direction): src/b/index.ts -> src/a/index.ts; alternatively, break the cycle at ...`. The count is import statements; the file list names each file pair once. When several pairs are lopsided, the highest majority-to-minority ratio wins, and a tie goes to module-name order. Without a lopsided pair, `do:` is the text above alone. `evidence` and the fingerprint never change.
34
+
35
+ A known cycle can be exempted by naming any two of its modules in config's `ignoredCycles` (order doesn't matter): `ignoredCycles: [["a", "b"]]` suppresses the whole component both belong to, not just that one edge - a cycle is one finding regardless of how many modules or edges it spans. An `ignoredCycles` pair that no longer matches any real cycle is itself a violation (`stale-cycle-exception`, below) - an exception that hides nothing real must be visible, not silently kept.
36
+
37
+ ## 3. uncovered-module
38
+
39
+ A real file (not excluded) matches no `declaredModules` entry - the same fact `graph.outsideFiles` already tracks, reported here instead of silently skipped. Not freezable: the file belongs to no module, so there is no module directory to freeze it into - the only fix is a config change (declare a module for it, or exclude it).
40
+
41
+ - because: "a file matching no declared module is unchecked, not passing"
42
+ - `path`: the file itself
43
+ - do, for a lone file: `add { name: "sqlite.ts", glob: "src/sqlite.ts", surface: "sqlite.ts" } to declaredModules in archstrict.config.ts, or add "src/sqlite.ts" to exclude if it is not module content; then run archstrict init`
44
+ - do, for a directory (this and every other uncovered file directly inside it): `add { name: "extra", glob: "src/extra/**" } to declaredModules in archstrict.config.ts, or add "src/extra/**" to exclude if it is not module content; then run archstrict init`
45
+
46
+ A file entry always names itself as `surface`: an entry without one makes that single-file module entirely private (its default surface, `index.ts`/`index.tsx`/`index.mts`/`index.cts`, resolves to a different file), so following the `do:` literally would create a public-surface-bypass instead of fixing the coverage gap. `archstrict rules <path>` prints the identical entry for the same file, and `archstrict init`'s own re-run lists one `declare:`/`or exclude:` pair per uncovered directory or file the same way. `archstrict check`'s own footer changes while any `uncovered-module` violation exists: `do: add each uncovered-module file to declaredModules or exclude in archstrict.config.ts, then run archstrict check`, not `do: archstrict todo` - the same reason this rule isn't freezable applies to the whole run, not just this one violation.
47
+
48
+ ## 4. empty-rule-set
49
+
50
+ A configured rule that structurally cannot match anything: zero `declaredModules` entries at all, a `classify` glob matching zero real files in scope, a `deprecated` entry whose actual edge count is exactly 0 (handed off from rule 5, which deliberately does not report that case itself), or an `edges` rule (`allowDeny`/`order`/`point`) whose own source/target combination never applies to any real edge in the graph. A rule that checks nothing must not look like a pass. `check`'s own `edgeRuleCoverage` field reports, per configured `edges` rule, how many real edges it actually evaluated - the same number this violation's own zero case reads off, exposed directly so authoring a new rule doesn't need a throwaway script against the graph to tell "0 violations, genuinely clean" from "0 violations, checked nothing" (a real, measured trap: writing an `edges` rule whose `targetNamespace` names a tag classify never assigns to anything in scope produces exactly this silent, meaningless "clean" pass - a workspace's own sibling-package imports were a concrete, previously-real instance of this, before this project's resolver learned to tell a workspace sibling apart from a genuine external dependency; see rule 7 below).
51
+
52
+ **A zero from an `edges` rule you haven't seen fire is an untested hypothesis, not evidence.** `evaluated > 0` only proves the rule had real edges to judge, not that its `allow`/`deny`/`sequence`/`from`/`to` shape is the one you meant to write. Before trusting a clean pass on a newly-written rule, inject a real edge you expect it to forbid, confirm the violation actually fires, then revert - a positive control any rule-writer should apply before trusting a rule that reports zero.
53
+
54
+ - because: "a rule that checks nothing must not look like a pass"
55
+ - `path`: the config file, not a module
56
+ - `do` (one of four, depending on which case fired):
57
+ - zero `declaredModules` entries: `add at least one declaredModules entry in archstrict.config.ts`
58
+ - a `classify` glob matching no file: `remove this classify entry from archstrict.config.ts, or point its glob at real files`
59
+ - a `deprecated` entry whose actual edge count fell to 0: `remove the '<from> -> <to>' entry from deprecated in archstrict.config.ts`
60
+ - an `edges` rule (`allowDeny`/`order`/`point`) with `evaluated: 0`: `remove or correct this <kind> entry in archstrict.config.ts's edges - its own source/target never applies to any real edge this project has (a workspace-sibling import may resolve as an external package rather than a project tag; see rules.md)`
61
+
62
+ ### exhaustive-allow-list
63
+
64
+ An `allowDeny` rule whose `allow` list names every value of its `targetNamespace` that exists in the graph today. Such a rule cannot fire on any current or future edge between those values, so it passes without checking anything - the same failure rule 4 reports, reached by a list instead of by a missing target.
65
+
66
+ - The universe is every value of `targetNamespace` that any file in the graph carries, not only the values this rule's source reaches today. A value reached only by another source still counts.
67
+ - The source's own tag is left out of the universe: a target that shares it is always exempt.
68
+ - `exceptions`, `edgeType`, and `importForm` do not shrink the universe. A value that only exempted, type-only, or dynamic edges reach still keeps the list from being exhaustive.
69
+ - Only `allow` lists are checked. A `deny` list may name a value that does not exist yet, to guard against a future edge.
70
+ - A rule with `evaluated: 0` is reported as `empty-rule-set` instead, never as both.
71
+ - On a small graph, a list can become exhaustive by accident. Add a value the rule must forbid (or wait until one exists) before you trust the rule.
72
+
73
+ - evidence: `allowDeny rule '<identifier>' allows every real target value with allow [...]`
74
+ - because: the rule's own `because`
75
+ - `path`: the config file
76
+ - do: `narrow the allow list for '<identifier>' in archstrict.config.ts to a genuine subset of real target values, or remove the rule if it should forbid nothing today`
77
+
78
+ ## 5. deprecated-edge-increased / deprecated-edge-decreased
79
+
80
+ A `deprecated` entry in the config names an edge between two modules and a `count` it must not exceed - tach's own deprecated-dependency idea (warn, don't forbid), with "must not grow" added on top. The actual edge count exceeding the declared `count` is a violation (`rule: "deprecated-edge-increased"`); the actual count falling strictly between 0 and the declared count is a `suggestion` under a different rule id (`rule: "deprecated-edge-decreased"`, informational, never affects the exit code - the edge shrank, which is progress, not a failure). `because` is mandatory in the config; deprecating an edge without a reason is a decision no future reader can judge.
81
+
82
+ - violation (`deprecated-edge-increased`) do: `reduce <from> -> <to> back to <count> edges, or raise count in archstrict.config.ts and record why the increase was accepted`
83
+ - suggestion (`deprecated-edge-decreased`) do: `update count to <actual> for <from> -> <to> in archstrict.config.ts`
84
+ - Does not suppress rule 1: a deprecated edge that also bypasses its target's surface is still a rule-1 violation.
85
+
86
+ ## 6. type-leak
87
+
88
+ A module's surface file re-exports or otherwise exposes an internal declaration - one declared inside a real declared module's own directory (never a loose file outside every module, and never under `node_modules`) and named by NO declared module's own surface - without the consumer ever having a name for it. "Named by" resolves through an aliased re-export (`export { X as Y }`) and through a chain of re-exports, and counts a name from ANY declared module's own surface, not only the exposing module's own: a type a consumer can already `import { Y } from "b"` is not a leak in module `a`'s surface either, even though `a`'s own surface never re-exports it itself. A structural leak: recurses through an exported symbol's properties, index signatures, union members, a generic type reference's own type arguments (`Promise<Internal>`, `Map<K, Internal>`, `Array<Internal>`), and a function's return type directly. A generic type parameter (a substitutable variable, not a declaration) and an anonymous type literal are excluded - neither has a name a consumer could fail to import. A type declared outside every declared module's own directory (a loose root-level file, or one inside a module that isn't declared) is never flagged by this rule at all - it belongs to no module's own boundary, so it can't leak from one. Neither is a type declared under `node_modules` - a real dependency's own type, whatever module's glob base happens to contain it (a root-based glob like `"**"` puts the project's own `node_modules` inside a module's literal directory, but the consumer already names that type from the package it imported it from, not from this project's own boundary to keep).
89
+
90
+ One violation per (module, internal type), not per exported symbol that reaches it: a single never-exported internal type can be referenced by many different exported symbols in the same module at once (measured directly, against a real, large library's own client package: a single internal type referenced by 51 different exported symbols) - that is one real fact (this type has no public name here), not 51 separate ones, and fixing it (one re-export) resolves every one of those references at once. `evidence` names every referencing exported symbol, up to 10 - past that, "(and N more)". The named exports are sorted alphabetically (plain ascending string sort), not by declaration order or by which export the walk happened to visit first - a rerun of `check` names them in the same order every time, regardless of file-system walk order.
91
+
92
+ The violation's own `line`/`column` anchor to the earliest referencing export's own declaration site: whichever exported symbol's own position sorts first by line, then by column, among every export that references the leaking type in one surface file. This is a deterministic, stable anchor independent of which exported symbol the walk happened to visit first - the same reasoning as the alphabetical evidence order above, applied to position instead of naming. When a module's surface is more than one file (`surface` as a glob or array), the anchor is the position within whichever surface file the leak's group was first assembled from, not necessarily the earliest position across every surface file combined.
93
+
94
+ A module can have more than one surface file. A type declared in one surface file and used in another does not count as a leak. For a type from an internal file, you can add that file to the module's `surface`, re-export the type, or remove its exposure. Consider the wider surface when you adopt module boundaries one file at a time. To compare results, run `archstrict check`, edit the real config, then run `archstrict check` again. `archstrict simulate` also previews a proposed config: include `archstrict.config.ts` and its proposed content in `changes`, alongside or instead of source changes.
95
+
96
+ - because: "a consumer needs a name for every type it receives from a public surface, not just the type doing the exposing"
97
+ - evidence: `'<InternalType>', declared in '<relative path>', is never exported by name from module '<module>' - referenced by '<Exported1>', '<Exported2>', ...`
98
+ - do, when this module owns the leaked type: `'<InternalType>' belongs to module '<module>': export '<InternalType>' by name from <surface absolute path> (it's declared in <relative path>)`
99
+ - do, when another module with no surface owns it: `'<Exported1>', ... reaches '<InternalType>', owned by module '<owner>', which has no surface: give '<owner>' a surface that exports '<InternalType>', or drop '<Exported1>', ... from this surface`. Naming the type from this surface cannot fix this case.
100
+ - do, otherwise (the owner has a surface that does not name the type, or no module owns the file): `export '<InternalType>' by name from <surface absolute path> (it's declared in <relative path>), change the referencing exports to not expose it, or add <relative path> to this module's own surface` - `<surface absolute path>` is the surface file's full absolute path (e.g. `/project/src/m/index.ts`), unlike rule 1's own `<surface>` placeholder above, which is the bare file name
101
+ - `todoModule`: the module owning the leaking surface (a leak is a self-violation, not a cross-module edge)
102
+
103
+ ## 7. tag-boundary / tag-order / point-rule (the constraint engine)
104
+
105
+ Three shapes over `config.edges`, generalizing rules 1/2's fixed module vocabulary to tags (`classify`/`classifyByDirectoryName`). Each rule is evaluated independently, blind to every other rule's namespace: an edge violates if ANY ONE applicable rule says no. A rule scoped to a tag namespace says nothing about a target with no tag in that namespace at all (that's rule 3's territory, not this rule's concern) - an edge into an untagged file or an untagged external package simply never matches. `edgeType` (`"value"`/`"type"`/`"both"`, default `"both"`) and `importForm` (`"static"`/`"dynamic"`/`"both"`, default `"both"`) filter which edges a given `allowDeny`/`order`/`point` rule can match at all, checked before its own allow/deny, sequence, or from/to logic runs - the same filter, on all three shapes.
106
+
107
+ An edge reaching a genuinely external target (a real npm package, a node builtin) carries a synthesized `pkg:<name>` tag instead of the real file's classify tags - a `targetNamespace: "pkg"` rule constrains what a source may import from outside the project at all (VS Code's own per-layer external-package restrictions are the motivating case). A workspace's own sibling package - symlinked into `node_modules` by the package manager, which resolves exactly like a real dependency to TypeScript - is not treated as external: the edge's target keeps this project's own `classify` tags, so a `kind:`/`domain:`/`layer:` rule can constrain traffic between a monorepo's own packages, the same way it constrains traffic between plain directories.
108
+
109
+ `<name>` is the resolved module's real identity, not always the bare specifier a rule author wrote: a package shipping no bundled type declarations of its own resolves through its own `@types/<name>` shadow package instead (TypeScript's own resolver, several real, common npm packages this way) - so a rule targeting the bare name alone would silently match nothing, with `edgeRuleCoverage`'s own `evaluated` count still nonzero (the edge WAS judged, just under the wrong identity - rule 4's `evaluated: 0` case can't catch this, since the rule did fire). Both identities are tagged (`pkg:express` and `pkg:@types/express`, for a package resolving that way), so a rule written against either one matches the same real edge.
110
+
111
+ A node builtin also carries a second, shared `pkg:node` tag alongside its own bare-name tag (`pkg:fs` and `pkg:node`, for `node:fs`) - a rule targeting `pkg:node` alone bans every builtin at once (a real, common need for code that must never touch a Node API at all, a browser-runtime layer being the motivating case), without a rule author having to enumerate each specific builtin they currently know is imported and silently under-protecting against the next one nobody thought to add.
112
+
113
+ **`allowDeny`** (`rule: "tag-boundary"`): a `source` tag's allow-or-deny list over one `targetNamespace` at a time. A target sharing the source's own tag value is unconstrained by that rule - "the same group as source" is never restricted. An `exceptions` list (`{ from, to, because }[]`, glob pairs on the real file paths) overrides the rule either way for a specific edge - `allowDeny`'s own field only; `point` (below) has no `exceptions` of its own, since its `from`/`to` predicates are already as explicit as a rule gets.
114
+
115
+ - because: whatever the `allowDeny` entry's own `because` gives (mandatory)
116
+ - evidence: `'<specifier>' (from '<source tag>') reaches '<violating tag>'`
117
+ - do: `remove this edge, or add '<value>' to '<source>'s allow list in archstrict.config.ts and record why`
118
+ - `todoModule`: the edge's own source module
119
+
120
+ **`order`** (`rule: "tag-order"`): a `tagNamespace`'s values must appear in `sequence`, in declared order, `direction: "downward-only"` meaning a source may depend on its own layer or an earlier one, never a later one. `within` scopes the rule to edges sharing the same value in a second namespace (e.g. one `sequence` per `domain`) - a `within` value with no `sequence` entry at all is silently out of scope for that rule, not an error (a domain legitimately needing no internal layering); a `within` value that DOES have a `sequence` but doesn't list one of the two layer values classify actually assigned is a thrown config error (a real omission, not a design choice).
121
+
122
+ - because: whatever the `order` entry's own `because` gives (mandatory)
123
+ - evidence: `'<specifier>' reaches '<target layer>' from '<source layer>' (<namespace> sequence: <a> -> <b> -> ...)`
124
+ - do: `move this edge to depend only on '<namespace>' values at or before '<source layer>' in archstrict.config.ts's sequence, or restructure the code so it does`
125
+ - `todoModule`: the edge's own source module
126
+
127
+ **`point`** (`rule: "point-rule"`): an explicit forbidden `from -> to` edge, each side either a glob (matched against the real project-relative path; never matches an external target) or a tag predicate - `from` may be `{ tags, exclude? }` (every listed tag must be present, and if `exclude` is given, none of its tags may all be present at once), but `to` is `{ tags }` only, with no `exclude` of its own. The narrowest, most explicit of the three shapes - a specific pair a broader `allowDeny`/`order` rule doesn't already cover.
128
+
129
+ - because: whatever the `point` entry's own `because` gives (mandatory)
130
+ - evidence: `'<specifier>' matches a forbidden edge`
131
+ - do: `remove this edge, or narrow the point rule '<from> -> <to>' in archstrict.config.ts if it's too broad`. Glob values appear unchanged; tag predicates use `JSON.stringify` for `<from>` and `<to>`.
132
+ - `todoModule`: the edge's own source module
133
+
134
+ ## must-be-empty
135
+
136
+ A directory a team decided must hold no code at all (e.g. a project that keeps rich models and no service objects declares `app/services` must stay empty, an anti-pattern guard). Distinct from rule 4 (`empty-rule-set`): that rule flags a rule that structurally cannot match anything; this one flags a real file existing where config says none should. A violation is any file matching config's `mustBeEmpty` glob at all - zero matches is a clean pass, not silence. The glob is project-root-relative, the same convention every rule follows now that `check`/`todo` only ever build a declared-mode module graph.
137
+
138
+ - because: whatever `mustBeEmpty`'s own entry gives (mandatory, same as every other root-level rule with a reason to record)
139
+ - `path`: the matching file itself; `line`/`column` are always `1`/`1` (no single line is "the" violation - the file's existence is)
140
+ - do: `move '<file>' out of '<glob>', or drop this mustBeEmpty entry in archstrict.config.ts if the restriction no longer applies`
141
+ - Not freezable: a file that shouldn't exist at all isn't debt to track, it's a file to move or a rule to remove.
142
+
143
+ ## Not a rule of its own: stale-todo, clean-module-has-todo, and stale-cycle-exception
144
+
145
+ `stale-todo`: a todo entry matches no current violation. `path` is `archstrict.todo.json`, at that entry's own line - fix it there with `archstrict todo`; don't leave it, an unmatched entry hides nothing real. `entryPath` is the entry's own stored file (the importer, for rules 1 and 7; a cycle's own arbitrary anchor edge; a type-leak's own surface) - a `check <file>` scoped to that exact file, or a `check <dir>` scoped to a directory containing it, still surfaces this violation (matched through `entryPath`, not `path`), so the moment an edit retires a frozen violation the agent sees the stale entry it just created.
146
+
147
+ `clean-module-has-todo`: a module in the config's `strict` list has any todo entries at all, existing or new. `path` is `archstrict.todo.json`, at that module's own key - config-level, so only a plain, unscoped `check` reports it; a `check <file>` or `check <dir>` never does. Staying clean means no debt, not debt frozen at whatever existed when the module was marked - fix the violation(s), then run `archstrict todo` to prune.
148
+
149
+ `stale-cycle-exception`: an `ignoredCycles` entry names two modules that aren't part of any real cycle at all (never were, or no longer are). `path` is the config file. Remove the entry - same reasoning as `stale-todo`: an exception that hides nothing real must be visible, not silently kept.
@@ -0,0 +1,109 @@
1
+ # Preview changes with `archstrict simulate`
2
+
3
+ Run `archstrict simulate [--json] [--whole-project]` from the project root.
4
+ The command reads a JSON object from stdin:
5
+
6
+ ```ts
7
+ {
8
+ changes: { path: string; content: string | null }[];
9
+ }
10
+ ```
11
+
12
+ Each path identifies a file to create, replace, or delete.
13
+ Relative paths resolve from the project root.
14
+ A string supplies the full proposed file content, not a patch.
15
+ Use `null` to delete a file in the simulation.
16
+ By default, the command reports only violations whose `path` is one of the changed files.
17
+ It uses the same focused type-leak analysis as `check <file>` when a changed file is a module surface.
18
+ Use `--whole-project` when a proposal can create a violation on an unchanged file, such as a new target that makes an existing import resolvable.
19
+ Whole-project mode also suits CI and multi-file refactors that need the complete delta.
20
+ It compares the proposal with the project on disk and leaves the files unchanged.
21
+
22
+ For example:
23
+
24
+ ```sh
25
+ printf '%s\n' '{"changes":[{"path":"src/app/index.ts","content":"export const value = 1;\n"}]}' | archstrict simulate --json
26
+ ```
27
+
28
+ ## Results
29
+
30
+ With `--json`, the result has this shape:
31
+
32
+ ```ts
33
+ {
34
+ mode: "scoped" | "whole-project";
35
+ added: Violation[];
36
+ resolved: Violation[];
37
+ unchangedCount: number;
38
+ }
39
+ ```
40
+
41
+ Each violation includes `rule`, `path`, `line`, `column`, `evidence`, `because`, `config`, and `do`.
42
+ `config` is one `{ path, pointer, value, line, column, role }` object, or an array when the violation needs more than one config location.
43
+ The comparison uses the same fingerprints as todo tracking:
44
+
45
+ - `added`: violations whose fingerprints occur after the proposal but not before it.
46
+ - `resolved`: violations whose fingerprints occur before the proposal but not after it.
47
+ - `unchangedCount`: the number of baseline violations whose fingerprints also occur after the proposal.
48
+
49
+ Unchanged violations contribute to the count; the result does not list their values.
50
+ A changed fingerprint can produce one resolved violation and one added violation for the same underlying problem.
51
+
52
+ `mode` states whether the default changed-file scope or `--whole-project` ran.
53
+
54
+ Without `--json`, the first two lines give a summary such as:
55
+
56
+ ```text
57
+ mode: scoped
58
+ added: 0; resolved: 0; unchanged: 1
59
+ ```
60
+
61
+ The text then lists added and resolved violations when present.
62
+ The command exits with code 1 when `added` is nonempty, and 0 otherwise.
63
+ Input or config errors also exit with code 1; `--json` reports them as `{ "error": "<message>" }`.
64
+
65
+ ## Proposed config
66
+
67
+ Include `archstrict.config.ts` in the same `changes` array to preview a config change.
68
+ Set its `content` to the full proposed config source.
69
+ You can combine this entry with source changes or submit it alone.
70
+ The baseline uses the config on disk; the proposed check uses the supplied config.
71
+ No separate flag or field is required.
72
+
73
+ ```sh
74
+ printf '%s\n' '{"changes":[{"path":"archstrict.config.ts","content":"export default { declaredModules: [{ name: \"app\", glob: \"src/app/**\" }, { name: \"lib\", glob: \"src/lib/**\", surface: [\"index.ts\", \"private.ts\"] }], exclude: [\"*.ts\"], because: \"Publish the library value as a supported entry point.\" };"}]}' | archstrict simulate --json
75
+ ```
76
+
77
+ The example proposes a second public entry point for `lib`.
78
+ A config entry with `content: null` fails with `cannot delete archstrict.config.ts`.
79
+
80
+ ## Todo freeze
81
+
82
+ Both checks apply the existing archstrict.todo.json before the fingerprint comparison.
83
+ A violation suppressed by a matching todo entry is absent from the baseline violation list.
84
+ If the proposal fixes that violation, it does not appear in `resolved`.
85
+ Instead, a frozen entry that no longer matches can produce an added `stale-todo` violation.
86
+ Simulation leaves archstrict.todo.json unchanged.
87
+
88
+ ## MCP
89
+
90
+ The MCP `simulate` tool accepts the same `changes` array in its arguments and uses scoped mode.
91
+ After the MCP connection is initialized, send a JSON-RPC `tools/call` request:
92
+
93
+ ```json
94
+ {
95
+ "jsonrpc": "2.0",
96
+ "id": 1,
97
+ "method": "tools/call",
98
+ "params": {
99
+ "name": "simulate",
100
+ "arguments": {
101
+ "changes": [
102
+ { "path": "src/app/index.ts", "content": "export const value = 1;\n" }
103
+ ]
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ Config proposals use the same entry shape through MCP.