archstrict 0.1.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.
@@ -13,13 +13,13 @@ Every violation carries a rule id, `path:line:col`, the `evidence` (what was fou
13
13
 
14
14
  ## Workflow
15
15
 
16
- Run `archstrict agents` to install the fixed pre-edit guidance in `AGENTS.md`; use `--remove` to remove it. See [agent instructions](references/agents-verb.md).
16
+ Run `archstrict agents` to add the fixed pre-edit guidance to `AGENTS.md`; use `--remove` to remove it. That section is a few lines of project instructions, separate from this skill. See [agent instructions](references/agents-verb.md). The edit hooks run only in Claude Code; on any other host, `archstrict check` in CI is what catches a violation after an edit. The [README's Install section](https://github.com/meganemura/archstrict#install) says how each host gets the skill, the hooks, and the MCP server.
17
17
 
18
18
  Before creating a file, run `archstrict rules <path> [--json]` to inspect its module, tags, public surfaces, friend access, and placement violations; see [path rules](references/path-rules.md).
19
19
 
20
- 1. **Start a project**: `archstrict init [dir] [--json]` (default container `src/`) - if there's no `archstrict` command available in this project yet, see the [README's Install section](../../README.md#install) first. On a fresh project, declares one module per top-level directory that holds a `.ts`/`.tsx`/`.mts`/`.cts` file, and one single-file module per loose top-level file of one of those extensions (its own name is the file, and its own `surface` is that file) - both inside the opened container and at the project root. When `src/` is absent or holds none of those, `init` walks the project root alone instead. Every file the first `check` analyzes then belongs to exactly one declared module, by construction (see [docs/init-singleton-modules.md](../../docs/init-singleton-modules.md)). Writes `archstrict.types.ts` (the module-name union type) and, if absent, `archstrict.config.ts` (`schemaVersion: 1`) - it never overwrites a hand-edited config; a re-run only re-reads the existing config and regenerates `archstrict.types.ts` from its own `declaredModules` names. Declaring a genuinely new module (a directory `init` never saw) means hand-adding its own entry - the same trade-off Prisma's own `architecture.config.json` makes: a new package needs its own new config entry, not automatic discovery. `--json` prints one object (`configPath`, `typesPath`, `configWritten`, `opened`, `moduleNames`, `hiddenDirs`, `noiseDirs`, `testFileExcludes`, `uncovered`, `notes`, `do`), or `{ "error": "<message>", "do": "<command>" }` on failure, the same two-shape convention `check --json` and `todo --json` follow. Without a config, `archstrict recommend [dir] [--json]` previews this same walk in memory (no file written) and proposes boundaries from the resulting graph; with a config, it proposes from the declared modules instead. `patternProposals` ranks at most 5 detected patterns (layered order, app over library, a leaf/pure kernel, an external package confined to one area, test code kept out of production, public-entry-only, host/plugin inversion, feature isolation around a shared kernel) by how strongly the real edges support each, with the evidence in numbers, a pasteable `classify`/`edges` fragment, and how many violations adopting it would add today - `detected` on the result keeps the cut visible when more than 5 were found. `surfaceProposals` covers the rest: for every declared module with no public surface file present, a ranked list of the files other modules actually import from it, and the smallest ranked prefix covering at least 80% of those real imports, offered as a `surface` value to paste, alongside adding a barrel file or leaving the module private and freezing its bypasses. See [references/recommend.md](references/recommend.md) for both fields in full, and [references/patterns.md](references/patterns.md) for the pattern catalog a proposal's own `pattern` id names.
20
+ 1. **Start a project**: `archstrict init [dir] [--json]` (default container `src/`) - if there's no `archstrict` command available in this project yet, see the [README's Install section](https://github.com/meganemura/archstrict#install) first. On a fresh project, declares one module per top-level directory that holds a `.ts`/`.tsx`/`.mts`/`.cts` file, and one single-file module per loose top-level file of one of those extensions (its own name is the file, and its own `surface` is that file) - both inside the opened container and at the project root. When `src/` is absent or holds none of those, `init` walks the project root alone instead. Every file the first `check` analyzes then belongs to exactly one declared module, by construction (see [docs/init-singleton-modules.md](../../docs/init-singleton-modules.md)). Writes `archstrict.types.ts` (the module-name union type) and, if absent, `archstrict.config.ts` (`schemaVersion: 1`) - it never overwrites a hand-edited config; a re-run only re-reads the existing config and regenerates `archstrict.types.ts` from its own `declaredModules` names. Declaring a genuinely new module (a directory `init` never saw) means hand-adding its own entry - the same trade-off Prisma's own `architecture.config.json` makes: a new package needs its own new config entry, not automatic discovery. `--json` prints one object (`configPath`, `typesPath`, `configWritten`, `opened`, `moduleNames`, `hiddenDirs`, `noiseDirs`, `testFileExcludes`, `uncovered`, `notes`, `do`), or `{ "error": "<message>", "do": "<command>" }` on failure, the same two-shape convention `check --json` and `todo --json` follow. Without a config, `archstrict recommend [dir] [--json]` previews this same walk in memory (no file written) and proposes boundaries from the resulting graph; with a config, it proposes from the declared modules instead. `patternProposals` ranks at most 5 detected patterns (layered order, app over library, a leaf/pure kernel, an external package confined to one area, test code kept out of production, public-entry-only, host/plugin inversion, feature isolation around a shared kernel) by how strongly the real edges support each, with the evidence in numbers, a pasteable `classify`/`edges` fragment, and how many violations adopting it would add today - `detected` on the result keeps the cut visible when more than 5 were found. `surfaceProposals` covers the rest: for every declared module with no public surface file present, a ranked list of the files other modules actually import from it, and the smallest ranked prefix covering at least 80% of those real imports, offered as a `surface` value to paste, alongside adding a barrel file or leaving the module private and freezing its bypasses. See [references/recommend.md](references/recommend.md) for both fields in full, and [references/patterns.md](references/patterns.md) for the pattern catalog a proposal's own `pattern` id names. The map `init` writes is an inventory; read [The first map is an inventory](#the-first-map-is-an-inventory) before treating it as finished.
21
21
  2. **Give each module its own surface file** (`index.ts`/`index.tsx`/`index.mts`/`index.cts` by default, or whatever `surface` names) that re-exports what other modules may use. An import from outside the module that reaches any other file is `public-surface-bypass` ([references/rules.md](references/rules.md#1-public-surface-bypass)); a surface file that re-exports or otherwise exposes a type whose own shape reaches an internal declaration nothing exports by name is `type-leak` ([references/rules.md](references/rules.md#6-type-leak)).
22
- 3. **Check**: `archstrict check [file] [--json]`. With no file, analyzes the whole project. With a file, analysis still covers the whole project (resolving an edge needs every file), but the report is scoped to that file's own violations - what the PostToolUse hook uses after an edit. For a module surface, rule 6 builds from that surface's type closure, and `typeLeaks` counts only leaks reported at that file. The `todo` count is scoped the same way: with a file, it counts only the frozen violations reported at that file, not every frozen violation in the project (a frozen violation elsewhere is real, just not evaluated this run - see references/hook.md). Exit code 1 means at least one violation. `--json` prints a `CheckResult` on success, or `{ "error": "<message>", "do": "<command>" }` with exit 1 instead when the config itself is broken (a required field absent, a `deprecated` entry naming a module that doesn't exist, a named file that doesn't exist) - two shapes, not one; check for `error` before reading `violations`. Full config schema: [references/config.md](references/config.md). `check` analyzes every file under the project root, not just the container `init` opened (`scope` would narrow this but isn't wired to anything yet). `init` already seeds `exclude` with archstrict's own two files, two hidden-directory patterns, every common non-source directory name it found for real on disk (`test`, `example`, `spike`, and similar), and every colocated test-file naming convention it found for real on disk (`*.test.ts`, `*.spec.tsx`, `__tests__/`, and similar - see [docs/init-singleton-modules.md](../../docs/init-singleton-modules.md) for the full list); every other file it analyzed on that run instead became a `declaredModules` entry. A file added later that still matches no declared module and no `exclude` glob is `uncovered-module` - its own `do:` pastes the exact `declaredModules` entry to add (a lone file's entry always names itself as `surface`, so the module isn't left entirely private), or the `exclude` glob to add instead if it isn't module content. `archstrict rules <path>` prints the same entry for a path not yet checked, and a re-run of `init` on an existing config lists one such pair per uncovered file or directory too - all three share one grouping/naming function so the text never drifts. `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`, since a file matching no module has no module to freeze it into (see step 4 below). Text output prints every violation up to 20; past that it prints one summary line, then groups (by rule, then by the module owning the todo), each with a count, one full example, and a `do:` that reruns just that group - cut at a line-count cap, with the number of groups left out and `archstrict check --json` to see them all. `--rule <id>` and `--module <name>` (repeatable) narrow both the text and `--json` violations after analysis; the exit code reflects the filtered set once either is given, and an unknown id or name is an error listing the valid ones. `--frozen` includes todo-matched violations too, each marked `frozen: true`, still grouped and bounded and combinable with `--rule`/`--module`; the exit code never counts a frozen one, so a project already covered by `archstrict todo` still exits 0. Hotspot drill-downs (`archstrict hotspots`) point here instead of a module's todo JSON file. When most `public-surface-bypass` violations target modules with no surface file present at all, the summary adds a note naming that fact, with `do:` lines for freezing them (`archstrict todo`), naming each module's real surface, or adding surface files.
22
+ 3. **Check**: `archstrict check [file] [--json]`. With no file, analyzes the whole project. With a file, analysis still covers the whole project (resolving an edge needs every file), but the report is scoped to that file's own violations - what the PostToolUse hook uses after an edit. For a module surface, rule 6 builds from that surface's type closure, and `typeLeaks` counts only leaks reported at that file. The `todo` count is scoped the same way: with a file, it counts only the frozen violations reported at that file, not every frozen violation in the project (a frozen violation elsewhere is real, just not evaluated this run - see references/hook.md). Exit code 1 means at least one violation. `--json` prints a `CheckResult` on success, or `{ "error": "<message>", "do": "<command>" }` with exit 1 instead when the config itself is broken (a required field absent, a `deprecated` entry naming a module that doesn't exist, a named file that doesn't exist) - two shapes, not one; check for `error` before reading `violations`. Full config schema: [references/config.md](references/config.md). `check` analyzes every file under the project root, not just the container `init` opened (`scope` would narrow this but isn't wired to anything yet). `init` already seeds `exclude` with archstrict's own two files, two hidden-directory patterns, every common non-source directory name it found for real on disk (`test`, `example`, `spike`, and similar), and every colocated test-file naming convention it found for real on disk (`*.test.ts`, `*.spec.tsx`, `__tests__/`, and similar - see [docs/init-singleton-modules.md](../../docs/init-singleton-modules.md) for the full list); every other file it analyzed on that run instead became a `declaredModules` entry. A file added later that still matches no declared module and no `exclude` glob is `uncovered-module` - its own `do:` pastes the exact `declaredModules` entry to add (a lone file's entry always names itself as `surface`, so the module isn't left entirely private), or the `exclude` glob to add instead if it isn't module content. `archstrict rules <path>` prints the same entry for a path not yet checked, and a re-run of `init` on an existing config lists one such pair per uncovered file or directory too - all three share one grouping/naming function so the text never drifts. `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`, since a file matching no module has no module to freeze it into (see step 4 below). Text output prints every violation up to 20; past that it prints one summary line, then groups (by rule, then by the module owning the todo), each with a count, one full example, and a `do:` that reruns just that group - cut at a line-count cap, with the number of groups left out and `archstrict check --json` to see them all. `--rule <id>` and `--module <name>` (repeatable) narrow both the text and `--json` violations after analysis; the exit code reflects the filtered set once either is given, and an unknown id or name is an error listing the valid ones. `--frozen` includes todo-matched violations too, each marked `frozen: true`, still grouped and bounded and combinable with `--rule`/`--module`; the exit code never counts a frozen one, so a project already covered by `archstrict todo` still exits 0. Hotspot drill-downs (`archstrict hotspots`) point here instead of a module's todo JSON file. When most `public-surface-bypass` violations target modules with no surface file present at all, the summary adds a note naming that fact, with `do:` lines for freezing them (`archstrict todo`), naming each module's real surface, or adding surface files. While the config has no `edges` rule, a whole-project `check` also prints one `summary:` line (`nextSteps` in `--json`): the config so far freezes today's import graph, not a target architecture. Its `do:` lines name `archstrict recommend`, `archstrict hotspots`, and [references/rearchitect.md](references/rearchitect.md). `check <file>` leaves it out.
23
23
  Before making a source or config change, preview it with `archstrict simulate`; see [references/simulate.md](references/simulate.md). After adding or changing a config rule, prove that it can report the intended violation with the positive-control procedure in [references/prove-rules.md](references/prove-rules.md).
24
24
  4. **Freeze known violations**: `archstrict todo [--json]`. On a project's first run, freezes every current freezable violation (rule 1, rule 2, rule 6, and rule 7's constraint engine - the ones whose violation names the module it belongs to) into one project-root `archstrict.todo.json`, grouped by module name (each module's entries sorted by path, then rule, then a stable key, one entry per line, so two branches touching different modules diff cleanly). Every later run only prunes: an entry that no longer matches a current violation is dropped, and nothing is ever added again, even when a new violation appears. `--json` prints `{ firstRun, added, pruned }` (or `{ "error": "<message>", "do": "<command>" }` on a config error, the same two-shape convention `check --json` uses). `check` then suppresses a violation whose identity is already frozen (counted in the `todo` field, not the exit code), unless the module is `strict` (step 5 below) - a strict module's own violations are never suppressed, frozen or not. `check` also flags a todo entry that matches nothing as its own violation (`stale-todo`), reported at the todo file's own line (where to fix it), but still matched by `entryPath` (the entry's own stored file) against a `check <file>`/`check <dir>` scope - the moment an edit retires a frozen violation, that same scoped `check` surfaces the stale entry it just created. `clean-module-has-todo` (step 5 below) is config-level and only ever shows up on a plain, unscoped `check`. Rule 3's `uncovered-module` is never freezable this way - the file belongs to no module, so there is nowhere to freeze it; the only fix is `declaredModules` or `exclude`. The first run refuses outright (exit 1, no file written) while any `uncovered-module` violation exists, since declaring that file later would find it permanently past the one chance to freeze; a later, prune-only run is unaffected.
25
25
  "First run" is the file's own existence, not a separate marker - a project with zero freezable violations still gets the file after its first run, with an empty module map, so the ratchet's own state ("todo has run") stays visible on disk. Deleting `archstrict.todo.json` resets freezing for the WHOLE project (the next `todo` run reads as a first run again and re-freezes every current violation) - there is no per-module reset; removing one module's own entries by hand un-freezes only those, and `todo` will not add them back. A project adopted under the older per-module layout keeps passing `check` until it migrates, with a note pointing at `archstrict todo`; see [docs/todo-single-file-migration.md](../../docs/todo-single-file-migration.md).
@@ -28,6 +28,18 @@ Before creating a file, run `archstrict rules <path> [--json]` to inspect its mo
28
28
 
29
29
  After adoption is clean, use `archstrict hotspots [--since <git ref or date>] [--json]` to find high-change modules and boundary pairs that change together. Follow the [re-architecture workflow](references/rearchitect.md) to name a move, simulate it, and verify its numeric effect.
30
30
 
31
+ ## The first map is an inventory
32
+
33
+ `archstrict init` covers every analyzed file, one module per directory and one per loose file. That is coverage. A file-per-module map makes every file public as itself, so imports between files are checked, and it still names no growth seam. One module over `src/**` (or one directory that holds almost every file) makes `archstrict hotspots` report a single score, and freezing its public-surface bypasses records that one bucket. `init` prints both shapes when it sees them. `archstrict recommend` repeats them as `mapNotes` (`mega-module`, `file-per-module`). `check` and `todo` add a note when most public-surface bypasses target that one large module, and the next command is to split it before freezing more.
34
+
35
+ Name seams that change together, then add an `edges` rule so import direction is checked. `archstrict hotspots`, once the project has history, shows which modules and pairs move together. `archstrict recommend` proposes `edges` from the real graph (`patternProposals`) and a surface per surface-less module (`surfaceProposals`). Prove a new rule can fire before trusting a clean check: [references/prove-rules.md](references/prove-rules.md).
36
+
37
+ A flat directory can name a seam without a directory move. `glob` accepts an array of paths that share one directory: `{ name: "plan", glob: ["src/build/plan.ts", "src/build/graph.ts"], surface: "plan.ts" }`. `plan.ts` is public; `graph.ts` stays private except to a `friends` entry. Paths in two directories are a config error; use one entry per directory. Brace syntax (`{a,b}`) is rejected. See [references/config.md](references/config.md) and [references/patterns.md](references/patterns.md#flat-directory-seam).
38
+
39
+ Two cuts that kept a real graph readable: the CLI and the MCP server sit above the verbs they call, because a verb that imports the CLI cycles. A helper that the module graph calls (a type-leak walk, a shared report type) sits with the graph, because putting it in the rules package makes the graph import the rules. A pair that keeps changing together after that cut can be the graph's own API. See [references/rearchitect.md](references/rearchitect.md#cuts-that-kept-the-signal).
40
+
41
+ Install the skill once for the user (`gh skill install meganemura/archstrict archstrict --agent cursor --scope user`). Copying `skills/archstrict` into every repository duplicates it. `archstrict agents` is the separate, per-project `AGENTS.md` section; it is a few lines of commands, and it is not a second copy of this skill.
42
+
31
43
  ## Reading a violation
32
44
 
33
45
  `path` is always an absolute path (`/path/to/project/src/app/importer.ts` here, abbreviated below):
@@ -25,12 +25,21 @@ Fields:
25
25
  - **`schemaVersion`** (optional, current value `1`) - the schema this file was written for. `init` writes `1`. Omit it and the loader treats the file as schema 1 (a config from before the field existed is still current). Any other value is a thrown config error; `do` says to set it back to `1`. The name is camelCase, the same as every other field here.
26
26
  - **`scope`** (optional) - typed on `Config`, but not yet read by any rule or verb; declaring it has no effect on what `classify`, `declaredModules`, or the constraint engine see. Wiring it in (an analysis boundary narrower than the whole project) is real, not-yet-done work, not a decision that was made and reversed.
27
27
  - **`exclude`** (optional) - glob patterns kept out of analysis entirely: not a module member, not an edge source, not an edge target, not counted as `outsideFiles` either. `init` always writes its own two files (`archstrict.config.ts`, `archstrict.types.ts`) and two fixed hidden-directory patterns (`.*/**`, `**/.*/**` - matching `tsc`'s own default `include`, which already skips hidden paths); it also adds one entry per common non-source directory name it found for real on disk (`test`, `example`, `spike`, and similar), and one entry per colocated test-file naming convention it found for real on disk (`*.test.ts`, `*.spec.tsx`, `__tests__/`, and similar - see [docs/init-singleton-modules.md](../../../docs/init-singleton-modules.md) for the full list). A file `init`'s own walk covers is declared as its own module instead of excluded - a loose file added later, matching neither, is `uncovered-module` until a project adds its own `exclude` entry or `declaredModules` entry for it.
28
+ - **Gitignored paths** are kept out of analysis the same way, with no config entry. Every verb that walks the project (`init`, `check`, `todo`, `simulate`, `hotspots`, `recommend`) reads the project's `.gitignore` files (the root one and each nested one), each `.gitignore` above the project root up to the repository root, and the repository's `info/exclude`, with git's own pattern rules. archstrict parses these files itself and never runs git, so a copy of a checkout with no `.git` directory still skips what its own `.gitignore` files name. The user's global `core.excludesFile` is not read, so two machines analyze the same files. A gitignored file stays resolvable: a checked-in file can still import gitignored generated output. To analyze a gitignored path anyway, declare a module whose glob starts inside it (for example `{ name: "api", glob: "generated/api/**" }` when `generated/` is gitignored): every file under that module's base directory is analyzed. `exclude` still wins over that declaration.
28
29
  - **`surface`** (optional, default `["index.ts", "index.tsx", "index.mts", "index.cts"]`, one entry per analyzed source extension) - the public-surface file name every module is checked against. Not fixed by the tool: a project names its own. A `declaredModules` entry's own `surface` can override this per module, and can itself be a glob (a module's public surface can be more than one file) - or an array of them, when a package's own `exports` map names several real, differently-shaped entry points at once (e.g. `surface: ["index.ts", "http.ts", "observable/index.ts"]`); `surfaceFiles` is the union of every glob's own matches, and every one is equally, unconditionally public (unlike `friends`, whose whole point is a narrower, named-consumer exception - reach for `surface` as an array first when a package's own real entry points are meant for every importer equally). A `.d.ts` file is excluded from analysis by default (most are a third-party ambient declaration or a generated twin of a real `.ts` file, not module content) - the one exception is a `.d.ts` a `declaredModules` entry's own `surface` explicitly names, recognizing a real convention (a webpack-built package publishing `"types": "./types.d.ts"` with no `index.ts` at all).
29
30
  When a module is a real npm package, check its own `package.json` for an `"exports"` map before setting `surface` - `"main"` alone only names the package's first, default entry point. A package can publish several real, sanctioned entry points at once (e.g. `"."`, `"./testing"`, `"./internal"`), each its own key in `exports`; `surface` should be a glob matching every one of them, not just the file `main` resolves to (measured directly: authoring a config against a real monorepo, overriding `surface` per `main` correctly handled seven packages that published from a non-default path, but missed one package whose `exports` map named six real entry points while `main` alone showed only the first).
30
31
  - **`declaredModules`** (required) - `{ name, glob, surface?, friends? }[]`, the source of truth for module boundaries. Replaces v0's index.ts-presence discovery (measured wrong: a barrel `index.ts` is not evidence of an enforced boundary, in NestJS's or Drizzle's own real code). An entry's own `friends` (optional, `{ file, from, because }[]`) is rule 1's "friend" exception: `file` (relative to this module, may itself be a glob) is public to exactly the importers `from` (a project-relative glob) matches, private to everyone else - unlike `surface`, which is public to every importer equally. Narrower than `surface`: use it for one specific internal file meant for one specific, named group of consumers, not for a package's own multiple real public entry points (that's `surface` as a glob, see above).
31
32
 
32
33
  A glob may name a single file (`src/index.ts`). That file is the module. `surface` and `friends` still resolve relative to a directory — the file's parent — so `{ glob: "src/index.ts", surface: "index.ts" }` means the file itself.
33
34
 
35
+ `glob` may also be an array of paths that share one directory. That is how a flat directory names a seam without moving files:
36
+
37
+ ```ts
38
+ { name: "plan", glob: ["src/build/plan.ts", "src/build/graph.ts"], surface: "plan.ts" }
39
+ ```
40
+
41
+ `plan.ts` is public to every importer. `graph.ts` is private, unless a `friends` entry names who may import it. Surface and friends resolve against the shared directory (`src/build` here). The type-leak boundary is each listed file, so a sibling file in the same directory stays outside this module. A one-element array is the same module as the string. An empty array, or paths whose directories differ (`src/a/a.ts` together with `src/b/b.ts`), is a config error. A module that spans directories is still one `declaredModules` entry per directory.
42
+
34
43
  An entry's own `surface` is optional. `init` leaves it out entirely on every directory entry it writes - a per-entry `surface` always wins outright, so setting one would turn off the `exports` derivation below for that module. Which directories are declared as modules stays a decision, hand-authored here - but once that decision is made, a declared module's own surface, when a real `package.json` sits at its own root, is inventory: derived at graph-build time from that package's real `exports` map, not hand-transcribed. Every real entry point named there is resolved back to its own real source file - directly, when the map carries a source-pointing condition (a project-specific key ending in `-source`, e.g. `@acme/pkg-source`); otherwise by stripping a `dist/`-style build-output prefix and swapping the built extension for a real source one, then confirming the guess is a real, existing file. If even one entry can't be confidently resolved this way, or there's no `exports` map at all, the whole module falls back to the project's own top-level `surface` default instead - never a partial or guessed-wrong array. Setting `surface` by hand on an entry always wins outright, exactly as before; this only fills the gap when it's absent.
35
44
  - **`classify`** (optional) - `{ glob, tags }[]`, glob -> tags, most-specific-glob-wins (longest literal prefix, then fewest wildcards; a tie between two equally-specific entries naming different tags for the same file is a config error). Independent of `declaredModules` - tags classify any file in scope, whether or not it belongs to a declared module.
36
45
  - **`classifyByDirectoryName`** (optional) - `{ tagNamespace, names }`, ambient tagging by directory-name segment (VS Code's `code-layering.ts` convention): the nearest path segment matching one of `names`, walking from the file outward, becomes `${tagNamespace}:${name}`. Independent of `classify` - a file can carry tags from both mechanisms at once; their results union. Ambient matching is name-only, blind to which package or module that name actually belongs to - a name that recurs elsewhere in the tree for an unrelated reason (a test suite's own subdirectory named after the package it happens to test, not that package itself) gets mistagged the same way (measured directly: 96 real, non-injected false violations from exactly this in one config-authoring pass). Use `classify` with an explicit glob prefix instead when a name isn't unique to one package.
@@ -47,7 +56,7 @@ A `classify` glob matching zero real files, or zero `declaredModules` entries at
47
56
 
48
57
  Every glob-bearing field on this page - `exclude`, `declaredModules[].glob`, `declaredModules[].surface`, `declaredModules[].friends[].file`/`.from`, `classify[].glob`, `mustBeEmpty[].glob`, `edges.allowDeny[].exceptions[].from`/`.to`, and `edges.point[].from`/`.to` when written as a string - supports exactly two wildcards: `*` (any characters within one path segment - never crosses a `/`) and `**` (any depth, including zero segments). Every other character is a literal, matched exactly - never the shell/minimatch meaning it looks like it should have. Brace expansion (`{a,b}`), extglob (`+(a|b)`, `@(...)`, `!(...)`, `?(...)`), a bare `?` (one character), and bracket sets (`[...]`) all match nothing when written into one of these globs. Config loading rejects a glob containing `{`, `}`, `(`, `)`, `[`, `]`, `?`, or `!` up front, naming the field and the glob, rather than letting it silently match zero files - the failure mode otherwise is every file the glob was meant to cover staying `uncovered-module`, with nothing pointing back at the glob as the cause.
49
58
 
50
- A module spanning several top-level directories needs one `declaredModules` entry per directory today; there is no multi-root glob or brace-expansion shorthand for "these directories are one module."
59
+ A module spanning several directories needs one `declaredModules` entry per directory. An array of globs is the allowlist for several files in one directory, described above. Brace expansion (`{a,b}`) is not that allowlist: a glob containing `{` is rejected.
51
60
 
52
61
  ## `edges`'s own shape
53
62
 
@@ -37,7 +37,7 @@ On a TypeScript source file, this hook shells out to `node_modules/.bin/archstri
37
37
  Every one of these is silent by design, not a failure:
38
38
 
39
39
  - The edited file isn't `.ts`, or the tool wasn't an Edit/Write/MultiEdit.
40
- - The project has no `node_modules/.bin/archstrict` at all - most edits happen in files or projects that never adopted this tool. `archstrict init` does not produce this file (it only writes `archstrict.config.ts`/`archstrict.types.ts`); see the [README](../../../README.md#install) for how to actually install the package into that project.
40
+ - The project has no `node_modules/.bin/archstrict` at all - most edits happen in files or projects that never adopted this tool. `archstrict init` does not produce this file (it only writes `archstrict.config.ts`/`archstrict.types.ts`); see the [README](https://github.com/meganemura/archstrict#install) for how to actually install the package into that project.
41
41
  - `check <file>` found no violation in the edited file. Analysis still covers the whole project (resolving an edge needs every file), but the report is scoped to the one file that changed.
42
42
 
43
43
  For a module surface, `check <file>` builds rule 6 from that surface's type closure, and `typeLeaks` counts only leaks reported at that file.
@@ -734,6 +734,38 @@ shared tag value covers every area at once. Give each area a genuinely
734
734
  different tag only when the root's own rule must distinguish between
735
735
  them.
736
736
 
737
+ ## Flat directory seam
738
+
739
+ **Recognize it.** A directory that stays flat because a move is off the
740
+ table: `src/build/plan.ts`, `src/build/graph.ts`, and `src/build/emit.ts`
741
+ sit side by side, and two of them change together while the third does
742
+ not. One module per file makes each file public as itself. One module
743
+ over `src/build/**` makes every file in the directory private-or-public
744
+ together and hides the seam.
745
+
746
+ **Config.** `glob` is an array of paths in that one directory. `surface`
747
+ names the file other modules may import. `friends` names a file that one
748
+ caller may still reach.
749
+
750
+ ```ts
751
+ declaredModules: [
752
+ {
753
+ name: "plan",
754
+ glob: ["src/build/plan.ts", "src/build/graph.ts"],
755
+ surface: "plan.ts",
756
+ friends: [
757
+ { file: "graph.ts", from: "src/cli/main.ts", because: "the CLI reads the plan graph while it is built" },
758
+ ],
759
+ },
760
+ { name: "emit.ts", glob: "src/build/emit.ts", surface: "emit.ts" },
761
+ ],
762
+ ```
763
+
764
+ `plan.ts` is public. `graph.ts` is public only to `src/cli/main.ts`.
765
+ `emit.ts` is its own module. The type-leak boundary of `plan` is those
766
+ two files, so a type declared in `emit.ts` is outside it. Paths in two
767
+ directories are a config error. See [config.md](config.md).
768
+
737
769
  ## Friend list
738
770
 
739
771
  No category for this in the tool-search survey; 1 of 48 repositories in the
@@ -29,7 +29,38 @@ Simulating the move first shows no new cycle. The move applies in one step: crea
29
29
 
30
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
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
+
32
61
  ## Pitfalls
33
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.
34
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.
35
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.
@@ -7,6 +7,22 @@ Read [patterns.md](patterns.md) first for what each detected pattern's own
7
7
  config looks like, and [config.md](config.md) for `surface`'s exact
8
8
  semantics.
9
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
+
10
26
  ## `patternProposals`
11
27
 
12
28
  At most 5 proposals, ranked by evidence - a project with more detectable
@@ -62,11 +78,13 @@ module's own real imports (`coveredImports` of `totalImports`,
62
78
  `remainingImports` left over), offered as a `surface` value to paste into
63
79
  that module's `declaredModules` entry. `choices` gives the same alternative
64
80
  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.
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.
70
88
 
71
89
  ## Reading a proposal before pasting it
72
90
 
@@ -26,10 +26,11 @@ A module-level cycle: two or more modules import each other, directly or through
26
26
 
27
27
  - because: "modules that import each other cannot be reasoned about, tested, or replaced independently"
28
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`
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`
30
31
  - `todoModule`: the name-first module among the ones in the component
31
32
 
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
+ 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.
33
34
 
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.
35
36
 
@@ -94,7 +95,9 @@ A module can have more than one surface file. A type declared in one surface fil
94
95
 
95
96
  - because: "a consumer needs a name for every type it receives from a public surface, not just the type doing the exposing"
96
97
  - 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
+ - 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
98
101
  - `todoModule`: the module owning the leaking surface (a leak is a self-violation, not a cross-module edge)
99
102
 
100
103
  ## 7. tag-boundary / tag-order / point-rule (the constraint engine)