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.
Files changed (65) 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 +69 -0
  7. package/CHANGELOG.md +38 -0
  8. package/README.ja.md +62 -0
  9. package/README.md +63 -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 +239 -0
  14. package/dist/config-pointer.js +251 -0
  15. package/dist/config.js +186 -0
  16. package/dist/edge-cache.js +530 -0
  17. package/dist/mcp-server.js +111 -0
  18. package/dist/module-candidates.js +118 -0
  19. package/dist/module-graph.js +2072 -0
  20. package/dist/project-path.js +59 -0
  21. package/dist/report-error.js +13 -0
  22. package/dist/rules/config-meaning.js +143 -0
  23. package/dist/rules/constraints.js +417 -0
  24. package/dist/rules/cycles.js +257 -0
  25. package/dist/rules/deprecated.js +67 -0
  26. package/dist/rules/empty-rule.js +101 -0
  27. package/dist/rules/moves.js +79 -0
  28. package/dist/rules/must-be-empty.js +52 -0
  29. package/dist/rules/public-surface.js +100 -0
  30. package/dist/rules/type-leak.js +562 -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/verbs/agents.js +116 -0
  36. package/dist/verbs/check.js +957 -0
  37. package/dist/verbs/fix.js +170 -0
  38. package/dist/verbs/hotspots.js +261 -0
  39. package/dist/verbs/init.js +522 -0
  40. package/dist/verbs/recommend.js +800 -0
  41. package/dist/verbs/rules.js +188 -0
  42. package/dist/verbs/search.js +109 -0
  43. package/dist/verbs/simulate.js +220 -0
  44. package/dist/verbs/todo.js +163 -0
  45. package/dist/warm-graph.js +82 -0
  46. package/docs/boundary-patterns.md +374 -0
  47. package/docs/calibrated-rules-design.md +124 -0
  48. package/docs/init-singleton-modules.md +128 -0
  49. package/docs/maintenance.md +82 -0
  50. package/docs/releasing.md +55 -0
  51. package/docs/rules-edge-cache.md +50 -0
  52. package/docs/todo-single-file-migration.md +58 -0
  53. package/llms.txt +19 -0
  54. package/package.json +57 -4
  55. package/skills/archstrict/SKILL.md +42 -0
  56. package/skills/archstrict/references/agents-verb.md +39 -0
  57. package/skills/archstrict/references/config.md +107 -0
  58. package/skills/archstrict/references/hook.md +57 -0
  59. package/skills/archstrict/references/path-rules.md +57 -0
  60. package/skills/archstrict/references/patterns.md +883 -0
  61. package/skills/archstrict/references/prove-rules.md +58 -0
  62. package/skills/archstrict/references/rearchitect.md +35 -0
  63. package/skills/archstrict/references/recommend.md +80 -0
  64. package/skills/archstrict/references/rules.md +146 -0
  65. 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,35 @@
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
+ ## Pitfalls
33
+
34
+ - **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.
35
+ - **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,80 @@
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
+ ## `patternProposals`
11
+
12
+ At most 5 proposals, ranked by evidence - a project with more detectable
13
+ shapes than that sees only its strongest-evidenced ones. `detected` on the
14
+ top-level result is the count before that cut, so a capped list never reads
15
+ as "nothing else was found." Ranking is not `support` alone: a proposal with
16
+ almost no real evidence behind it (one importer, one edge) can still reach a
17
+ clean `support` of 1, so the rank shrinks `support` toward 0 by how little
18
+ evidence backs it - a real, mostly-clean fit backed by hundreds of edges
19
+ outranks a trivially clean one backed by a single edge, even though the
20
+ trivial one's own `support` field reads higher. `support` itself is
21
+ unchanged by this: it always reports the plain fraction, not the rank.
22
+ Each proposal carries:
23
+
24
+ - `pattern` - a stable id: `layered-order`, `app-over-library`, `leaf-kernel`,
25
+ `external-package-confined`, `test-code-isolation`, `public-entry-only`,
26
+ `host-plugin-inversion`, `feature-isolation`.
27
+ - `support` - 0 to 1, the fraction of the pattern's own measured evidence
28
+ that agrees with it. A `layered-order` proposal with three modules and
29
+ one reverse edge among otherwise-unanimous real edges reports well below
30
+ 1, not a bare pass/fail.
31
+ - `evidence` - the real edge counts behind the proposal, in numbers (for
32
+ example "3 of 4 directed edges between them match this order") - complete
33
+ in JSON; a name list past 5 entries truncates to "+N more" in text only.
34
+ - `configFragment` - a pasteable `classify`/`edges` (or `declaredModules`)
35
+ snippet, with a `because` drawn from the same evidence. When one group
36
+ covers every module but a small, named few (app over library, a package
37
+ confined to one area, test code kept out of production), `classify` is
38
+ one catch-all `"**"` glob for the default tag plus one entry per named
39
+ module - not one entry per module on the default side.
40
+ - `addedViolations` - how many violations of this proposal's own rule id
41
+ the rule pipeline reports today, run in memory against the real graph.
42
+ 0 means no real edge violates it yet - not "safe to adopt blindly."
43
+ - `do` - [prove-rules.md](prove-rules.md)'s own positive-control workflow,
44
+ or, for `public-entry-only` (which adds no new rule, only narrows an
45
+ existing one), the command to see which `public-surface-bypass`
46
+ violations the proposed surfaces would retire.
47
+
48
+ Detection reads real edges between declared modules; it never guesses from
49
+ a project's framework or its `package.json` dependencies. A pair with no
50
+ directional evidence, or a set of modules whose real edges cycle instead of
51
+ order, is left out rather than forced into a proposal.
52
+
53
+ ## `surfaceProposals`
54
+
55
+ One entry per declared module with no public surface file present today and
56
+ at least one real external importer - complete in JSON; text shows the top
57
+ 5 modules and a "+N more" note past that. `candidates` ranks every file
58
+ other modules actually import from it, densest first, by distinct importer
59
+ count - complete in JSON, truncated to the top 3 per shown module in text.
60
+ `proposedSurface` is the smallest ranked prefix covering at least 80% of the
61
+ module's own real imports (`coveredImports` of `totalImports`,
62
+ `remainingImports` left over), offered as a `surface` value to paste into
63
+ that module's `declaredModules` entry. `choices` gives the same alternative
64
+ every surface-less module finding does: set the proposed `surface`, add a
65
+ barrel file naming a different real entry, or leave the module entirely
66
+ private and freeze its bypasses with `archstrict todo` - text prints these
67
+ three choices once for the whole section, then one module-specific `do:`
68
+ line per shown module (the `surface` edit); `choices` itself stays complete
69
+ per module in JSON.
70
+
71
+ ## Reading a proposal before pasting it
72
+
73
+ 1. Confirm the evidence against the tree itself - `evidence`'s own numbers
74
+ should match what a quick read of the real imports shows.
75
+ 2. Paste `configFragment` into `archstrict.config.ts`, then follow the
76
+ proposal's own `do:` line: [prove-rules.md](prove-rules.md)'s positive
77
+ control for a new `classify`/`edges` rule, or a plain `archstrict check`
78
+ for a `surface` addition.
79
+ 3. Only after the positive control fires under the expected rule id, treat
80
+ a clean `archstrict check` as real - not before.
@@ -0,0 +1,146 @@
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: `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`
30
+ - `todoModule`: the name-first module among the ones in the component
31
+
32
+ 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 break-the-cycle text 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 break-the-cycle text alone. `evidence` and the fingerprint never change.
33
+
34
+ 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.
35
+
36
+ ## 3. uncovered-module
37
+
38
+ 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).
39
+
40
+ - because: "a file matching no declared module is unchecked, not passing"
41
+ - `path`: the file itself
42
+ - 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`
43
+ - 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`
44
+
45
+ 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.
46
+
47
+ ## 4. empty-rule-set
48
+
49
+ 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).
50
+
51
+ **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.
52
+
53
+ - because: "a rule that checks nothing must not look like a pass"
54
+ - `path`: the config file, not a module
55
+ - `do` (one of four, depending on which case fired):
56
+ - zero `declaredModules` entries: `add at least one declaredModules entry in archstrict.config.ts`
57
+ - a `classify` glob matching no file: `remove this classify entry from archstrict.config.ts, or point its glob at real files`
58
+ - a `deprecated` entry whose actual edge count fell to 0: `remove the '<from> -> <to>' entry from deprecated in archstrict.config.ts`
59
+ - 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)`
60
+
61
+ ### exhaustive-allow-list
62
+
63
+ 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.
64
+
65
+ - 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.
66
+ - The source's own tag is left out of the universe: a target that shares it is always exempt.
67
+ - `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.
68
+ - Only `allow` lists are checked. A `deny` list may name a value that does not exist yet, to guard against a future edge.
69
+ - A rule with `evaluated: 0` is reported as `empty-rule-set` instead, never as both.
70
+ - 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.
71
+
72
+ - evidence: `allowDeny rule '<identifier>' allows every real target value with allow [...]`
73
+ - because: the rule's own `because`
74
+ - `path`: the config file
75
+ - 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`
76
+
77
+ ## 5. deprecated-edge-increased / deprecated-edge-decreased
78
+
79
+ 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.
80
+
81
+ - 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`
82
+ - suggestion (`deprecated-edge-decreased`) do: `update count to <actual> for <from> -> <to> in archstrict.config.ts`
83
+ - Does not suppress rule 1: a deprecated edge that also bypasses its target's surface is still a rule-1 violation.
84
+
85
+ ## 6. type-leak
86
+
87
+ 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).
88
+
89
+ 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.
90
+
91
+ 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.
92
+
93
+ 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.
94
+
95
+ - because: "a consumer needs a name for every type it receives from a public surface, not just the type doing the exposing"
96
+ - evidence: `'<InternalType>', declared in '<relative path>', is never exported by name from module '<module>' - referenced by '<Exported1>', '<Exported2>', ...`
97
+ - do: `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
98
+ - `todoModule`: the module owning the leaking surface (a leak is a self-violation, not a cross-module edge)
99
+
100
+ ## 7. tag-boundary / tag-order / point-rule (the constraint engine)
101
+
102
+ 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.
103
+
104
+ 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.
105
+
106
+ `<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.
107
+
108
+ 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.
109
+
110
+ **`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.
111
+
112
+ - because: whatever the `allowDeny` entry's own `because` gives (mandatory)
113
+ - evidence: `'<specifier>' (from '<source tag>') reaches '<violating tag>'`
114
+ - do: `remove this edge, or add '<value>' to '<source>'s allow list in archstrict.config.ts and record why`
115
+ - `todoModule`: the edge's own source module
116
+
117
+ **`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).
118
+
119
+ - because: whatever the `order` entry's own `because` gives (mandatory)
120
+ - evidence: `'<specifier>' reaches '<target layer>' from '<source layer>' (<namespace> sequence: <a> -> <b> -> ...)`
121
+ - 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`
122
+ - `todoModule`: the edge's own source module
123
+
124
+ **`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.
125
+
126
+ - because: whatever the `point` entry's own `because` gives (mandatory)
127
+ - evidence: `'<specifier>' matches a forbidden edge`
128
+ - 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>`.
129
+ - `todoModule`: the edge's own source module
130
+
131
+ ## must-be-empty
132
+
133
+ 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.
134
+
135
+ - because: whatever `mustBeEmpty`'s own entry gives (mandatory, same as every other root-level rule with a reason to record)
136
+ - `path`: the matching file itself; `line`/`column` are always `1`/`1` (no single line is "the" violation - the file's existence is)
137
+ - do: `move '<file>' out of '<glob>', or drop this mustBeEmpty entry in archstrict.config.ts if the restriction no longer applies`
138
+ - 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.
139
+
140
+ ## Not a rule of its own: stale-todo, clean-module-has-todo, and stale-cycle-exception
141
+
142
+ `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.
143
+
144
+ `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.
145
+
146
+ `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.