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.
package/AGENTS.md CHANGED
@@ -21,7 +21,7 @@ A violation report always carries a rule id, `path:line:col`, the evidence, the
21
21
 
22
22
  ## Layout
23
23
 
24
- - `src/` is the library and CLI.
24
+ - `src/` is the library and CLI. The root `archstrict.config.ts` checks that tree: `core` is the analysis engine, `rules` and `verbs` are where a new rule or verb lands, and `cli`, `mcp`, and `check-options` sit above them. `archstrict.todo.json` is the ratchet. `skills/archstrict/` is the product skill shipped to consumers, not a second config.
25
25
  - `test/` is the Vitest suite; `features/` is the nukadoko (Gherkin) dogfood scenario.
26
26
  - `.agents/` is the canonical Claude Code plugin: the manifest, the PreToolUse and PostToolUse hooks, and the MCP server. `.claude-plugin/plugin.json` symlinks to `.agents/plugin.json`, and repo-root `hooks/` and `mcp/` symlink to `.agents/hooks` and `.agents/mcp`. `${CLAUDE_PLUGIN_ROOT}` is the directory that contains `.claude-plugin/`, so those plugin-root paths still resolve. `.claude-plugin/` stays a real directory holding only the manifest symlink: Claude loads `hooks/` from the plugin root, and a directory symlink onto `.agents/` would place `hooks/` and `mcp/` inside `.claude-plugin/`.
27
27
  - `skills/archstrict/` (and root `llms.txt`) is the agent-facing skill (`SKILL.md` plus `references/`).
@@ -48,6 +48,7 @@ If you want to cite an internal document, write its substance in place instead.
48
48
  For working on archstrict itself:
49
49
 
50
50
  - `npm run build` — compile `src/` to `dist/`. The CLI's own tests spawn the built `dist/cli.js`, so run this before `npm test` if `dist/` is missing or stale.
51
+ - `node dist/cli.js check` — checks this repository against the root `archstrict.config.ts`. `node dist/cli.js todo` prunes `archstrict.todo.json` after the first run and does not add debt again.
51
52
  - `npm run typecheck` — `tsc --noEmit` over the whole project.
52
53
  - `npm test` — the Vitest suite.
53
54
  - `npm run dogfood:nukadoko` — a nukadoko (Gherkin) scenario that runs the built CLI's full init/check/todo/edit/check round trip against nukadoko's own published `src/` (a real, unrelated codebase with no public-surface convention), copied into a disposable scratch directory. Never modifies the real nukadoko package or a checkout of it.
@@ -56,7 +57,7 @@ For working on archstrict itself:
56
57
 
57
58
  The CLI itself:
58
59
 
59
- - `archstrict init [dir] [--json]` — on a fresh project, declare one module per top-level directory holding TypeScript source (`.ts`, `.tsx`, `.mts`, `.cts`; default container `src/`, or the project root when it's absent or holds none) and one single-file module per loose top-level source file, so the first `check` covers every analyzed file by construction; write `archstrict.types.ts` (the module-name union type). Re-run any time; it never touches an existing `archstrict.config.ts`, only regenerates `archstrict.types.ts` from its own `declaredModules` names. `--json` prints one object (`configPath`, `typesPath`, `configWritten`, `opened`, `moduleNames`, `hiddenDirs`, `noiseDirs`, `testFileExcludes`, `uncovered`, `notes`, `do`), or `{ "error": "<message>", "do": "<command>" }` on failure, the same convention `check` and `todo` follow.
60
+ - `archstrict init [dir] [--json]` — on a fresh project, declare one module per top-level directory holding TypeScript source (`.ts`, `.tsx`, `.mts`, `.cts`; default container `src/`, or the project root when it's absent or holds none) and one single-file module per loose top-level source file, so the first `check` covers every analyzed file by construction. That map is an inventory: group files that change together (`glob` may be an array of paths in one directory), split a directory that holds almost every file, then add `edges`. `archstrict recommend` names a mega-module and a file-per-module inventory in `mapNotes`. Write `archstrict.types.ts` (the module-name union type). Re-run any time; it never touches an existing `archstrict.config.ts`, only regenerates `archstrict.types.ts` from its own `declaredModules` names. `--json` prints one object (`configPath`, `typesPath`, `configWritten`, `opened`, `moduleNames`, `hiddenDirs`, `noiseDirs`, `testFileExcludes`, `uncovered`, `notes`, `do`), or `{ "error": "<message>", "do": "<command>" }` on failure, the same convention `check` and `todo` follow.
60
61
  - `archstrict check [file] [--json] [--rule <id>] [--module <name>] [--frozen]` — analyze the whole project and report violations. With a file argument, analysis still covers the whole project (resolving an edge needs it), but the report is scoped to that file's own violations. Text output prints every violation up to 20; past that it prints grouped counts (by rule, then by the module owning the todo) with one full example per group and a `do:` that reruns just that group, cut at a line-count cap with a count of the groups left out. `--rule`/`--module` are repeatable and filter both the text and `--json` output after analysis; the exit code reflects the filtered set once either is given. An unknown rule id or module name is an error listing the valid ones. `--frozen` includes todo-matched violations too, each marked `frozen: true` and still combinable with `--rule`/`--module`; the exit code ignores a frozen violation, so a project already covered by `archstrict todo` still exits 0. When most public-surface-bypass violations target modules with no surface file at all, the summary says so and offers freezing, naming a surface, or adding surface files as next steps.
61
62
  - `archstrict todo [--json]` — on a project's first run, freeze every current freezable violation into one project-root `archstrict.todo.json`, grouped by module name; on every later run, only prune entries that no longer match a current violation. Never adds after the first run. That file's own existence is the "first run happened" signal, replacing an earlier marker file; a project with no debt after its first run still gets the file, with an empty module map, so the ratchet stays visible. The first run refuses instead (exit 1, no file written) while any `uncovered-module` violation exists, since such a file can never be frozen and a later declared-and-covered version of it would then find freezing already closed forever; a later, prune-only run is unaffected. `--json` prints `{ firstRun, added, pruned }`, or `{ "error": "<message>" }` on a config error, the same convention `check` follows.
62
63
 
@@ -67,3 +68,14 @@ The CLI itself:
67
68
  ## Claude Code plugin
68
69
 
69
70
  This repository is itself a Claude Code plugin (`.claude-plugin/plugin.json`, a symlink to `.agents/plugin.json`). It ships two hooks around every Edit/Write/MultiEdit, both shelling out to the edited project's own `node_modules/.bin/archstrict`, never to this repository's own build. Its `PreToolUse` hook (`.agents/hooks/pre-tool-use.mjs`, reached as `hooks/pre-tool-use.mjs`) runs before the write happens: it builds the file text the tool call would produce and previews it through that project's `archstrict simulate --json`, returning any added violation into the agent's own context so it can change course before the write lands, or denying the tool call outright when `ARCHSTRICT_PRETOOLUSE=deny` is set. Its `PostToolUse` hook (`.agents/hooks/post-tool-use.mjs`, reached as `hooks/post-tool-use.mjs`) runs the edited project's own installed `archstrict check <file>` right after the write and returns any violation into the agent's own context - the same moment a human editor's red squiggly would appear. Both say nothing when the edited project has no `archstrict` installed at all or the edited file has no violation; see [the hook reference](skills/archstrict/references/hook.md) for the full set of silent cases. The MCP server is `.agents/mcp/server.mjs`, reached as `${CLAUDE_PLUGIN_ROOT}/mcp/server.mjs`. `npm pack` ships `.agents/` and drops the symlinks, so an installed package's hooks are `node_modules/archstrict/.agents/hooks/pre-tool-use.mjs` and `post-tool-use.mjs`.
71
+
72
+
73
+ <!-- ARCHSTRICT_START -->
74
+ ## archstrict
75
+
76
+ In projects with an `archstrict.config.ts` (module-boundary/architecture linting), run `archstrict rules <path>` BEFORE creating a file or adding an import - it reports the module, tags, and constraints that would govern that path, even before it exists. Run `archstrict check` after editing to confirm.
77
+
78
+ The full rule reference (every rule's evidence/because/do shape, the config schema, the pre-edit query) is at `node_modules/archstrict/skills/archstrict/SKILL.md` when installed via npm - read it before configuring `archstrict.config.ts`, or when a violation's `do:` text alone isn't enough.
79
+
80
+ If there is no `archstrict.config.ts`, skip archstrict entirely - it may not be installed here.
81
+ <!-- ARCHSTRICT_END -->
package/CHANGELOG.md CHANGED
@@ -3,6 +3,45 @@
3
3
  The format follows Keep a Changelog, and the versions follow SemVer. Before 1.0, a minor version
4
4
  may change commands, flags, config, or output shape. The version entry will describe each change.
5
5
 
6
+ ## 0.2.0 (2026-10-01)
7
+
8
+ ### Added
9
+
10
+ - This repository includes `.claude-plugin/marketplace.json`. Claude Code users can install the plugin with `/plugin marketplace add meganemura/archstrict` and `/plugin install archstrict@archstrict`.
11
+ - `declaredModules[].glob` accepts an array of paths that share one directory, so a flat directory can name a multi-file seam without a directory move. Surface and friends resolve against that directory. The type-leak boundary is each listed file. Paths in two directories, and an empty array, are config errors.
12
+ - `archstrict init` prints that the generated map is an inventory. A container of only files is told to group seams with a glob array. A directory that holds at least four fifths of the files (and at least eight) is named so it can be split before `archstrict todo`.
13
+ - `archstrict recommend` adds `mapNotes` (`mega-module`, `file-per-module`). A surface proposal for the mega-module says to split it before freezing its bypasses.
14
+ - `archstrict check` and `archstrict todo` name the case where one module holds most analyzed files and most public-surface bypasses, including bypasses a todo file already suppresses. The next command is to split that module before freezing more. `check <file>` leaves the note out.
15
+ - While the config has no `edges` rule, a whole-project `check` prints one `summary:` line: the
16
+ config so far freezes today's import graph, not a target architecture. Its `do:` lines name
17
+ `archstrict recommend`, `archstrict hotspots`, and the rearchitect reference. `--json` carries
18
+ the same text as `nextSteps`. `check <file>` leaves it out.
19
+ - A cycle between two modules now names the imports on each side and two moves in its `do:`:
20
+ extract the shared part into a leaf module both import, or pass the dependency in from the side
21
+ that owns it, and run `archstrict simulate` on the planned change first. A lopsided pair still
22
+ names its minority imports first. A cycle of three or more modules keeps the earlier text.
23
+ - A `type-leak` `do:` now tells two cases apart. A type this module owns needs only a name on this
24
+ surface. A type owned by another module with no surface needs a surface on that module, or the
25
+ exposing export must leave this surface.
26
+
27
+ ### Changed
28
+
29
+ - Every verb that walks the project (`init`, `check`, `todo`, `simulate`, `hotspots`, `recommend`)
30
+ now skips gitignored paths. archstrict reads the root and nested `.gitignore` files, the ones
31
+ above the project root, and the repository's `info/exclude` with git's own pattern rules, and
32
+ never runs git. A local scratch directory such as `tmp/` no longer floods `check` with
33
+ `uncovered-module` violations. A gitignored file stays resolvable as an import target. A config
34
+ that already declares a module inside a gitignored directory keeps analyzing that module; remove
35
+ the declaration to drop it.
36
+
37
+ ### Documentation
38
+
39
+ - The skill, recommend reference, and re-architecture notes tell an adopter to treat `init` as an inventory, to split a mega-module before freezing it, to group a flat directory with a `glob` array, to install the skill once per user, and where a CLI and a graph helper sit.
40
+ - The README (and its Japanese twin) has one Install section per host. Claude Code users add the
41
+ repository marketplace, then install `archstrict@archstrict` for the skill, edit hooks, and MCP
42
+ server. Other agents install the skill with `gh skill install`, add the `AGENTS.md` section with
43
+ `archstrict agents`, and run `archstrict check` in CI, since the edit hooks are Claude Code only.
44
+
6
45
  ## 0.1.0 (2026-09-29)
7
46
 
8
47
  ### Added
package/README.ja.md CHANGED
@@ -4,24 +4,104 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/archstrict?logo=npm)](https://www.npmjs.com/package/archstrict)
6
6
 
7
- arch は architecture の略であり、tsc でも eslint でも型チェッカーでもない。module 境界を検査する道具である。
8
- archetype(アーキタイプ)の略ではない。
7
+ archstrict は TypeScript の module 境界を検査する。ArchUnit(Java)や archspec(Ruby)と同じ考え方に立つ。config で各 module を宣言する。module は 1 個の directory であり、glob が 1 個の file を指すときはその file である。module は codebase の他の部分に対して public-surface file を 1 個だけ見せ、その file が export しないものは private である。
9
8
 
10
- TypeScript の module 境界検査であり、ArchUnit(Java)や archspec(Ruby)と同じ考え方に立つ。module は config で明示的に宣言した 1 個の directory であり、他の module に対しては 1 個の public-surface file だけを見せる。その file が export しないものはすべて private である。
11
-
12
- repository の形、rule、command は [AGENTS.md](AGENTS.md) を、実際の workflow は [skills/archstrict/SKILL.md](skills/archstrict/SKILL.md) を参照する。
9
+ tsc と型チェッカーが見るのは型であり、ESLint が見るのは style である。archstrict が見るのは境界である。その public surface を越えて module の内部へ届く import は violation になる。
13
10
 
14
11
  ## 導入
15
12
 
13
+ どの host でも、まずプロジェクトに npm package を入れる。
14
+
15
+ ```sh
16
+ npm install -D archstrict
17
+ ```
18
+
19
+ Node.js 22 以降が要る。
20
+
21
+ これで、プロジェクトの `node_modules/.bin/archstrict` に実体の `archstrict` binary が置かれる。編集 hook と CI はこの binary を実行し、MCP server は同じ導入済みの package を読み込む。
22
+
23
+ ### 各部品の役割
24
+
25
+ - **CLI**(`npm install -D archstrict`)は `init`、`check`、`todo` などの verb を実行する。violation を見つけるのはこの部品だけである。
26
+ - **skill**(`skills/archstrict/`)は、violation の報告の読み方と config の変え方を agent に教える。host は `node_modules/` からではなく、host 自身の skill directory から読み込む。
27
+ - **AGENTS.md の節**(`archstrict agents`)は、`AGENTS.md` を読むすべての agent に、file を作る前や import を足す前に `archstrict rules <path>` を実行し、編集の後に `archstrict check` を実行するよう指示する。数行のプロジェクト向け指示であり、skill ではない。
28
+ - **編集 hook**(Claude Code 専用)は編集ごとに動く。PreToolUse hook は変更を事前に確かめ、PostToolUse hook は `archstrict check <file>` を実行して violation を agent の文脈に返す。[hook.md](skills/archstrict/references/hook.md) を参照。
29
+ - **MCP server**(Claude Code の plugin)は、`check`、`rules`、`search`、`simulate` を tool として agent に渡す。
30
+ - **CI** は、どの host が加えた変更にも `archstrict check` を実行する。
31
+
32
+ ### Claude Code
33
+
34
+ このリポジトリの marketplace から plugin を入れる。plugin は skill、2 個の編集 hook、MCP server を持つ。Claude Code の中で次を実行する。
35
+
36
+ ```text
37
+ /plugin marketplace add meganemura/archstrict
38
+ /plugin install archstrict@archstrict
39
+ ```
40
+
41
+ hook はプロジェクト自身の `node_modules/.bin/archstrict` を実行するので、上の npm install も要る。`node_modules/archstrict/` は plugin として読み込まれない。npm は plugin root に要る symlink(`.claude-plugin/plugin.json`、`hooks/`、`mcp/`)を含めないためである。plugin の file 自体は `node_modules/archstrict/.agents/` に入っている。リリース前の checkout を試すときは、`claude --plugin-dir <clone のパス>` で 1 回の session だけ読み込む。
42
+
43
+ ### その他の agent(Cursor、Codex、cloud agent)
44
+
45
+ 編集 hook は Claude Code 専用である。それ以外の agent では、次の 3 つを使う。
46
+
47
+ 1. GitHub CLI で、公開リポジトリから skill を入れる。`cursor` は、`gh skill install --help` にある自分の agent の値に置き換える。
48
+
49
+ ```sh
50
+ gh skill install meganemura/archstrict archstrict --agent cursor
51
+ ```
52
+
53
+ ユーザーにつき 1 回、`--scope user` で入れる。skill をリポジトリごとにコピーしない。既定の scope はプロジェクトである。Cursor、Codex など複数の agent が `.agents/skills/archstrict/` を共有する。次の `archstrict agents` は、プロジェクトごとの `AGENTS.md` の節である。コマンドを数行書くもので、skill の複製ではない。
54
+
55
+ 2. AGENTS.md の節を足す。
56
+
57
+ ```sh
58
+ npx archstrict agents
59
+ ```
60
+
61
+ 3. CI で `archstrict check` を実行する。編集 hook が無いので、agent の session で生じた violation は CI で捕まえる。
62
+
63
+ ```yaml
64
+ - run: npm ci
65
+ - run: npx archstrict check
66
+ ```
67
+
68
+ ## クイックスタート
69
+
16
70
  ```sh
17
- npm install --save-dev archstrict
71
+ npm install -D archstrict
72
+ npx archstrict init
73
+ npx archstrict check
18
74
  ```
19
75
 
20
- これで、プロジェクトの `node_modules/.bin/archstrict` に実体の `archstrict` binary が置かれる。これは PreToolUse と PostToolUse の 2 個の hook([hook.md](skills/archstrict/references/hook.md) を参照)が、編集の前後で変更を確認するために探す path そのものである。この導入では、agent skill(`skills/archstrict/SKILL.md` と `skills/archstrict/references/`)、`llms.txt`、`.agents/`(plugin manifest、2 個の hook、MCP server)も `node_modules/archstrict/` に入る。npm は checkout 側の symlink(`.claude-plugin/plugin.json`、`hooks/`、`mcp/`)を含めないため、導入後の hook は `node_modules/archstrict/.agents/hooks/pre-tool-use.mjs` と `post-tool-use.mjs` になり、MCP server は `node_modules/archstrict/.agents/mcp/server.mjs` になる。git の checkout では、それらの symlink を通じて Claude Code の plugin として読み込まれる。
76
+ `init` は、`archstrict.config.ts` が無いときその file を書く。あわせて、module 名の union type である `archstrict.types.ts` を書く。初回は、TypeScript source(`.ts`、`.tsx`、`.mts`、`.cts`)を持つ top-level directory ごとに 1 module を宣言し、subdirectory には入っていない top-level の source file ごとに 1 module を宣言する。対象は、開いた container の中と project root の両方である。container は、`src/` が source を持つときは `src/`、`src/` が無い、または source を持たないときは project root である。この map は、解析する file をすべて覆う。成長の seam や import の方向は、まだ検査しない。次に `npx archstrict recommend` を実行し、一緒に変わる file を 1 つの seam にまとめ、`edges` を足す。それが済むまで、この map は完成した architecture ではない。再実行したときは、手で編集した config をそのまま残し、`declaredModules` から `archstrict.types.ts` だけを再生成する。
77
+
78
+ `check` は project を解析し、各 violation を rule id、`path:line:col`、evidence、`because` の理由、`do:` command とともに表示する。
79
+
80
+ config は TypeScript の値 1 個である。`init` は歩いた tree から実際の `declaredModules` を書く。下の entry は、directory module と single-file module の例である。下の `exclude` は、`init` が常に書く基本のリストである。`init` は、disk 上で見つけた noise directory と colocated test file の pattern についても、それぞれ entry を足す。
81
+
82
+ ```ts
83
+ import type { Config } from "./archstrict.types.js";
84
+
85
+ export default {
86
+ schemaVersion: 1,
87
+ surface: ["index.ts", "index.tsx", "index.mts", "index.cts"],
88
+ exclude: ["archstrict.config.ts", "archstrict.types.ts", ".*/**", "**/.*/**"],
89
+ declaredModules: [
90
+ { name: "app", glob: "src/app/**" },
91
+ { name: "shared", glob: "src/shared/**" },
92
+ { name: "cli.ts", glob: "src/cli.ts", surface: "cli.ts" },
93
+ ],
94
+ because: "app and shared are directory modules; cli.ts is one loose file, public as itself",
95
+ } satisfies Config;
96
+ ```
97
+
98
+ `surface` は directory module の public-surface file の名前である。single-file module は、上の `cli.ts` のように、その file 自身を `surface` として書く。`because` は必須である。どの `declaredModules` の glob にも、どの `exclude` の pattern にも一致しない file は `uncovered-module` violation になる。
99
+
100
+ rule、command、config の全体については、[AGENTS.md](AGENTS.md)、[skills/archstrict/SKILL.md](skills/archstrict/SKILL.md)、[skills/archstrict/references/config.md](skills/archstrict/references/config.md) を参照する。
21
101
 
22
- ### local checkout からの導入
102
+ ## local checkout からの導入
23
103
 
24
- この repository の未公開の checkout に対して作業するときは、代わりに次のいずれかを使う。
104
+ 上の `npm install` は公開済みの package を入れる。この repository の checkout から作業する contributor と agent は、次のいずれかを使う。
25
105
 
26
106
  1. **同じ machine 上の local checkout からの `npm link`。**
27
107
 
package/README.md CHANGED
@@ -2,24 +2,104 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/archstrict?logo=npm)](https://www.npmjs.com/package/archstrict)
4
4
 
5
- arch is architecture, not tsc, not eslint, not a type checker: module boundary checking.
6
- Not archetype.
5
+ archstrict checks TypeScript module boundaries, in the sense of ArchUnit (Java) and archspec (Ruby). You declare each module in config: one directory, or one file when its glob names that file. A module shows the rest of the codebase one public-surface file, and anything that file does not export is private.
7
6
 
8
- TypeScript module boundary checking, in the sense of ArchUnit (Java) and archspec (Ruby): a module is one directory declared explicitly in config, it shows the rest of the codebase one public-surface file, and everything else inside it is private.
9
-
10
- See [AGENTS.md](AGENTS.md) for the shape, the rules, and the commands, and [skills/archstrict/SKILL.md](skills/archstrict/SKILL.md) for the workflow.
7
+ tsc and type checkers examine types, and ESLint examines style. archstrict examines the boundary: an import that reaches past that public surface into a module's internals is a violation.
11
8
 
12
9
  ## Install
13
10
 
11
+ Every host starts with the npm package in the project:
12
+
13
+ ```sh
14
+ npm install -D archstrict
15
+ ```
16
+
17
+ Requires Node.js 22 or newer.
18
+
19
+ This puts a real `archstrict` binary at `node_modules/.bin/archstrict`. The edit hooks and CI run this binary, and the MCP server loads the same installed package.
20
+
21
+ ### What each piece does
22
+
23
+ - **The CLI** (`npm install -D archstrict`) runs `init`, `check`, `todo`, and the other verbs. It is the only piece that finds violations.
24
+ - **The skill** (`skills/archstrict/`) teaches an agent to read a violation report and to change the config. A host loads it from its own skill directory, not from `node_modules/`.
25
+ - **The AGENTS.md section** (`archstrict agents`) tells any agent that reads `AGENTS.md` to run `archstrict rules <path>` before it creates a file or adds an import, and `archstrict check` after it edits. It is a few lines of project instructions, not the skill.
26
+ - **The edit hooks** (Claude Code only) run around each edit. The PreToolUse hook previews the change, and the PostToolUse hook runs `archstrict check <file>` and returns any violation into the agent's context. See [hook.md](skills/archstrict/references/hook.md).
27
+ - **The MCP server** (Claude Code plugin) gives the agent `check`, `rules`, `search`, and `simulate` as tools.
28
+ - **CI** runs `archstrict check` on every change, whichever host made it.
29
+
30
+ ### Claude Code
31
+
32
+ Install the plugin from this repository's marketplace. The plugin carries the skill, the two edit hooks, and the MCP server. Run these inside Claude Code:
33
+
34
+ ```text
35
+ /plugin marketplace add meganemura/archstrict
36
+ /plugin install archstrict@archstrict
37
+ ```
38
+
39
+ The hooks run the project's own `node_modules/.bin/archstrict`, so the npm install above is still required. The plugin does not load from `node_modules/archstrict/`: npm drops the symlinks that the plugin root needs (`.claude-plugin/plugin.json`, `hooks/`, `mcp/`). The package still carries the plugin's files under `node_modules/archstrict/.agents/`. To try an unreleased checkout, load it for one session with `claude --plugin-dir <path-to-clone>`.
40
+
41
+ ### Other agents (Cursor, Codex, cloud agents)
42
+
43
+ The edit hooks are Claude Code only. For any other agent, use three pieces:
44
+
45
+ 1. Install the skill from the public repository with the GitHub CLI. Replace `cursor` with your agent's value from `gh skill install --help`:
46
+
47
+ ```sh
48
+ gh skill install meganemura/archstrict archstrict --agent cursor
49
+ ```
50
+
51
+ Install it once for the user, with `--scope user`, so the skill is not copied into every repository. The default scope is the project: Cursor, Codex, and several other agents share `.agents/skills/archstrict/`. `archstrict agents` (the next step) is the per-project `AGENTS.md` section. It is a few lines of commands, and it is not a second copy of the skill.
52
+
53
+ 2. Add the AGENTS.md section:
54
+
55
+ ```sh
56
+ npx archstrict agents
57
+ ```
58
+
59
+ 3. Run `archstrict check` in CI. With no edit hook, CI is where a violation from an agent session is caught:
60
+
61
+ ```yaml
62
+ - run: npm ci
63
+ - run: npx archstrict check
64
+ ```
65
+
66
+ ## Quick start
67
+
14
68
  ```sh
15
- npm install --save-dev archstrict
69
+ npm install -D archstrict
70
+ npx archstrict init
71
+ npx archstrict check
16
72
  ```
17
73
 
18
- This puts a real `archstrict` binary at `node_modules/.bin/archstrict` in your project - the exact path the PreToolUse and PostToolUse hooks (see [hook.md](skills/archstrict/references/hook.md)) check for before previewing and confirming a change on your behalf around an edit. The install also carries the agent skill (`skills/archstrict/SKILL.md` and `skills/archstrict/references/`), `llms.txt`, and `.agents/` (the plugin manifest, the two hooks, and the MCP server) into `node_modules/archstrict/`. npm omits the checkout's symlinks (`.claude-plugin/plugin.json`, `hooks/`, `mcp/`), so the installed hooks are `node_modules/archstrict/.agents/hooks/pre-tool-use.mjs` and `post-tool-use.mjs`, and the installed MCP server is `node_modules/archstrict/.agents/mcp/server.mjs`. A git checkout still loads as a Claude Code plugin through those symlinks.
74
+ `init` writes `archstrict.config.ts` when that file is absent, and writes `archstrict.types.ts`, the module-name union. On a fresh project it declares one module per top-level directory that holds TypeScript source (`.ts`, `.tsx`, `.mts`, `.cts`), and one module per loose top-level source file, both inside the opened container and at the project root. The container is `src/` when that directory holds source, and the project root when `src/` is absent or holds none. That map covers every analyzed file. It does not yet name growth seams or check import direction. Run `npx archstrict recommend` next, group files that change together, and add an `edges` rule before treating the check as a finished architecture. A later run leaves a hand-edited config in place and only regenerates `archstrict.types.ts` from `declaredModules`.
75
+
76
+ `check` analyzes the project and prints each violation with a rule id, `path:line:col`, the evidence, a `because` reason, and a `do:` command.
77
+
78
+ A config is one TypeScript value. `init` writes the real `declaredModules` from the tree it walked; the entries below are examples of a directory module and a single-file module. The `exclude` list below is the base that `init` always writes. `init` also adds an entry for each noise directory and colocated test-file pattern it finds on disk.
79
+
80
+ ```ts
81
+ import type { Config } from "./archstrict.types.js";
82
+
83
+ export default {
84
+ schemaVersion: 1,
85
+ surface: ["index.ts", "index.tsx", "index.mts", "index.cts"],
86
+ exclude: ["archstrict.config.ts", "archstrict.types.ts", ".*/**", "**/.*/**"],
87
+ declaredModules: [
88
+ { name: "app", glob: "src/app/**" },
89
+ { name: "shared", glob: "src/shared/**" },
90
+ { name: "cli.ts", glob: "src/cli.ts", surface: "cli.ts" },
91
+ ],
92
+ because: "app and shared are directory modules; cli.ts is one loose file, public as itself",
93
+ } satisfies Config;
94
+ ```
95
+
96
+ `surface` names the public-surface file of a directory module. A single-file module names that file as its own `surface`, as `cli.ts` does above. `because` is required. A file that matches no `declaredModules` glob and no `exclude` pattern is an `uncovered-module` violation.
97
+
98
+ Rules, commands, and the full config: [AGENTS.md](AGENTS.md), [skills/archstrict/SKILL.md](skills/archstrict/SKILL.md), and [skills/archstrict/references/config.md](skills/archstrict/references/config.md).
19
99
 
20
- ### Installing from a local checkout
100
+ ## Installing from a local checkout
21
101
 
22
- Use one of these instead when working against an unpublished checkout of this repository.
102
+ The `npm install` above installs the published package. Contributors and agents working from a checkout of this repository use one of the modes below.
23
103
 
24
104
  1. **`npm link`, from a local checkout on the same machine.**
25
105
 
package/dist/cli.js CHANGED
@@ -110,7 +110,11 @@ async function runTodo(args) {
110
110
  else {
111
111
  process.stdout.write(`pruned ${result.pruned} stale entrie(s)\n`);
112
112
  }
113
- process.stdout.write(`do: archstrict check\n`);
113
+ for (const note of result.notes ?? [])
114
+ process.stdout.write(`note: ${note}\n`);
115
+ process.stdout.write(result.notes !== undefined && result.notes.length > 0
116
+ ? `do: split the module named above before treating this freeze as done\n`
117
+ : `do: archstrict check\n`);
114
118
  return 0;
115
119
  }
116
120
  async function runRules(args) {
package/dist/config.js CHANGED
@@ -156,7 +156,15 @@ export function assertGlobsSupported(config, verb) {
156
156
  assertGlobSupported(configPath, `mustBeEmpty[${i}].glob`, entry?.glob, verb);
157
157
  }
158
158
  for (const [i, mod] of (config.declaredModules ?? []).entries()) {
159
- assertGlobSupported(configPath, `declaredModules[${i}].glob`, mod?.glob, verb);
159
+ const glob = mod?.glob;
160
+ if (Array.isArray(glob)) {
161
+ for (const [j, entry] of glob.entries()) {
162
+ assertGlobSupported(configPath, `declaredModules[${i}].glob[${j}]`, entry, verb);
163
+ }
164
+ }
165
+ else {
166
+ assertGlobSupported(configPath, `declaredModules[${i}].glob`, glob, verb);
167
+ }
160
168
  const surface = mod?.surface;
161
169
  if (Array.isArray(surface)) {
162
170
  for (const [j, s] of surface.entries()) {
@@ -0,0 +1,271 @@
1
+ // Responsibility: decide whether a project-relative path is gitignored, by
2
+ // parsing .gitignore files and the repository's info/exclude with git's own
3
+ // pattern rules (negation, directory-only patterns, anchoring, `**`), so the
4
+ // project walk can skip scratch output that was never project source.
5
+ // Boundary: this module reads ignore files only. It never spawns git, never
6
+ // walks a directory tree (module-graph.ts's walk calls in per directory), and
7
+ // never reads the user's global core.excludesFile: that file differs from
8
+ // machine to machine, so honoring it would make a laptop and CI analyze
9
+ // different file sets from the same checkout.
10
+ //
11
+ // Parsing instead of `git ls-files --ignored` keeps the walk independent of
12
+ // git: a copy of a checkout with no .git directory, or a machine with no git
13
+ // binary, still skips what the checkout's own .gitignore files name.
14
+ import { existsSync, readFileSync, statSync } from "node:fs";
15
+ import { dirname, join, relative, resolve, sep } from "node:path";
16
+ function makeLayer(rules, prefix, strip) {
17
+ const literalAny = new Map();
18
+ const literalDir = new Map();
19
+ const patterned = [];
20
+ rules.forEach((rule, index) => {
21
+ if (rule.literal === undefined)
22
+ patterned.push(index);
23
+ else
24
+ (rule.dirOnly ? literalDir : literalAny).set(rule.literal, index);
25
+ });
26
+ return { rules, prefix, strip, literalAny, literalDir, patterned };
27
+ }
28
+ function escapeRegex(char) {
29
+ return /[\\^$.*+?()[\]{}|/]/.test(char) ? `\\${char}` : char;
30
+ }
31
+ // One path segment (no "/") of a gitignore pattern, as a regex source.
32
+ function translateSegment(segment) {
33
+ let out = "";
34
+ for (let i = 0; i < segment.length; i++) {
35
+ const char = segment[i];
36
+ if (char === "\\" && i + 1 < segment.length) {
37
+ out += escapeRegex(segment[++i]);
38
+ }
39
+ else if (char === "*") {
40
+ // A "**" that is not a whole segment is an ordinary "*" (git's rule).
41
+ while (segment[i + 1] === "*")
42
+ i++;
43
+ out += "[^/]*";
44
+ }
45
+ else if (char === "?") {
46
+ out += "[^/]";
47
+ }
48
+ else if (char === "[") {
49
+ const close = segment.indexOf("]", i + 2);
50
+ if (close === -1) {
51
+ out += "\\[";
52
+ continue;
53
+ }
54
+ let body = segment.slice(i + 1, close);
55
+ let negated = false;
56
+ if (body.startsWith("!") || body.startsWith("^")) {
57
+ negated = true;
58
+ body = body.slice(1);
59
+ }
60
+ out += `[${negated ? "^" : ""}${body.replace(/[\\\]^]/g, (c) => `\\${c}`)}]`;
61
+ i = close;
62
+ }
63
+ else {
64
+ out += escapeRegex(char);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ // Exported for the parity test against `git check-ignore`.
70
+ export function parseGitignore(text) {
71
+ const rules = [];
72
+ for (const rawLine of text.split("\n")) {
73
+ let line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
74
+ if (line === "" || line.startsWith("#"))
75
+ continue;
76
+ // Trailing spaces are dropped unless the last one is escaped.
77
+ while (line.endsWith(" ") && !line.endsWith("\\ "))
78
+ line = line.slice(0, -1);
79
+ let negate = false;
80
+ if (line.startsWith("!")) {
81
+ negate = true;
82
+ line = line.slice(1);
83
+ }
84
+ let dirOnly = false;
85
+ if (line.endsWith("/")) {
86
+ dirOnly = true;
87
+ line = line.slice(0, -1);
88
+ }
89
+ if (line === "")
90
+ continue;
91
+ // A slash at the start or in the middle anchors the pattern to the
92
+ // ignore file's own directory; otherwise it matches at any depth.
93
+ const anchored = line.includes("/");
94
+ if (line.startsWith("/"))
95
+ line = line.slice(1);
96
+ const segments = line.split("/");
97
+ let source = "";
98
+ segments.forEach((segment, index) => {
99
+ const last = index === segments.length - 1;
100
+ if (segment === "**") {
101
+ source += last ? ".*" : "(?:.*/)?";
102
+ return;
103
+ }
104
+ source += translateSegment(segment) + (last ? "" : "/");
105
+ });
106
+ const regex = new RegExp(anchored ? `^${source}$` : `^(?:.*/)?${source}$`);
107
+ const literal = !anchored && !/[*?[\\]/.test(line) ? line : undefined;
108
+ rules.push(literal === undefined ? { regex, negate, dirOnly } : { regex, negate, dirOnly, literal });
109
+ }
110
+ return rules;
111
+ }
112
+ function toPosix(path) {
113
+ return sep === "/" ? path : path.split(sep).join("/");
114
+ }
115
+ function readText(path) {
116
+ try {
117
+ return readFileSync(path, "utf8");
118
+ }
119
+ catch {
120
+ return undefined;
121
+ }
122
+ }
123
+ // The repository's common git directory for `projectRoot`, found by walking
124
+ // up to the nearest `.git`. A linked worktree's `.git` is a file that points
125
+ // at its own git directory, whose `commondir` names the shared one where
126
+ // info/exclude lives.
127
+ function findRepository(projectRoot) {
128
+ let dir = projectRoot;
129
+ for (;;) {
130
+ const dotGit = join(dir, ".git");
131
+ if (existsSync(dotGit)) {
132
+ let gitDir = dotGit;
133
+ try {
134
+ if (statSync(dotGit).isFile()) {
135
+ const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"));
136
+ if (match === null)
137
+ return undefined;
138
+ gitDir = resolve(dir, match[1].trim());
139
+ }
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ const commonDirText = readText(join(gitDir, "commondir"));
145
+ const commonDir = commonDirText === undefined ? gitDir : resolve(gitDir, commonDirText.trim());
146
+ return { root: dir, commonDir };
147
+ }
148
+ const parent = dirname(dir);
149
+ if (parent === dir)
150
+ return undefined;
151
+ dir = parent;
152
+ }
153
+ }
154
+ // Every ignore file that applies above `projectRoot` itself: info/exclude
155
+ // and each .gitignore from the repository root down to the project root's
156
+ // parent. The project root's own .gitignore and every nested one are added
157
+ // by the caller as it reaches each directory (withGitignoreFile). Outside a
158
+ // repository this is empty, and the nested files still apply.
159
+ export function gitignoreStackAbove(projectRoot) {
160
+ const repository = findRepository(projectRoot);
161
+ if (repository === undefined)
162
+ return [];
163
+ const layers = [];
164
+ const add = (baseDir, text) => {
165
+ if (text === undefined)
166
+ return;
167
+ const rules = parseGitignore(text);
168
+ if (rules.length === 0)
169
+ return;
170
+ const fromBase = toPosix(relative(baseDir, projectRoot));
171
+ layers.push(makeLayer(rules, fromBase === "" ? "" : `${fromBase}/`, 0));
172
+ };
173
+ add(repository.root, readText(join(repository.commonDir, "info", "exclude")));
174
+ const between = [];
175
+ for (let dir = projectRoot; dir !== repository.root; dir = dirname(dir)) {
176
+ const parent = dirname(dir);
177
+ if (parent === dir)
178
+ break;
179
+ between.unshift(parent);
180
+ }
181
+ for (const dir of between)
182
+ add(dir, readText(join(dir, ".gitignore")));
183
+ return layers;
184
+ }
185
+ // `stack` plus the .gitignore whose text is `text`, found in the directory
186
+ // at project-relative `dirRel` ("" for the project root).
187
+ export function withGitignoreFile(stack, dirRel, text) {
188
+ const rules = parseGitignore(text);
189
+ if (rules.length === 0)
190
+ return stack;
191
+ return [...stack, makeLayer(rules, "", dirRel === "" ? 0 : dirRel.length + 1)];
192
+ }
193
+ // The deepest layer's last matching rule decides, the same precedence git
194
+ // uses. No match at all means not ignored.
195
+ export function isIgnoredBy(stack, rel, isDir) {
196
+ if (stack.length === 0)
197
+ return false;
198
+ const basename = rel.slice(rel.lastIndexOf("/") + 1);
199
+ for (let l = stack.length - 1; l >= 0; l--) {
200
+ const layer = stack[l];
201
+ let best = layer.literalAny.get(basename) ?? -1;
202
+ if (isDir)
203
+ best = Math.max(best, layer.literalDir.get(basename) ?? -1);
204
+ const local = layer.prefix + rel.slice(layer.strip);
205
+ for (let p = layer.patterned.length - 1; p >= 0; p--) {
206
+ const index = layer.patterned[p];
207
+ if (index < best)
208
+ break;
209
+ const rule = layer.rules[index];
210
+ if (rule.dirOnly && !isDir)
211
+ continue;
212
+ if (rule.regex.test(local)) {
213
+ best = index;
214
+ break;
215
+ }
216
+ }
217
+ if (best >= 0)
218
+ return !layer.rules[best].negate;
219
+ }
220
+ return false;
221
+ }
222
+ // The state of the entry at project-relative `rel`, given its parent's
223
+ // state. Git never re-includes a path under an ignored directory, so
224
+ // "ignored" is inherited; only a declared module's base directory (or
225
+ // single file) reverses it, for its whole subtree.
226
+ export function nextIgnoreState(parent, stack, rel, isDir, forcedBases) {
227
+ if (parent === "forced")
228
+ return "forced";
229
+ const ignored = parent === "ignored" || isIgnoredBy(stack, rel, isDir);
230
+ if (!ignored)
231
+ return "kept";
232
+ return forcedBases.has(rel) ? "forced" : "ignored";
233
+ }
234
+ // The walk's own decision for one path that the walk never visited: a file
235
+ // simulate proposes to create. Replays nextIgnoreState down the path's own
236
+ // directories, reading each directory's .gitignore on the way.
237
+ export function isPathGitignored(projectRoot, rel, forcedBases) {
238
+ let stack = gitignoreStackAbove(projectRoot);
239
+ const rootText = readText(join(projectRoot, ".gitignore"));
240
+ if (rootText !== undefined)
241
+ stack = withGitignoreFile(stack, "", rootText);
242
+ const parts = rel.split("/");
243
+ let state = "kept";
244
+ for (let i = 0; i < parts.length; i++) {
245
+ const sub = parts.slice(0, i + 1).join("/");
246
+ const isDir = i < parts.length - 1;
247
+ state = nextIgnoreState(state, stack, sub, isDir, forcedBases);
248
+ if (state === "kept" && isDir) {
249
+ const text = readText(join(projectRoot, sub, ".gitignore"));
250
+ if (text !== undefined)
251
+ stack = withGitignoreFile(stack, sub, text);
252
+ }
253
+ }
254
+ return state === "ignored";
255
+ }
256
+ // The project-relative base of every declared module whose glob names a
257
+ // real path (the literal part before the first wildcard). A base of ""
258
+ // (a glob like "**/*.ts") covers the whole project and forces nothing.
259
+ export function forcedBasesOf(bases) {
260
+ const set = new Set();
261
+ const ancestors = new Set();
262
+ for (const base of bases) {
263
+ if (base === "")
264
+ continue;
265
+ set.add(base);
266
+ const parts = base.split("/");
267
+ for (let i = 1; i < parts.length; i++)
268
+ ancestors.add(parts.slice(0, i).join("/"));
269
+ }
270
+ return { bases: set, ancestors };
271
+ }