archstrict 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.agents/hooks/hooks.json +29 -0
  2. package/.agents/hooks/post-tool-use.mjs +107 -0
  3. package/.agents/hooks/pre-tool-use.mjs +182 -0
  4. package/.agents/mcp/server.mjs +71 -0
  5. package/.agents/plugin.json +19 -0
  6. package/AGENTS.md +69 -0
  7. package/CHANGELOG.md +38 -0
  8. package/README.ja.md +62 -0
  9. package/README.md +63 -2
  10. package/dist/augmentation-cache.js +65 -0
  11. package/dist/check-options.js +40 -0
  12. package/dist/classify.js +148 -0
  13. package/dist/cli.js +239 -0
  14. package/dist/config-pointer.js +251 -0
  15. package/dist/config.js +186 -0
  16. package/dist/edge-cache.js +530 -0
  17. package/dist/mcp-server.js +111 -0
  18. package/dist/module-candidates.js +118 -0
  19. package/dist/module-graph.js +2072 -0
  20. package/dist/project-path.js +59 -0
  21. package/dist/report-error.js +13 -0
  22. package/dist/rules/config-meaning.js +143 -0
  23. package/dist/rules/constraints.js +417 -0
  24. package/dist/rules/cycles.js +257 -0
  25. package/dist/rules/deprecated.js +67 -0
  26. package/dist/rules/empty-rule.js +101 -0
  27. package/dist/rules/moves.js +79 -0
  28. package/dist/rules/must-be-empty.js +52 -0
  29. package/dist/rules/public-surface.js +100 -0
  30. package/dist/rules/type-leak.js +562 -0
  31. package/dist/rules/uncovered.js +75 -0
  32. package/dist/todo-migration.js +112 -0
  33. package/dist/todo-store.js +434 -0
  34. package/dist/type-closure.js +959 -0
  35. package/dist/verbs/agents.js +116 -0
  36. package/dist/verbs/check.js +957 -0
  37. package/dist/verbs/fix.js +170 -0
  38. package/dist/verbs/hotspots.js +261 -0
  39. package/dist/verbs/init.js +522 -0
  40. package/dist/verbs/recommend.js +800 -0
  41. package/dist/verbs/rules.js +188 -0
  42. package/dist/verbs/search.js +109 -0
  43. package/dist/verbs/simulate.js +220 -0
  44. package/dist/verbs/todo.js +163 -0
  45. package/dist/warm-graph.js +82 -0
  46. package/docs/boundary-patterns.md +374 -0
  47. package/docs/calibrated-rules-design.md +124 -0
  48. package/docs/init-singleton-modules.md +128 -0
  49. package/docs/maintenance.md +82 -0
  50. package/docs/releasing.md +55 -0
  51. package/docs/rules-edge-cache.md +50 -0
  52. package/docs/todo-single-file-migration.md +58 -0
  53. package/llms.txt +19 -0
  54. package/package.json +57 -4
  55. package/skills/archstrict/SKILL.md +42 -0
  56. package/skills/archstrict/references/agents-verb.md +39 -0
  57. package/skills/archstrict/references/config.md +107 -0
  58. package/skills/archstrict/references/hook.md +57 -0
  59. package/skills/archstrict/references/path-rules.md +57 -0
  60. package/skills/archstrict/references/patterns.md +883 -0
  61. package/skills/archstrict/references/prove-rules.md +58 -0
  62. package/skills/archstrict/references/rearchitect.md +35 -0
  63. package/skills/archstrict/references/recommend.md +80 -0
  64. package/skills/archstrict/references/rules.md +146 -0
  65. package/skills/archstrict/references/simulate.md +109 -0
package/README.md CHANGED
@@ -1,3 +1,64 @@
1
- # archstrict
1
+ # 🧱 archstrict
2
2
 
3
- Reserved. Nothing is published here yet.
3
+ [![npm version](https://img.shields.io/npm/v/archstrict?logo=npm)](https://www.npmjs.com/package/archstrict)
4
+
5
+ arch is architecture, not tsc, not eslint, not a type checker: module boundary checking.
6
+ Not archetype.
7
+
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.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm install --save-dev archstrict
16
+ ```
17
+
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.
19
+
20
+ ### Installing from a local checkout
21
+
22
+ Use one of these instead when working against an unpublished checkout of this repository.
23
+
24
+ 1. **`npm link`, from a local checkout on the same machine.**
25
+
26
+ ```sh
27
+ # in this checkout
28
+ npm run build # if dist/ is missing or stale
29
+ npm link
30
+
31
+ # in the project you want to check
32
+ npm link archstrict
33
+ ```
34
+
35
+ `npm unlink archstrict` in the target project removes it again.
36
+
37
+ 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:
38
+
39
+ ```json
40
+ "archstrict": "file:../archstrict"
41
+ ```
42
+
43
+ `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.
44
+
45
+ 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:
46
+ - 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.
47
+ - 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.
48
+
49
+ 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):
50
+
51
+ ```sh
52
+ # in this checkout
53
+ npm run build
54
+ npm pack # writes archstrict-<version>.tgz
55
+
56
+ # copy the tarball to the target machine, then in the target project
57
+ npm install ./archstrict-<version>.tgz
58
+ ```
59
+
60
+ `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.
61
+
62
+ ---
63
+
64
+ [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
+ }
@@ -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
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ // Responsibility: parse argv and dispatch to a verb (init, check, todo, rules, agents, recommend, fix, simulate, search, hotspots).
3
+ // Boundary: no rule logic here; verbs live in their own modules.
4
+ import { startArchstrictMcpServer } from "./mcp-server.js";
5
+ import { search, formatSearchText } from "./verbs/search.js";
6
+ import { simulate, formatSimulateText } from "./verbs/simulate.js";
7
+ import { agents, formatAgentsText } from "./verbs/agents.js";
8
+ import { recommend, formatRecommendText } from "./verbs/recommend.js";
9
+ import { fix, formatFixText } from "./verbs/fix.js";
10
+ import { init } from "./verbs/init.js";
11
+ import { check, formatText, hasBlockingViolations } from "./verbs/check.js";
12
+ import { todo } from "./verbs/todo.js";
13
+ import { rules, formatRulesText } from "./verbs/rules.js";
14
+ import { hotspots, formatHotspotsText } from "./verbs/hotspots.js";
15
+ import { ReportError } from "./report-error.js";
16
+ import { parseCheckArgv } from "./check-options.js";
17
+ // Text and JSON share one shape: the message, then the one command to run.
18
+ // A violation already carries `do`; a thrown config or missing-file
19
+ // failure goes through here so it does too.
20
+ function reportFailure(error, verb, asJson) {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ const doText = error instanceof ReportError ? error.do : `archstrict ${verb}`;
23
+ if (asJson) {
24
+ process.stdout.write(`${JSON.stringify({ error: message, do: doText })}\n`);
25
+ }
26
+ else {
27
+ process.stderr.write(`archstrict: ${message}\ndo: ${doText}\n`);
28
+ }
29
+ }
30
+ // `--json` is a flag, never read as a directory, so it's stripped before
31
+ // the positional rules apply. Every other argument is a directory, so any
32
+ // other flag-looking argument ("-" prefix) is rejected outright rather
33
+ // than silently accepted as a directory name. More than one positional
34
+ // means the shell expanded an unquoted glob (`src/*` with more than one
35
+ // match) - archstrict cannot tell that apart from a person genuinely
36
+ // typing two directory names, so both read the same way: init takes
37
+ // exactly one.
38
+ function parseInitArgv(argv) {
39
+ const positional = argv.filter((arg) => arg !== "--json");
40
+ for (const arg of positional) {
41
+ if (arg.startsWith("-"))
42
+ throw new ReportError(`unknown option '${arg}'`, "archstrict init");
43
+ }
44
+ if (positional.length > 1) {
45
+ throw new ReportError(`init takes one directory; got ${positional.length} arguments (the shell expands an unquoted * or src/*)`, "archstrict init");
46
+ }
47
+ return positional[0];
48
+ }
49
+ async function runInit(args) {
50
+ const asJson = args.includes("--json");
51
+ const result = await init(process.cwd(), parseInitArgv(args));
52
+ if (asJson) {
53
+ // Field names and shapes follow check's and todo's own --json
54
+ // convention: one object, keys in the same order this prints them.
55
+ // `typesPath` (not `generatedPath`, InitResult's own internal name)
56
+ // matches archstrict.types.ts's own file name, which is what an agent
57
+ // reading this JSON actually needs to find.
58
+ process.stdout.write(`${JSON.stringify({
59
+ configPath: result.configPath,
60
+ typesPath: result.generatedPath,
61
+ configWritten: result.configWritten,
62
+ opened: result.opened === "" ? null : result.opened,
63
+ moduleNames: result.moduleNames,
64
+ hiddenDirs: result.hiddenDirs,
65
+ noiseDirs: result.noiseDirs,
66
+ testFileExcludes: result.testFileExcludes.map((t) => ({ pattern: t.label, exclude: t.exclude, files: t.fileCount })),
67
+ uncovered: result.uncovered.map((g) => ({
68
+ path: g.rel,
69
+ kind: g.kind,
70
+ files: g.fileCount,
71
+ declare: g.entry,
72
+ exclude: g.excludeGlob,
73
+ })),
74
+ notes: result.notes,
75
+ do: result.doText,
76
+ })}\n`);
77
+ return 0;
78
+ }
79
+ for (const line of result.messageLines)
80
+ process.stdout.write(`${line}\n`);
81
+ process.stdout.write(`do: ${result.doText}\n`);
82
+ return 0;
83
+ }
84
+ async function runCheck(args) {
85
+ const parsed = parseCheckArgv(args);
86
+ const result = await check(process.cwd(), parsed.focusFile, {
87
+ prove: parsed.prove,
88
+ rules: parsed.rules,
89
+ modules: parsed.modules,
90
+ frozen: parsed.frozen,
91
+ });
92
+ if (parsed.asJson) {
93
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
94
+ }
95
+ else {
96
+ process.stdout.write(formatText(result));
97
+ }
98
+ return hasBlockingViolations(result) ? 1 : 0;
99
+ }
100
+ async function runTodo(args) {
101
+ const asJson = args.includes("--json");
102
+ const result = await todo(process.cwd());
103
+ if (asJson) {
104
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
105
+ return 0;
106
+ }
107
+ if (result.firstRun) {
108
+ process.stdout.write(`froze ${result.added} violation(s)\n`);
109
+ }
110
+ else {
111
+ process.stdout.write(`pruned ${result.pruned} stale entrie(s)\n`);
112
+ }
113
+ process.stdout.write(`do: archstrict check\n`);
114
+ return 0;
115
+ }
116
+ async function runRules(args) {
117
+ const asJson = args.includes("--json");
118
+ const paths = args.filter((arg) => arg !== "--json");
119
+ if (paths.length !== 1)
120
+ throw new Error("usage: archstrict rules <path> [--json]");
121
+ const result = await rules(process.cwd(), paths[0]);
122
+ process.stdout.write(asJson ? JSON.stringify(result, null, 2) + "\n" : formatRulesText(result));
123
+ return 0;
124
+ }
125
+ function runAgents(args) {
126
+ if (args.some((arg) => arg !== "--json" && arg !== "--remove")) {
127
+ throw new Error("usage: archstrict agents [--remove] [--json]");
128
+ }
129
+ const result = agents(process.cwd(), args.includes("--remove"));
130
+ process.stdout.write(args.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : formatAgentsText(result));
131
+ return 0;
132
+ }
133
+ async function runRecommend(args) {
134
+ const paths = args.filter(arg => arg !== "--json");
135
+ if (paths.length > 1 || paths.some(arg => arg.startsWith("-"))) {
136
+ throw new Error("usage: archstrict recommend [dir] [--json]");
137
+ }
138
+ // Without a config, the directory controls init's own in-memory walk; a
139
+ // declared config supplies its own scope regardless.
140
+ const result = await recommend(process.cwd(), paths[0]);
141
+ process.stdout.write(args.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : formatRecommendText(result));
142
+ return 0;
143
+ }
144
+ async function runFix(args) {
145
+ const paths = args.filter(arg => arg !== "--json" && arg !== "--dry-run");
146
+ if (paths.length > 1 || paths.some(arg => arg.startsWith("-"))) {
147
+ throw new Error("usage: archstrict fix [file] [--dry-run] [--json]");
148
+ }
149
+ const dryRun = args.includes("--dry-run");
150
+ const result = await fix(process.cwd(), paths[0], dryRun);
151
+ process.stdout.write(args.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : formatFixText(result));
152
+ return dryRun || (result.unfixable.length === 0 && result.reverted.length === 0) ? 0 : 1;
153
+ }
154
+ async function runSimulate(args) {
155
+ if (args.some(arg => arg !== "--json" && arg !== "--whole-project")) {
156
+ throw new Error("usage: archstrict simulate [--json] [--whole-project]");
157
+ }
158
+ let input = "";
159
+ process.stdin.setEncoding("utf8");
160
+ for await (const chunk of process.stdin)
161
+ input += chunk;
162
+ const body = JSON.parse(input);
163
+ if (typeof body !== "object" || body === null || !("changes" in body) || !Array.isArray(body.changes)) {
164
+ throw new Error("stdin must contain a JSON object with a changes array");
165
+ }
166
+ const result = await simulate(process.cwd(), body.changes, { wholeProject: args.includes("--whole-project") });
167
+ process.stdout.write(args.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : formatSimulateText(result));
168
+ return result.added.length === 0 ? 0 : 1;
169
+ }
170
+ async function runSearch(args) {
171
+ const query = args.filter(arg => arg !== "--json").join(" ");
172
+ const result = await search(process.cwd(), query);
173
+ process.stdout.write(args.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : formatSearchText(result));
174
+ return 0;
175
+ }
176
+ async function runHotspots(args) {
177
+ let since;
178
+ for (let i = 0; i < args.length; i++) {
179
+ const arg = args[i];
180
+ if (arg === "--json")
181
+ continue;
182
+ if (arg === "--since" && since === undefined && args[i + 1] !== undefined && !args[i + 1].startsWith("--")) {
183
+ since = args[++i];
184
+ continue;
185
+ }
186
+ throw new Error("usage: archstrict hotspots [--since <git ref or date>] [--json]");
187
+ }
188
+ const result = await hotspots(process.cwd(), since);
189
+ process.stdout.write(args.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : formatHotspotsText(result));
190
+ return 0;
191
+ }
192
+ // The connected transport's stdin listener keeps Node alive after this function returns.
193
+ // A real subprocess stayed alive with stdin open and exited when stdin closed;
194
+ // the host can also terminate it. A separate server-closed promise is unnecessary.
195
+ async function runMcp() {
196
+ await startArchstrictMcpServer(process.cwd());
197
+ return 0;
198
+ }
199
+ async function main(argv) {
200
+ const [verb, ...rest] = argv;
201
+ if (verb === undefined) {
202
+ process.stderr.write("usage: archstrict <init|check|todo|rules|agents|recommend|fix|simulate|search|hotspots|mcp> [args]\n");
203
+ return 1;
204
+ }
205
+ try {
206
+ if (verb === "mcp")
207
+ return await runMcp();
208
+ if (verb === "search")
209
+ return await runSearch(rest);
210
+ if (verb === "hotspots")
211
+ return await runHotspots(rest);
212
+ if (verb === "simulate")
213
+ return await runSimulate(rest);
214
+ if (verb === "fix")
215
+ return await runFix(rest);
216
+ if (verb === "recommend")
217
+ return await runRecommend(rest);
218
+ if (verb === "init")
219
+ return await runInit(rest);
220
+ if (verb === "check")
221
+ return await runCheck(rest);
222
+ if (verb === "todo")
223
+ return await runTodo(rest);
224
+ if (verb === "rules")
225
+ return await runRules(rest);
226
+ if (verb === "agents")
227
+ return runAgents(rest);
228
+ process.stderr.write(`archstrict: '${verb}' is not implemented yet\ndo: archstrict init\n`);
229
+ return 1;
230
+ }
231
+ catch (error) {
232
+ // A config or missing-file error throws before any real output.
233
+ // reportFailure prints the message and a do: line — the same pair
234
+ // a violation carries — instead of a bare message or stack trace.
235
+ reportFailure(error, verb, rest.includes("--json"));
236
+ return 1;
237
+ }
238
+ }
239
+ process.exitCode = await main(process.argv.slice(2));