archstrict 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/hooks/hooks.json +29 -0
- package/.agents/hooks/post-tool-use.mjs +107 -0
- package/.agents/hooks/pre-tool-use.mjs +182 -0
- package/.agents/mcp/server.mjs +71 -0
- package/.agents/plugin.json +19 -0
- package/AGENTS.md +81 -0
- package/CHANGELOG.md +77 -0
- package/README.ja.md +142 -0
- package/README.md +143 -2
- package/dist/augmentation-cache.js +65 -0
- package/dist/check-options.js +40 -0
- package/dist/classify.js +148 -0
- package/dist/cli.js +243 -0
- package/dist/config-pointer.js +251 -0
- package/dist/config.js +194 -0
- package/dist/edge-cache.js +530 -0
- package/dist/gitignore.js +271 -0
- package/dist/mcp-server.js +111 -0
- package/dist/module-candidates.js +125 -0
- package/dist/module-graph.js +2179 -0
- package/dist/project-path.js +59 -0
- package/dist/report-error.js +13 -0
- package/dist/rules/config-meaning.js +143 -0
- package/dist/rules/constraints.js +419 -0
- package/dist/rules/cycles.js +285 -0
- package/dist/rules/deprecated.js +67 -0
- package/dist/rules/empty-rule.js +101 -0
- package/dist/rules/moves.js +79 -0
- package/dist/rules/must-be-empty.js +52 -0
- package/dist/rules/public-surface.js +100 -0
- package/dist/rules/uncovered.js +75 -0
- package/dist/todo-migration.js +112 -0
- package/dist/todo-store.js +434 -0
- package/dist/type-closure.js +959 -0
- package/dist/type-leak.js +590 -0
- package/dist/verbs/agents.js +116 -0
- package/dist/verbs/check.js +1011 -0
- package/dist/verbs/fix.js +170 -0
- package/dist/verbs/hotspots.js +261 -0
- package/dist/verbs/init.js +538 -0
- package/dist/verbs/map-shape.js +78 -0
- package/dist/verbs/recommend.js +863 -0
- package/dist/verbs/rules.js +188 -0
- package/dist/verbs/search.js +109 -0
- package/dist/verbs/simulate.js +220 -0
- package/dist/verbs/todo.js +180 -0
- package/dist/warm-graph.js +82 -0
- package/docs/boundary-patterns.md +374 -0
- package/docs/calibrated-rules-design.md +124 -0
- package/docs/init-singleton-modules.md +133 -0
- package/docs/maintenance.md +109 -0
- package/docs/releasing.md +58 -0
- package/docs/rules-edge-cache.md +50 -0
- package/docs/todo-single-file-migration.md +58 -0
- package/llms.txt +25 -0
- package/package.json +61 -4
- package/skills/archstrict/SKILL.md +54 -0
- package/skills/archstrict/references/agents-verb.md +39 -0
- package/skills/archstrict/references/config.md +116 -0
- package/skills/archstrict/references/hook.md +57 -0
- package/skills/archstrict/references/path-rules.md +57 -0
- package/skills/archstrict/references/patterns.md +915 -0
- package/skills/archstrict/references/prove-rules.md +58 -0
- package/skills/archstrict/references/rearchitect.md +66 -0
- package/skills/archstrict/references/recommend.md +98 -0
- package/skills/archstrict/references/rules.md +149 -0
- package/skills/archstrict/references/simulate.md +109 -0
package/README.ja.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# 🧱 archstrict
|
|
2
|
+
|
|
3
|
+
[English](README.md)
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/archstrict)
|
|
6
|
+
|
|
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 である。
|
|
8
|
+
|
|
9
|
+
tsc と型チェッカーが見るのは型であり、ESLint が見るのは style である。archstrict が見るのは境界である。その public surface を越えて module の内部へ届く import は violation になる。
|
|
10
|
+
|
|
11
|
+
## 導入
|
|
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
|
+
|
|
70
|
+
```sh
|
|
71
|
+
npm install -D archstrict
|
|
72
|
+
npx archstrict init
|
|
73
|
+
npx archstrict check
|
|
74
|
+
```
|
|
75
|
+
|
|
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) を参照する。
|
|
101
|
+
|
|
102
|
+
## local checkout からの導入
|
|
103
|
+
|
|
104
|
+
上の `npm install` は公開済みの package を入れる。この repository の checkout から作業する contributor と agent は、次のいずれかを使う。
|
|
105
|
+
|
|
106
|
+
1. **同じ machine 上の local checkout からの `npm link`。**
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
# この checkout の中で
|
|
110
|
+
npm run build # dist/ が無いか古い場合
|
|
111
|
+
npm link
|
|
112
|
+
|
|
113
|
+
# 検査したいプロジェクトの中で
|
|
114
|
+
npm link archstrict
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
対象プロジェクトで `npm unlink archstrict` を実行すれば取り除ける。
|
|
118
|
+
|
|
119
|
+
2. **local checkout への `file:` 依存**。依存関係を global link ではなく、対象側の `package.json` 自体に記録したい場合に使う。
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
"archstrict": "file:../archstrict"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`npm install` はこれを `npm link` と同じ symlink に変換し、build 手順は実行しない。対象プロジェクトで `npm install` を実行する前に、checkout 側で `npm run build` を実行する。symlink 化された `file:` 依存は checkout 自身の lifecycle script を実行しないため、`prepare` script は決して build を行わない。build 前に既に install していた場合は、対象プロジェクトで `npm install` をやり直す。これで `dist/` ができた後の binary にリンクし直される。
|
|
126
|
+
|
|
127
|
+
3. **git 依存**(`"archstrict": "github:<owner>/archstrict#<ref>"`)。この checkout に到達できないが、git 経由で repository を読める machine 向け。`dist/` は commit されていないため、npm は package の devDependencies を install し、clone 後に `prepare` script(`npm run build`)を実行して `dist/` を build する。2 つの条件がある。
|
|
128
|
+
- install する machine が repository を読めること。access がその repository だけの agent はここで 404 になるため、方法 4 を使う。
|
|
129
|
+
- lifecycle script が有効であること。npm config で `ignore-scripts=true` の場合、`prepare` は実行されず `dist/` の無い install になり、`node_modules/.bin/archstrict` は存在しない file を指す。この install だけ `--ignore-scripts=false` を渡すか、方法 4 を使う。
|
|
130
|
+
|
|
131
|
+
4. **`npm pack` が作る tarball** を対象 machine にコピーする方法。この repository に一切 access できない対象(例えば access がその repository だけの agent)でも動く唯一の方法である。
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
# この checkout の中で
|
|
135
|
+
npm run build
|
|
136
|
+
npm pack # archstrict-<version>.tgz を書き出す
|
|
137
|
+
|
|
138
|
+
# tarball を対象 machine にコピーしたあと、対象プロジェクトの中で
|
|
139
|
+
npm install ./archstrict-<version>.tgz
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`npm pack` は checkout の working tree を packing する。git 履歴ではないため、`dist/` が既に build 済みであることが要る。実際に実行して確認済み: tarball には `dist/`、`skills/`、`llms.txt`、`.agents/`、`README.md`、`README.ja.md`、`CHANGELOG.md`、`docs/`、`AGENTS.md`、`LICENSE`、`package.json` が入る。これは `npm link` と `file:` の方法が見せる集合と同じで、それに packaging 自体が加わる。
|
package/README.md
CHANGED
|
@@ -1,3 +1,144 @@
|
|
|
1
|
-
# archstrict
|
|
1
|
+
# 🧱 archstrict
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/archstrict)
|
|
4
|
+
|
|
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.
|
|
6
|
+
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
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
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm install -D archstrict
|
|
70
|
+
npx archstrict init
|
|
71
|
+
npx archstrict check
|
|
72
|
+
```
|
|
73
|
+
|
|
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).
|
|
99
|
+
|
|
100
|
+
## Installing from a local checkout
|
|
101
|
+
|
|
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.
|
|
103
|
+
|
|
104
|
+
1. **`npm link`, from a local checkout on the same machine.**
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
# in this checkout
|
|
108
|
+
npm run build # if dist/ is missing or stale
|
|
109
|
+
npm link
|
|
110
|
+
|
|
111
|
+
# in the project you want to check
|
|
112
|
+
npm link archstrict
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`npm unlink archstrict` in the target project removes it again.
|
|
116
|
+
|
|
117
|
+
2. **A `file:` dependency on a local checkout**, when you want the dependency recorded in the target's own `package.json` instead of a global link:
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
"archstrict": "file:../archstrict"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`npm install` turns this into a symlink to the checkout, the same way `npm link` does, and runs no build step. Run `npm run build` in the checkout before running `npm install` in the target project - a symlinked `file:` dependency does not run the checkout's lifecycle scripts, so its `prepare` script never builds it. If you already installed before building, rerun `npm install` in the target project afterward, so it links the binary now that `dist/` exists.
|
|
124
|
+
|
|
125
|
+
3. **A git dependency** (`"archstrict": "github:<owner>/archstrict#<ref>"`), for a machine that cannot reach this checkout but can read the repository over git. `dist/` is not committed; npm installs the package's devDependencies and runs its `prepare` script (`npm run build`) after cloning, which builds `dist/`. Two conditions apply:
|
|
126
|
+
- The installing machine must be able to read the repository. An agent whose access covers only the repository it runs in gets a 404 here; use mode 4 instead.
|
|
127
|
+
- Lifecycle scripts must be enabled. With `ignore-scripts=true` in the npm config, `prepare` never runs and the install has no `dist/`, so `node_modules/.bin/archstrict` points at a missing file. Pass `--ignore-scripts=false` for this install, or use mode 4.
|
|
128
|
+
|
|
129
|
+
4. **A tarball from `npm pack`**, copied to the target machine - the mode that works where the target has no access to this repository at all (for example, an agent whose access covers only the repository it runs in):
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
# in this checkout
|
|
133
|
+
npm run build
|
|
134
|
+
npm pack # writes archstrict-<version>.tgz
|
|
135
|
+
|
|
136
|
+
# copy the tarball to the target machine, then in the target project
|
|
137
|
+
npm install ./archstrict-<version>.tgz
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`npm pack` packs the checkout's working tree, not its git history, so it needs `dist/` already built. Confirmed by running it: the tarball contains `dist/`, `skills/`, `llms.txt`, `.agents/`, `README.md`, `README.ja.md`, `CHANGELOG.md`, `docs/`, `AGENTS.md`, `LICENSE`, and `package.json` - the same set `npm link` and the `file:` mode expose, plus the packaging itself.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
[Japanese](README.ja.md)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Responsibility: store the lazy module-augmentation scan for TypeScript
|
|
2
|
+
// files outside analysis. Each absolute file path owns one validated entry.
|
|
3
|
+
// Boundary: this module does not list, read, parse, or resolve project files.
|
|
4
|
+
// The caller supplies scan results and treats every cache failure as a miss.
|
|
5
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
// An unknown shape is a silent miss. Trying to decode an older shape is
|
|
9
|
+
// refused because a stale negative answer can suppress a required fallback.
|
|
10
|
+
export const AUGMENTATION_CACHE_SCHEMA = 1;
|
|
11
|
+
function record(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function isMode(value) {
|
|
15
|
+
return value === undefined || (typeof value === "number" && Number.isInteger(value));
|
|
16
|
+
}
|
|
17
|
+
function isSpecifier(value) {
|
|
18
|
+
return record(value) && typeof value.specifier === "string" && isMode(value.mode);
|
|
19
|
+
}
|
|
20
|
+
function isEntry(value) {
|
|
21
|
+
return record(value) && typeof value.mtimeMs === "number" && Number.isFinite(value.mtimeMs) &&
|
|
22
|
+
typeof value.size === "number" && Number.isFinite(value.size) &&
|
|
23
|
+
typeof value.optionsHash === "string" && isMode(value.impliedNodeFormat) &&
|
|
24
|
+
Array.isArray(value.specifiers) && value.specifiers.every(isSpecifier);
|
|
25
|
+
}
|
|
26
|
+
// Invalid content is a cache miss. Reporting cache damage is refused because
|
|
27
|
+
// the scan result remains available from the project files themselves.
|
|
28
|
+
export function readAugmentationCache(path, archstrictVersion) {
|
|
29
|
+
let value;
|
|
30
|
+
try {
|
|
31
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
if (!record(value) || value.schema !== AUGMENTATION_CACHE_SCHEMA ||
|
|
37
|
+
value.archstrictVersion !== archstrictVersion || !record(value.files) ||
|
|
38
|
+
!Object.values(value.files).every(isEntry))
|
|
39
|
+
return undefined;
|
|
40
|
+
const entries = value.files;
|
|
41
|
+
const files = Object.fromEntries(Object.entries(entries).map(([file, entry]) => [file, {
|
|
42
|
+
...entry,
|
|
43
|
+
// JSON omits an undefined mode. Restoring the field is required because
|
|
44
|
+
// callers use the same exact shape that the syntax scanner returns.
|
|
45
|
+
specifiers: entry.specifiers.map((item) => ({ specifier: item.specifier, mode: item.mode })),
|
|
46
|
+
}]));
|
|
47
|
+
return { schema: AUGMENTATION_CACHE_SCHEMA, archstrictVersion, files };
|
|
48
|
+
}
|
|
49
|
+
// A temporary file keeps a killed writer from leaving partial JSON. Direct
|
|
50
|
+
// writes are refused because a later scoped check must treat the cache atomically.
|
|
51
|
+
export function writeAugmentationCache(path, archstrictVersion, files) {
|
|
52
|
+
const dir = dirname(path);
|
|
53
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(dir, { recursive: true });
|
|
56
|
+
writeFileSync(temp, JSON.stringify({ schema: AUGMENTATION_CACHE_SCHEMA, archstrictVersion, files }));
|
|
57
|
+
renameSync(temp, path);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
try {
|
|
61
|
+
rmSync(temp);
|
|
62
|
+
}
|
|
63
|
+
catch { /* A successful rename already removes it. */ }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Responsibility: parse the check verb's command-line options.
|
|
2
|
+
// Boundary: rule and module existence depend on the loaded project and are validated by check().
|
|
3
|
+
import { ReportError } from "./report-error.js";
|
|
4
|
+
export function parseCheckArgv(argv) {
|
|
5
|
+
const positional = [];
|
|
6
|
+
const rules = [];
|
|
7
|
+
const modules = [];
|
|
8
|
+
let asJson = false;
|
|
9
|
+
let prove = false;
|
|
10
|
+
let frozen = false;
|
|
11
|
+
for (let index = 0; index < argv.length; index++) {
|
|
12
|
+
const arg = argv[index];
|
|
13
|
+
if (arg === "--json") {
|
|
14
|
+
asJson = true;
|
|
15
|
+
}
|
|
16
|
+
else if (arg === "--prove") {
|
|
17
|
+
prove = true;
|
|
18
|
+
}
|
|
19
|
+
else if (arg === "--frozen") {
|
|
20
|
+
frozen = true;
|
|
21
|
+
}
|
|
22
|
+
else if (arg === "--rule" || arg === "--module") {
|
|
23
|
+
const value = argv[++index];
|
|
24
|
+
if (value === undefined || value.startsWith("--")) {
|
|
25
|
+
throw new ReportError(`${arg} requires a value`, "archstrict check [file] [--rule <id>] [--module <name>]");
|
|
26
|
+
}
|
|
27
|
+
(arg === "--rule" ? rules : modules).push(value);
|
|
28
|
+
}
|
|
29
|
+
else if (arg.startsWith("-")) {
|
|
30
|
+
throw new ReportError(`unknown option '${arg}'`, "archstrict check [file] [--rule <id>] [--module <name>]");
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
positional.push(arg);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (positional.length > 1) {
|
|
37
|
+
throw new ReportError("check takes at most one file", "archstrict check [file] [--rule <id>] [--module <name>]");
|
|
38
|
+
}
|
|
39
|
+
return { asJson, prove, frozen, focusFile: positional[0], rules, modules };
|
|
40
|
+
}
|
package/dist/classify.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Responsibility: turn a config's `classify` (glob -> tags) and
|
|
2
|
+
// `classifyByDirectoryName` (ambient, name-based) entries into the tag set a
|
|
3
|
+
// given file carries. Boundary: no edge-constraint logic here (that's
|
|
4
|
+
// tickets 4+); this module only answers "what tags does this file have."
|
|
5
|
+
//
|
|
6
|
+
// Precedence for two `classify` entries that both match the same file:
|
|
7
|
+
// most-specific wins, where specificity is (a) the glob's literal prefix
|
|
8
|
+
// length (the text before its first wildcard character), then (b) fewest
|
|
9
|
+
// wildcard characters. This makes config order irrelevant - the property a
|
|
10
|
+
// coding agent depends on when it can't see how a config it's editing was
|
|
11
|
+
// originally ordered. A tie (identical specificity, different tags) is a
|
|
12
|
+
// config error: two equally-specific entries disagreeing about the same
|
|
13
|
+
// file is not something precedence can resolve for you.
|
|
14
|
+
//
|
|
15
|
+
// `classify` and `classifyByDirectoryName` are independent mechanisms whose
|
|
16
|
+
// results union: a file can get tags from an explicit glob AND an ambient
|
|
17
|
+
// directory-name match at once (VS Code's own env:* tags are pure ambient;
|
|
18
|
+
// Prisma's are pure explicit; nothing requires a project pick only one).
|
|
19
|
+
import { sep } from "node:path";
|
|
20
|
+
import { ReportError } from "./report-error.js";
|
|
21
|
+
// Converts one glob into a matcher plus its specificity. Supports `**`
|
|
22
|
+
// (any number of path segments, including zero) and `*` (any characters
|
|
23
|
+
// within one path segment - no `/`). Anything else in the pattern is a
|
|
24
|
+
// literal character, escaped for use in a RegExp. Exported: declared-module
|
|
25
|
+
// membership (module-graph.ts) uses the same precedence rule as tag
|
|
26
|
+
// classification does, and shouldn't reimplement it.
|
|
27
|
+
// Every per-file caller (mostSpecificMatch, for declared-module membership
|
|
28
|
+
// and tag classification; the exclude-glob check and the .d.ts surface
|
|
29
|
+
// check in isEligibleSourceFileWithDtsGlobs) recompiles the same fixed,
|
|
30
|
+
// small set of config globs once per candidate file - O(files * globs)
|
|
31
|
+
// RegExp construction on a large codebase. A glob's own compiled form
|
|
32
|
+
// depends only on its literal text, so caching by that text is exact, not
|
|
33
|
+
// approximate: the same string always compiles to the same matcher.
|
|
34
|
+
const compiledGlobCache = new Map();
|
|
35
|
+
export function compileGlob(glob) {
|
|
36
|
+
const cached = compiledGlobCache.get(glob);
|
|
37
|
+
if (cached !== undefined)
|
|
38
|
+
return cached;
|
|
39
|
+
const compiled = compileGlobUncached(glob);
|
|
40
|
+
compiledGlobCache.set(glob, compiled);
|
|
41
|
+
return compiled;
|
|
42
|
+
}
|
|
43
|
+
function compileGlobUncached(glob) {
|
|
44
|
+
const firstWildcard = glob.search(/\*/);
|
|
45
|
+
const literalPrefixLength = firstWildcard === -1 ? glob.length : firstWildcard;
|
|
46
|
+
const wildcardCount = (glob.match(/\*/g) ?? []).length;
|
|
47
|
+
let pattern = "";
|
|
48
|
+
let i = 0;
|
|
49
|
+
while (i < glob.length) {
|
|
50
|
+
if (glob.startsWith("/**/", i)) {
|
|
51
|
+
// `a/**/b.ts` must match `a/b.ts` too (zero segments between the two
|
|
52
|
+
// literal slashes), not just `a/x/b.ts` - translating `**` to `.*` in
|
|
53
|
+
// isolation while keeping both surrounding slashes as literals would
|
|
54
|
+
// require at least one segment. Fold the trailing slash into an
|
|
55
|
+
// optional group instead: one literal slash, then an optional
|
|
56
|
+
// "anything, ending in a slash" group.
|
|
57
|
+
pattern += "/(?:.*/)?";
|
|
58
|
+
i += 4;
|
|
59
|
+
}
|
|
60
|
+
else if (glob.startsWith("**", i)) {
|
|
61
|
+
pattern += ".*";
|
|
62
|
+
i += 2;
|
|
63
|
+
}
|
|
64
|
+
else if (glob[i] === "*") {
|
|
65
|
+
pattern += "[^/]*";
|
|
66
|
+
i += 1;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
pattern += glob[i].replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const re = new RegExp(`^${pattern}$`);
|
|
74
|
+
return { test: (path) => re.test(path), literalPrefixLength, wildcardCount };
|
|
75
|
+
}
|
|
76
|
+
// A path is more specific than another when its literal prefix is longer,
|
|
77
|
+
// or (tied) it has fewer wildcards. Returns 0 for a genuine tie: same
|
|
78
|
+
// literal-prefix length AND same wildcard count - the config-error case.
|
|
79
|
+
function compareSpecificity(a, b) {
|
|
80
|
+
if (a.literalPrefixLength !== b.literalPrefixLength) {
|
|
81
|
+
return a.literalPrefixLength - b.literalPrefixLength;
|
|
82
|
+
}
|
|
83
|
+
return b.wildcardCount - a.wildcardCount; // fewer wildcards = more specific
|
|
84
|
+
}
|
|
85
|
+
export class AmbiguousClassifyError extends ReportError {
|
|
86
|
+
constructor(path, glob1, glob2) {
|
|
87
|
+
super(`'${path}' matches two equally-specific entries ('${glob1}' and '${glob2}') with no way to prefer one - narrow one of the globs`, `narrow '${glob1}' or '${glob2}' in archstrict.config.ts, then run archstrict check`);
|
|
88
|
+
this.name = "AmbiguousClassifyError";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Shared precedence engine: the most-specific of several glob-keyed entries
|
|
92
|
+
// matching `path` wins, config order is irrelevant, and a genuine tie
|
|
93
|
+
// (equal specificity, different `value`s per `sameValue`) throws. Used both
|
|
94
|
+
// for tag classification (`value` is a tag array) and declared-module
|
|
95
|
+
// membership (`value` is a module name) - two different callers, one
|
|
96
|
+
// precedence rule, so they can't quietly drift apart.
|
|
97
|
+
export function mostSpecificMatch(path, entries, sameValue) {
|
|
98
|
+
let best;
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
const compiled = compileGlob(entry.glob);
|
|
101
|
+
if (!compiled.test(path))
|
|
102
|
+
continue;
|
|
103
|
+
if (best === undefined) {
|
|
104
|
+
best = { value: entry.value, glob: entry.glob, ...compiled };
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const cmp = compareSpecificity(compiled, best);
|
|
108
|
+
if (cmp > 0) {
|
|
109
|
+
best = { value: entry.value, glob: entry.glob, ...compiled };
|
|
110
|
+
}
|
|
111
|
+
else if (cmp === 0 && !sameValue(entry.value, best.value)) {
|
|
112
|
+
throw new AmbiguousClassifyError(path, best.glob, entry.glob);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return best?.value;
|
|
116
|
+
}
|
|
117
|
+
function sameTags(a, b) {
|
|
118
|
+
return a.length === b.length && a.every((tag, i) => tag === b[i]);
|
|
119
|
+
}
|
|
120
|
+
// Relative path (project-root-relative, forward-slash-separated) -> the
|
|
121
|
+
// tags its most-specific matching `classify` entry names, or undefined if
|
|
122
|
+
// no entry matches at all.
|
|
123
|
+
export function classifyByGlob(path, entries) {
|
|
124
|
+
return mostSpecificMatch(path, entries.map((e) => ({ glob: e.glob, value: e.tags })), sameTags);
|
|
125
|
+
}
|
|
126
|
+
// The nearest directory-name segment (innermost first) matching one of
|
|
127
|
+
// `names` becomes `${tagNamespace}:${name}` - VS Code's own code-layering.ts
|
|
128
|
+
// algorithm: walk the path's directory segments from the file outward, stop
|
|
129
|
+
// at the first recognized name.
|
|
130
|
+
export function classifyByDirectoryName(path, config) {
|
|
131
|
+
if (config === undefined)
|
|
132
|
+
return [];
|
|
133
|
+
const segments = path.split(sep === "\\" ? /\\|\// : "/");
|
|
134
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
135
|
+
if (config.names.includes(segments[i])) {
|
|
136
|
+
return [`${config.tagNamespace}:${segments[i]}`];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
export function classifyFile(path, config) {
|
|
142
|
+
const tags = new Set();
|
|
143
|
+
for (const tag of classifyByGlob(path, config.classify ?? []) ?? [])
|
|
144
|
+
tags.add(tag);
|
|
145
|
+
for (const tag of classifyByDirectoryName(path, config.classifyByDirectoryName))
|
|
146
|
+
tags.add(tag);
|
|
147
|
+
return tags;
|
|
148
|
+
}
|