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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: archstrict
|
|
3
|
+
description: Use when a project has an archstrict.config.ts, or when the user names archstrict, a module's public surface, a module boundary, `archstrict check`, `archstrict init`, `archstrict todo`, a todo freeze, a strict module, a type leak, a deprecated edge, or the archstrict PostToolUse hook. Covers writing or changing a module's public-surface file, reading a violation report (rule id, path:line:col, evidence, because, do), freezing known violations into the project's todo file, and troubleshooting a violation the hook or `check` reported.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# archstrict
|
|
7
|
+
|
|
8
|
+
TypeScript module boundary checking: architecture linting. It does not check types or code style.
|
|
9
|
+
|
|
10
|
+
A module is one directory, or one file when its glob names that file, declared explicitly in `declaredModules` (never discovered from directory structure at runtime). A module shows the rest of the codebase one file, named by the root config's own `surface` field (default one entry per analyzed source extension: `index.ts`, `index.tsx`, `index.mts`, `index.cts`); anything a module does not export from that file is private, and an import that reaches past it into the module's internals is a violation. A module with no public-surface file present is entirely private. A file can also carry tags (`classify`/`classifyByDirectoryName`) independent of module membership, and a constraint engine (`edges`) checks edges between tags - domain/layer/plane boundaries generalized over tags instead of module names. Shape (scope, exclude, classify, declaredModules, edges, the surface file name) lives in one root file, `archstrict.config.ts`, written as a plain TypeScript value satisfying the generated `Config` type - never scattered per module.
|
|
11
|
+
|
|
12
|
+
Every violation carries a rule id, `path:line:col`, the `evidence` (what was found), the `because` reason, a structured `config` pointer, and a `do:` command - enough to fix the mistake without asking. Read `config.pointer` to find the config value without parsing `evidence` or `do`. A violation that needs two config locations uses a `config` array, with the entry that fired first. Every rule, in full: [references/rules.md](references/rules.md).
|
|
13
|
+
|
|
14
|
+
## Workflow
|
|
15
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
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. 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
|
+
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
|
+
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
|
+
"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).
|
|
26
|
+
5. **`strict` a module** (in `archstrict.config.ts`'s `strict: string[]`) once it has no todo entries of its own, to keep it that way: a strict module can never gain a new entry, and any existing one is itself a violation (`clean-module-has-todo`) - staying clean means no debt, not debt frozen at whatever existed when the module was marked.
|
|
27
|
+
6. **The PostToolUse hook** runs automatically after an Edit/Write/MultiEdit on a TypeScript source file (`.ts`, `.tsx`, `.mts`, `.cts`), if this project has archstrict installed (`node_modules/.bin/archstrict`) - no separate step. It says nothing when the edited file has no violation or archstrict isn't installed here at all. Troubleshooting: [references/hook.md](references/hook.md).
|
|
28
|
+
|
|
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
|
+
|
|
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
|
+
|
|
43
|
+
## Reading a violation
|
|
44
|
+
|
|
45
|
+
`path` is always an absolute path (`/path/to/project/src/app/importer.ts` here, abbreviated below):
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
[public-surface-bypass] .../src/app/importer.ts:1:24
|
|
49
|
+
'../shared/module.ts' resolved to module 'shared', which has no index.ts, index.tsx, index.mts, index.cts
|
|
50
|
+
because: a module's public surface is its only public surface; everything else is private
|
|
51
|
+
do: add one of index.ts, index.tsx, index.mts, index.cts to shared/ naming what it exports
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`evidence` says what was found; `do` says the one thing to do about it. Do the thing `do` says, not a broader refactor - a rule flags exactly the edge, property, or entry it names, nothing implied beyond it.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Install agent instructions
|
|
2
|
+
|
|
3
|
+
Run `archstrict agents [--json]` from the project root to add the archstrict section to `AGENTS.md`.
|
|
4
|
+
The section tells agents to run `archstrict rules <path>` before creating files or adding imports, and `archstrict check` after editing.
|
|
5
|
+
It applies only when `archstrict.config.ts` exists.
|
|
6
|
+
The command itself does not require or read that config.
|
|
7
|
+
|
|
8
|
+
The fixed section sits between `<!-- ARCHSTRICT_START -->` and `<!-- ARCHSTRICT_END -->`.
|
|
9
|
+
It contains command guidance, not module names, tags, or configured constraints.
|
|
10
|
+
Changing the project config does not change the section.
|
|
11
|
+
|
|
12
|
+
The command creates a missing file, appends to an unmarked file, or replaces the existing marked section.
|
|
13
|
+
Replacement preserves every byte outside the markers.
|
|
14
|
+
Repeated installation leaves the same file contents.
|
|
15
|
+
Appending retains existing whitespace and adds a blank-line separator before the new section.
|
|
16
|
+
|
|
17
|
+
Run `archstrict agents --remove` to remove the section and its separator.
|
|
18
|
+
Other content remains intact. Removing from a missing or unmarked file does not change it.
|
|
19
|
+
A file containing only the section becomes empty; it is not deleted.
|
|
20
|
+
Incomplete or duplicate markers produce an error without changing the file.
|
|
21
|
+
|
|
22
|
+
`AGENTS.md` can be a symbolic link or a chain of links, including a link whose target does not exist yet.
|
|
23
|
+
The command follows the links and writes the resolved target, preserving the links themselves.
|
|
24
|
+
It creates missing target directories when installing. A link cycle produces an error.
|
|
25
|
+
Other agent instruction files are not separate write targets; if they share the same target, they see its updated content.
|
|
26
|
+
|
|
27
|
+
Text output is one confirmation line.
|
|
28
|
+
JSON output has `state` and `path`, where `path` names the resolved target:
|
|
29
|
+
|
|
30
|
+
| State | Result |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `created` | Created the target with the section. |
|
|
33
|
+
| `appended` | Added the section after existing content. |
|
|
34
|
+
| `replaced` | Updated an existing section, or kept an identical section. |
|
|
35
|
+
| `removed` | Removed the section. |
|
|
36
|
+
| `already-absent` | The target does not exist; no change. |
|
|
37
|
+
| `no-markers` | The target has no section; no change. |
|
|
38
|
+
|
|
39
|
+
Successful commands exit 0. Errors exit 1 and use `{ "error": "<message>" }` with `--json`.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# archstrict.config.ts
|
|
2
|
+
|
|
3
|
+
Written as a plain TypeScript value satisfying the generated `Config` type (`archstrict.types.ts`, itself rewritten by every `archstrict init`). One root file - shape is never scattered per module.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import type { Config } from "./archstrict.types.js";
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
schemaVersion: 1,
|
|
10
|
+
surface: ["index.ts", "index.tsx", "index.mts", "index.cts"],
|
|
11
|
+
exclude: ["archstrict.config.ts", "archstrict.types.ts", ".*/**", "**/.*/**"],
|
|
12
|
+
declaredModules: [
|
|
13
|
+
{ name: "app", glob: "src/app/**" },
|
|
14
|
+
{ name: "shared", glob: "src/shared/**" },
|
|
15
|
+
{ name: "cli.ts", glob: "src/cli.ts", surface: "cli.ts" },
|
|
16
|
+
],
|
|
17
|
+
because: "archstrict init: one module per directory that holds TypeScript source and per TypeScript source file, so the first check covers every file it analyzes",
|
|
18
|
+
} satisfies Config;
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`declaredModules` is the only source of module boundaries - `check`/`todo` never discover modules from directory structure at runtime; only `archstrict init`'s own one-time walk does, to suggest what to declare. A file matching no `declaredModules` entry is `outsideFiles` (metric) and an `uncovered-module` violation (rule 3) unless it's `exclude`d.
|
|
22
|
+
|
|
23
|
+
Fields:
|
|
24
|
+
|
|
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
|
+
- **`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
|
+
- **`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.
|
|
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).
|
|
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).
|
|
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).
|
|
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.
|
|
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
|
+
|
|
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.
|
|
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.
|
|
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.
|
|
46
|
+
- **`edges`** (optional) - the constraint engine (rule 7: `tag-boundary`/`tag-order`/`point-rule`), each shape generalizing rules 1/2's fixed module vocabulary to tags. See [rules.md](rules.md#7-tag-boundary--tag-order--point-rule-the-constraint-engine) for the full shape of `allowDeny`, `order`, and `point`, and what each one's violation looks like. Every configured `edges` rule's own real, evaluated-edge count is in `check`'s own `edgeRuleCoverage` field, and a rule that evaluates zero is also reported as rule 4's `empty-rule-set` - see [rules.md](rules.md#4-empty-rule-set) for why a zero here needs the same scrutiny a genuinely clean pass does.
|
|
47
|
+
- **`deprecated`** (optional) - `{ from, to, count, because }[]`, a `from -> to` module edge whose actual count must never increase. `because` is mandatory: a deprecated edge names a real design tradeoff, and a root-level rule with no stated reason is a decision no future reader can judge.
|
|
48
|
+
- **`strict`** (optional) - module names whose todo entries may only shrink, never gain a new one, not even on `todo`'s first run. `check` reports any existing entry for a strict module as its own violation (`clean-module-has-todo`) - marking a module strict never hides a violation, old or new.
|
|
49
|
+
- **`ignoredCycles`** (optional) - `readonly [string, string][]`, e.g. `[["a", "b"]]`. Names any two modules of a known cycle, in either order; exempts the whole strongly-connected component they belong to from rule 2 (`cycle`), not just that one edge. A pair matching no real cycle at all is itself flagged (`stale-cycle-exception`) - remove it rather than leave it.
|
|
50
|
+
- **`mustBeEmpty`** (optional) - `{ glob, because }[]`. A directory a project decided must hold no code at all. A violation is any file matching the glob - zero matches is a clean pass, not silence. The glob is project-root-relative. See [rules.md](rules.md#must-be-empty).
|
|
51
|
+
- **`because`** (required) - the config's own reason for its shape as a whole (the preset choice, the module boundaries). Same reasoning as `deprecated`'s own `because`: a decision with no stated reason is one nobody later can judge.
|
|
52
|
+
|
|
53
|
+
A `classify` glob matching zero real files, or zero `declaredModules` entries at all, is a reported violation (rule 4, `empty-rule-set`), not a thrown error - `check` still runs and reports everything else it can. A `schemaVersion` other than `1` is a thrown config error, validated up front before any rule runs. A `deprecated` entry naming a module that doesn't exist is a thrown config error, validated up front the same way. An `order` rule's `sequence` missing a layer value classify actually assigns within a scope it does cover is also a thrown config error, but checked lazily instead - only once `checkOrder` walks an edge that actually carries the missing value, not before any rule runs. `edges` itself not being a plain object with only `allowDeny`/`order`/`point` keys, an `order` entry's own `sequence` not being a plain object, or an unknown field on any `allowDeny`/`order`/`point` entry, are each thrown config errors too, validated up front the same way - a project's own `archstrict.config.ts` may only ever import types from `archstrict.types.ts` (`import type`, never `import`), so nothing else validates this shape for you, not even `tsc`, unless a project separately runs it over the config file itself.
|
|
54
|
+
|
|
55
|
+
## Glob syntax
|
|
56
|
+
|
|
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.
|
|
58
|
+
|
|
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.
|
|
60
|
+
|
|
61
|
+
## `edges`'s own shape
|
|
62
|
+
|
|
63
|
+
Each `allowDeny` entry must specify `allow` or `deny`.
|
|
64
|
+
When `allow` is absent, `deny` must contain at least one value.
|
|
65
|
+
Config loading rejects entries that omit both lists or specify only `deny: []`, before any rule runs.
|
|
66
|
+
The error identifies the entry by its `source` and `targetNamespace`.
|
|
67
|
+
An empty `allow: []` remains valid: it rejects every target value in the selected namespace.
|
|
68
|
+
When both lists are present, `allow` retains precedence.
|
|
69
|
+
|
|
70
|
+
This shape check cannot detect an `allow` list that covers every target tag value present in the project.
|
|
71
|
+
That case depends on project data; static validation cannot distinguish an intended restriction from a list that happens to cover all current values.
|
|
72
|
+
|
|
73
|
+
`edges` is one object with up to three named lists, not a single array of rule entries - easy to misread from a prose description of each rule shape alone:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
edges: {
|
|
77
|
+
allowDeny: [
|
|
78
|
+
{ source: "domain:sql", targetNamespace: "domain", allow: ["framework"], because: "..." },
|
|
79
|
+
],
|
|
80
|
+
order: [
|
|
81
|
+
{
|
|
82
|
+
tagNamespace: "layer",
|
|
83
|
+
within: "domain", // omit `within` entirely for an unscoped rule
|
|
84
|
+
sequence: {
|
|
85
|
+
// one key per REAL `within` value classify assigns - "" is the
|
|
86
|
+
// literal key for a rule with no `within` at all, not a placeholder
|
|
87
|
+
sql: ["core", "runtime", "adapters"],
|
|
88
|
+
},
|
|
89
|
+
direction: "downward-only",
|
|
90
|
+
because: "...",
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
point: [
|
|
94
|
+
{ from: "packages/**", to: "test/**", because: "..." },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`sequence` is `Record<string, string[]>`, never a flat `string[]` - see [rules.md](rules.md#7-tag-boundary--tag-order--point-rule-the-constraint-engine) for what each key means. `edgeType`/`importForm` exist on all three of `allowDeny`, `order`, and `point`, with the same default (`"both"`) and the same semantics on each.
|
|
100
|
+
|
|
101
|
+
## tsconfig.json resolution
|
|
102
|
+
|
|
103
|
+
Every import is resolved using the nearest `tsconfig.json` to the importing file (walking up from that file's own directory, the same convention TypeScript itself follows for a real per-package override) - not always the project root's own config, so a monorepo package with its own `paths` alias, `jsx` setting, or `moduleResolution` override resolves the way that package's own build actually does, instead of inflating `unresolvedSpecifiers` for every aliased import in it (measured directly against a real per-package alias: 58 fewer false-unresolved specifiers in one package alone, out of 152). This is scoped to module resolution only - the single, shared `ts.Program` every module is checked against (rule 6's `TypeChecker`) still uses the project root's own compiler options for the whole project; a leaf package's own incompatible option (a different `target`, a different `jsx` mode) can still affect how `ts.createProgram` itself sees that package's files, independent of this fix. Mixing genuinely incompatible per-file compiler options into one shared checked program is a separate, larger question this does not attempt to solve.
|
|
104
|
+
|
|
105
|
+
When `unresolvedSpecifiers` (the plain count) is nonzero, `check`'s own `unresolvedSpecifierBreakdown` names which specifiers - the top 10 distinct prefixes, most frequent first, each with its own count. A bare or unscoped specifier (`lodash`, `lodash/fp`) groups by its own first path segment (`lodash`); a scoped specifier (`@scope/name` or any of its own subpaths, `@scope/name/sub-path`) groups by `@scope/name` together, since a scope alone would merge every unrelated package under it into one meaningless bucket, and a bare package name doesn't distinguish its own subpaths from each other the way a scoped one's would. Text output prints the same breakdown as one summary line right under the plain count, omitted entirely when nothing is unresolved.
|
|
106
|
+
|
|
107
|
+
## Persistent graph cache
|
|
108
|
+
|
|
109
|
+
`check` (and `todo`, `rules`, `recommend`, `fix`'s own baseline, and `search`) keeps a per-file
|
|
110
|
+
cache in `node_modules/.cache/archstrict/` in the analyzed project, so a repeat run reparses only a
|
|
111
|
+
changed or new file and re-resolves only when a real input to resolution has moved. Two limits: an
|
|
112
|
+
edit that keeps a file's exact byte size and whose mtime is restored (or never advances) is not
|
|
113
|
+
detected, and an edit made directly to an already-installed dependency's own file, leaving its
|
|
114
|
+
package.json untouched, is not detected either. Deleting `node_modules/.cache/archstrict` clears
|
|
115
|
+
the cache and forces a full, cold rebuild on the next run - always safe, never required for
|
|
116
|
+
correctness otherwise.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# The PreToolUse and PostToolUse hooks
|
|
2
|
+
|
|
3
|
+
This plugin ships two hooks around every Edit/Write/MultiEdit: `PreToolUse` (`.agents/hooks/pre-tool-use.mjs`) runs before the write happens, and `PostToolUse` (`.agents/hooks/post-tool-use.mjs`) runs right after. Claude invokes them as `${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use.mjs` and `${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use.mjs`; repo-root `hooks/` is a symlink to `.agents/hooks`. Both shell out to **the edited project's own** `node_modules/.bin/archstrict` - never this repository's own build.
|
|
4
|
+
|
|
5
|
+
## PreToolUse: a preview before the write
|
|
6
|
+
|
|
7
|
+
On a TypeScript source file (`.ts`, `.tsx`, `.mts`, `.cts`) edited with Edit, Write, or MultiEdit, the hook builds the file text the tool call would produce, without writing it to disk:
|
|
8
|
+
|
|
9
|
+
- Write uses `tool_input.content` directly.
|
|
10
|
+
- Edit reads the file's current text and applies `old_string` -> `new_string`, honoring `replace_all`.
|
|
11
|
+
- MultiEdit applies each edit in `tool_input.edits` in order, on top of the file's current text.
|
|
12
|
+
|
|
13
|
+
It then runs `node_modules/.bin/archstrict simulate --json` with that one change on stdin, under a time budget (10 s by default; set `ARCHSTRICT_PRETOOLUSE_TIMEOUT_MS` to change it, in milliseconds). When the change adds a violation, the hook allows the edit and returns the added violations as `hookSpecificOutput.additionalContext` - the same compact form the PostToolUse hook prints (rule, `path:line:col`, evidence, `because:`, the violation's `config:` pointer line, and `do:`), bounded to a few violations with a count of the rest. A resolved violation, if any, is mentioned in one summary line.
|
|
14
|
+
|
|
15
|
+
Set `ARCHSTRICT_PRETOOLUSE=deny` to make the hook deny the tool call instead, with the same text as `permissionDecisionReason`. The default is allow-with-context: an agent mid-refactor may write an intermediate state on purpose, and a hard deny would block that.
|
|
16
|
+
|
|
17
|
+
### Why it might say nothing
|
|
18
|
+
|
|
19
|
+
Every one of these is silent by design, not a failure:
|
|
20
|
+
|
|
21
|
+
- The edited file isn't `.ts`, or the tool wasn't an Edit/Write/MultiEdit.
|
|
22
|
+
- An `old_string` in the Edit or MultiEdit call isn't found in the file's current text - the tool call itself will report that failure, so the hook doesn't guess at it.
|
|
23
|
+
- The project has no `node_modules/.bin/archstrict` at all.
|
|
24
|
+
- `simulate --json` times out, crashes, or reports a config or input error (`{ "error": "..." }`) - unlike the PostToolUse hook, a broken project config stays silent here rather than being reported, because this hook runs before every edit; reporting it would interrupt every tool call instead of just the one edit that caused it.
|
|
25
|
+
- The change adds no violation, even when it resolves one.
|
|
26
|
+
|
|
27
|
+
### Double reporting
|
|
28
|
+
|
|
29
|
+
When the PreToolUse hook reports a violation for a file, the PostToolUse hook for that same edit may still report it once the write actually happens. The two hooks don't share state and don't suppress each other: an agent may see the same violation twice for one edit, once as a preview and once as confirmation that the write landed as previewed.
|
|
30
|
+
|
|
31
|
+
## PostToolUse: confirmation after the write
|
|
32
|
+
|
|
33
|
+
On a TypeScript source file, this hook shells out to `node_modules/.bin/archstrict check <file> --json` and returns any violation into the agent's own context via `hookSpecificOutput.additionalContext`, the same moment a human editor's red squiggly would appear.
|
|
34
|
+
|
|
35
|
+
### Why it might say nothing
|
|
36
|
+
|
|
37
|
+
Every one of these is silent by design, not a failure:
|
|
38
|
+
|
|
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](https://github.com/meganemura/archstrict#install) for how to actually install the package into that project.
|
|
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
|
+
|
|
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.
|
|
44
|
+
|
|
45
|
+
### The `todo` field is scoped too
|
|
46
|
+
|
|
47
|
+
`check <file>`'s own `todo` count (JSON) or `todo:` line (text) counts only the frozen violations reported at that one file - not every frozen violation in the project. A frozen violation elsewhere is real, and a plain `check` (no file argument) still counts it; this run just never evaluated it, the same way its own `typeLeaks: null` reports "not evaluated" rather than a project-wide fact whenever rule 6 is skipped.
|
|
48
|
+
|
|
49
|
+
### Why it might report "check did not run"
|
|
50
|
+
|
|
51
|
+
When `archstrict check --json` reports `{ "error": "...", "do": "..." }` instead of a real result, or exits with no output at all - `check` exiting 1 with violations present is expected, normal output, read as data, not this case. A config error (`archstrict.config.ts` missing a required field, a `schemaVersion` other than `1`, a `deprecated` entry naming a module that doesn't exist, `check <file>` naming a file that doesn't exist) reports this way; the message names which one, and `do` names the command to run. The hook includes that `do` line in the context it returns.
|
|
52
|
+
|
|
53
|
+
`archstrict init` itself, not the hook, reports its own errors when a project has no analyzed TypeScript source file to declare at all (`init` writes neither file and exits 1), or its own directory argument names something init won't open (a glob character, a nested path, a hidden name, `node_modules`, `dist`, or an explicit directory that doesn't exist or holds no TypeScript source) - each names what's wrong and the one command to run next.
|
|
54
|
+
|
|
55
|
+
## Path resolution
|
|
56
|
+
|
|
57
|
+
Both hooks use the hook payload's own `cwd` (the session's project directory), joined with `node_modules/.bin/archstrict` - matching how npm itself installs a package's binary. Neither searches `PATH`, a global install, or a parent directory's `node_modules`.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Plan a file with `archstrict rules`
|
|
2
|
+
|
|
3
|
+
Run `archstrict rules <path> [--json]` from the project root before creating a file or adding imports.
|
|
4
|
+
The path can refer to an existing file or a file that does not exist yet.
|
|
5
|
+
Relative paths resolve from the current directory. Paths outside the project root produce an error.
|
|
6
|
+
|
|
7
|
+
The command loads `archstrict.config.ts` and builds the project graph, as `check` does.
|
|
8
|
+
Existing files use the graph's actual module membership and surface files.
|
|
9
|
+
Proposed files use the configured module and surface globs.
|
|
10
|
+
The query does not create the proposed file.
|
|
11
|
+
|
|
12
|
+
The result reports:
|
|
13
|
+
|
|
14
|
+
- `path`: the absolute path, with existing directory symlinks resolved.
|
|
15
|
+
- `exists` and `excluded`: whether the path exists and matches a configured exclude glob.
|
|
16
|
+
- `module`, `tags`, and `isSurfaceFile`: module membership, sorted classification tags, and public surface status.
|
|
17
|
+
- `importableFrom`: other modules' existing public surface files, ordered by module name.
|
|
18
|
+
- `friendAccess`: friend entries whose `from` glob matches the queried importer path, including their reasons.
|
|
19
|
+
Each `file` is the graph's project-relative file glob.
|
|
20
|
+
- `mustBeEmptyViolation` and `uncoveredViolation`: the same violation objects used by `check`, when applicable.
|
|
21
|
+
|
|
22
|
+
Excluded paths retain descriptive information, but it does not apply to architecture checks.
|
|
23
|
+
Their violation fields are unset, and text output starts with an out-of-scope notice.
|
|
24
|
+
JSON omits fields whose values are undefined, including `module` when membership is unresolved.
|
|
25
|
+
|
|
26
|
+
`importableFrom` lists public entry points; the following projections describe restrictions on imports from the queried path:
|
|
27
|
+
|
|
28
|
+
- `allowDenyConstraints` includes entries whose source tag matches the path.
|
|
29
|
+
It copies allow and deny lists, reports edge filters, and sets `sameGroupExempt: true`.
|
|
30
|
+
A target with the same source tag is exempt from that rule.
|
|
31
|
+
`exceptionsFromP` lists matching importer exceptions; each target must still match its `to` glob for the exception to apply.
|
|
32
|
+
- `orderConstraints` reports the path's own layer and the sequence for its scope.
|
|
33
|
+
`rules` and `check` select the same layer and scope when a path has multiple tags in one namespace.
|
|
34
|
+
`mayDependOn` contains the sequence prefix through that layer, inclusive, following downward-only order.
|
|
35
|
+
Missing layer tags, missing scope tags, and undeclared scope sequences omit the rule.
|
|
36
|
+
A layer missing from a declared sequence produces the same configuration error as check, before an import exists.
|
|
37
|
+
- `pointConstraints` lists matching source predicates, their identifiers, and `forbiddenTo` predicates.
|
|
38
|
+
Glob predicates remain strings; tag predicates use JSON serialization, as point-rule reports do.
|
|
39
|
+
|
|
40
|
+
Each projection includes `edgeType`, `importForm`, and `because`; omitted filters appear as `both`.
|
|
41
|
+
Text output gives each constraint kind its own section and prints `(none)` for an empty projection.
|
|
42
|
+
Excluded paths still show descriptive projections under the out-of-scope notice.
|
|
43
|
+
A matching order rule can report an unlisted layer even for a proposed or excluded path.
|
|
44
|
+
These projections use source information; target tags, exception targets, and actual import forms still determine whether a particular edge violates a rule.
|
|
45
|
+
Run `archstrict check` after editing to evaluate actual imports.
|
|
46
|
+
|
|
47
|
+
For example:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
archstrict rules src/feature/new-thing.ts
|
|
51
|
+
archstrict rules src/feature/new-thing.ts --json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Text output uses `key: value` lines and includes each violation's original `evidence`, `because`, structured `config` location, and `do` fields.
|
|
55
|
+
The must-be-empty violation uses a project-relative path, as `check` does; the uncovered violation uses an absolute path.
|
|
56
|
+
A successful query exits with code 0, including when it reports a potential violation.
|
|
57
|
+
Argument, configuration, and outside-root errors exit with code 1; `--json` errors use `{ "error": "<message>" }`.
|