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.
- 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 +69 -0
- package/CHANGELOG.md +38 -0
- package/README.ja.md +62 -0
- package/README.md +63 -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 +239 -0
- package/dist/config-pointer.js +251 -0
- package/dist/config.js +186 -0
- package/dist/edge-cache.js +530 -0
- package/dist/mcp-server.js +111 -0
- package/dist/module-candidates.js +118 -0
- package/dist/module-graph.js +2072 -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 +417 -0
- package/dist/rules/cycles.js +257 -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/type-leak.js +562 -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/verbs/agents.js +116 -0
- package/dist/verbs/check.js +957 -0
- package/dist/verbs/fix.js +170 -0
- package/dist/verbs/hotspots.js +261 -0
- package/dist/verbs/init.js +522 -0
- package/dist/verbs/recommend.js +800 -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 +163 -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 +128 -0
- package/docs/maintenance.md +82 -0
- package/docs/releasing.md +55 -0
- package/docs/rules-edge-cache.md +50 -0
- package/docs/todo-single-file-migration.md +58 -0
- package/llms.txt +19 -0
- package/package.json +57 -4
- package/skills/archstrict/SKILL.md +42 -0
- package/skills/archstrict/references/agents-verb.md +39 -0
- package/skills/archstrict/references/config.md +107 -0
- package/skills/archstrict/references/hook.md +57 -0
- package/skills/archstrict/references/path-rules.md +57 -0
- package/skills/archstrict/references/patterns.md +883 -0
- package/skills/archstrict/references/prove-rules.md +58 -0
- package/skills/archstrict/references/rearchitect.md +35 -0
- package/skills/archstrict/references/recommend.md +80 -0
- package/skills/archstrict/references/rules.md +146 -0
- package/skills/archstrict/references/simulate.md +109 -0
package/README.md
CHANGED
|
@@ -1,3 +1,64 @@
|
|
|
1
|
-
# archstrict
|
|
1
|
+
# 🧱 archstrict
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](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
|
+
}
|
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
|
+
}
|
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));
|