archstrict 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/hooks/hooks.json +29 -0
- package/.agents/hooks/post-tool-use.mjs +107 -0
- package/.agents/hooks/pre-tool-use.mjs +182 -0
- package/.agents/mcp/server.mjs +71 -0
- package/.agents/plugin.json +19 -0
- package/AGENTS.md +81 -0
- package/CHANGELOG.md +77 -0
- package/README.ja.md +142 -0
- package/README.md +143 -2
- package/dist/augmentation-cache.js +65 -0
- package/dist/check-options.js +40 -0
- package/dist/classify.js +148 -0
- package/dist/cli.js +243 -0
- package/dist/config-pointer.js +251 -0
- package/dist/config.js +194 -0
- package/dist/edge-cache.js +530 -0
- package/dist/gitignore.js +271 -0
- package/dist/mcp-server.js +111 -0
- package/dist/module-candidates.js +125 -0
- package/dist/module-graph.js +2179 -0
- package/dist/project-path.js +59 -0
- package/dist/report-error.js +13 -0
- package/dist/rules/config-meaning.js +143 -0
- package/dist/rules/constraints.js +419 -0
- package/dist/rules/cycles.js +285 -0
- package/dist/rules/deprecated.js +67 -0
- package/dist/rules/empty-rule.js +101 -0
- package/dist/rules/moves.js +79 -0
- package/dist/rules/must-be-empty.js +52 -0
- package/dist/rules/public-surface.js +100 -0
- package/dist/rules/uncovered.js +75 -0
- package/dist/todo-migration.js +112 -0
- package/dist/todo-store.js +434 -0
- package/dist/type-closure.js +959 -0
- package/dist/type-leak.js +590 -0
- package/dist/verbs/agents.js +116 -0
- package/dist/verbs/check.js +1011 -0
- package/dist/verbs/fix.js +170 -0
- package/dist/verbs/hotspots.js +261 -0
- package/dist/verbs/init.js +538 -0
- package/dist/verbs/map-shape.js +78 -0
- package/dist/verbs/recommend.js +863 -0
- package/dist/verbs/rules.js +188 -0
- package/dist/verbs/search.js +109 -0
- package/dist/verbs/simulate.js +220 -0
- package/dist/verbs/todo.js +180 -0
- package/dist/warm-graph.js +82 -0
- package/docs/boundary-patterns.md +374 -0
- package/docs/calibrated-rules-design.md +124 -0
- package/docs/init-singleton-modules.md +133 -0
- package/docs/maintenance.md +109 -0
- package/docs/releasing.md +58 -0
- package/docs/rules-edge-cache.md +50 -0
- package/docs/todo-single-file-migration.md +58 -0
- package/llms.txt +25 -0
- package/package.json +61 -4
- package/skills/archstrict/SKILL.md +54 -0
- package/skills/archstrict/references/agents-verb.md +39 -0
- package/skills/archstrict/references/config.md +116 -0
- package/skills/archstrict/references/hook.md +57 -0
- package/skills/archstrict/references/path-rules.md +57 -0
- package/skills/archstrict/references/patterns.md +915 -0
- package/skills/archstrict/references/prove-rules.md +58 -0
- package/skills/archstrict/references/rearchitect.md +66 -0
- package/skills/archstrict/references/recommend.md +98 -0
- package/skills/archstrict/references/rules.md +149 -0
- package/skills/archstrict/references/simulate.md +109 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { ReportError } from "./report-error.js";
|
|
2
|
+
// The only schema loadConfig accepts. init writes this value into a new
|
|
3
|
+
// archstrict.config.ts. A config that omits the field is this same schema
|
|
4
|
+
// (the field arrived after the first configs); any other value is a config
|
|
5
|
+
// error, not a silent misread of a future shape.
|
|
6
|
+
export const SCHEMA_VERSION = 1;
|
|
7
|
+
// Checked before required-field validation: a future schema may rename
|
|
8
|
+
// those fields, and the version mismatch is the fact to report first.
|
|
9
|
+
export function assertSchemaVersion(configPath, raw) {
|
|
10
|
+
if (!("schemaVersion" in raw))
|
|
11
|
+
return;
|
|
12
|
+
const version = raw.schemaVersion;
|
|
13
|
+
if (version === SCHEMA_VERSION)
|
|
14
|
+
return;
|
|
15
|
+
throw new ReportError(`${configPath} schemaVersion ${JSON.stringify(version)} is not supported; this archstrict reads schemaVersion ${SCHEMA_VERSION}`, `set schemaVersion to ${SCHEMA_VERSION} in ${configPath}, then run archstrict check`);
|
|
16
|
+
}
|
|
17
|
+
// Throws if any `deprecated` entry names a module that doesn't exist.
|
|
18
|
+
// Shared by rule 4 and rule 5: without a single shared check, the two
|
|
19
|
+
// rules can disagree about the same config. Measured: rule 4's own
|
|
20
|
+
// zero-modules early return skips its `deprecated` loop entirely, so a
|
|
21
|
+
// `deprecated` entry naming a nonexistent module reached rule 4's "count
|
|
22
|
+
// is 0, edge no longer exists" case instead of a config error — a name
|
|
23
|
+
// that never existed is not the same fact as an edge that used to exist
|
|
24
|
+
// and shrank to nothing, and reporting it that way is misleading, not
|
|
25
|
+
// just imprecise.
|
|
26
|
+
export function assertDeprecatedModulesExist(graph, config) {
|
|
27
|
+
for (const entry of config.deprecated ?? []) {
|
|
28
|
+
for (const moduleName of [entry.from, entry.to]) {
|
|
29
|
+
if (!graph.modules.has(moduleName)) {
|
|
30
|
+
throw new ReportError(`deprecated entry '${entry.from} -> ${entry.to}' names module '${moduleName}', which does not exist`, `declare '${moduleName}' in ${config.configPath}, or remove that deprecated entry, then run archstrict check`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// loadConfig reads a real config file with ts.transpileModule (strips
|
|
36
|
+
// types, never fully type-checks - see loadConfig's own comment for why),
|
|
37
|
+
// so a malformed `edges` value is otherwise invisible to both the type
|
|
38
|
+
// system and every rule: writing `edges` as an array instead of the real
|
|
39
|
+
// `{ allowDeny?, order?, point? }` object produces zero rules, zero
|
|
40
|
+
// violations, and - critically - no empty-rule-set violation either
|
|
41
|
+
// (rule 4 has nothing to see, since no rule was ever parsed into
|
|
42
|
+
// existence), indistinguishable from a config that never used `edges` at
|
|
43
|
+
// all. Measured directly, via a fresh agent authoring a real config from
|
|
44
|
+
// scratch: this was the single silent failure among several very similar
|
|
45
|
+
// ones (an `order` entry's own `sequence` written as a flat array instead
|
|
46
|
+
// of `Record<string, string[]>`, a real, unsupported key mistyped onto a
|
|
47
|
+
// rule entry) - the `sequence` case happens to surface today via rule 4's
|
|
48
|
+
// own `evaluated: 0`, but neither it nor an unsupported key should depend
|
|
49
|
+
// on a downstream rule noticing a side effect. A config shape error is a
|
|
50
|
+
// config error, thrown up front, the same as an unsupported `deprecated`
|
|
51
|
+
// entry already is above.
|
|
52
|
+
function isPlainObject(value) {
|
|
53
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
// Exported: loadConfig's own declaredModules validation (check.ts) needs
|
|
56
|
+
// the same wording for the same kind of shape mismatch, rather than a
|
|
57
|
+
// second, differently-worded describer for the same fact.
|
|
58
|
+
export function describeShape(value) {
|
|
59
|
+
return Array.isArray(value) ? "an array" : typeof value;
|
|
60
|
+
}
|
|
61
|
+
function assertKnownKeys(value, known, context) {
|
|
62
|
+
for (const key of Object.keys(value)) {
|
|
63
|
+
if (!known.includes(key)) {
|
|
64
|
+
throw new ReportError(`${context} has an unknown field '${key}' - supported fields are ${known.join(", ")}`, `remove '${key}' from ${context} in archstrict.config.ts, then run archstrict check`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function assertEntries(value, keys, context) {
|
|
69
|
+
if (value === undefined)
|
|
70
|
+
return [];
|
|
71
|
+
if (!Array.isArray(value)) {
|
|
72
|
+
throw new ReportError(`config.edges.${context} must be an array of entries, not ${describeShape(value)}`, `set config.edges.${context} to an array of entries in archstrict.config.ts, then run archstrict check`);
|
|
73
|
+
}
|
|
74
|
+
return value.map((entry, i) => {
|
|
75
|
+
if (!isPlainObject(entry)) {
|
|
76
|
+
throw new ReportError(`config.edges.${context}[${i}] must be an object, not ${describeShape(entry)}`, `make config.edges.${context}[${i}] an object in archstrict.config.ts, then run archstrict check`);
|
|
77
|
+
}
|
|
78
|
+
assertKnownKeys(entry, keys, `config.edges.${context}[${i}]`);
|
|
79
|
+
return entry;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const ALLOW_DENY_KEYS = [
|
|
83
|
+
"source",
|
|
84
|
+
"targetNamespace",
|
|
85
|
+
"allow",
|
|
86
|
+
"deny",
|
|
87
|
+
"exceptions",
|
|
88
|
+
"edgeType",
|
|
89
|
+
"importForm",
|
|
90
|
+
"because",
|
|
91
|
+
];
|
|
92
|
+
const ORDER_KEYS = ["tagNamespace", "within", "sequence", "direction", "edgeType", "importForm", "because"];
|
|
93
|
+
const POINT_KEYS = ["from", "to", "edgeType", "importForm", "because"];
|
|
94
|
+
export function assertEdgesShapeValid(config) {
|
|
95
|
+
const edges = config.edges;
|
|
96
|
+
if (edges === undefined)
|
|
97
|
+
return;
|
|
98
|
+
if (!isPlainObject(edges)) {
|
|
99
|
+
throw new ReportError(`config.edges must be an object with allowDeny/order/point fields (e.g. { allowDeny: [...] }), not ${describeShape(edges)}`, "set config.edges to an object with allowDeny, order, and point in archstrict.config.ts, then run archstrict check");
|
|
100
|
+
}
|
|
101
|
+
assertKnownKeys(edges, ["allowDeny", "order", "point"], "config.edges");
|
|
102
|
+
for (const entry of assertEntries(edges.allowDeny, ALLOW_DENY_KEYS, "allowDeny")) {
|
|
103
|
+
// These shapes cannot reject any edge, even when coverage is nonzero.
|
|
104
|
+
if (entry.allow === undefined &&
|
|
105
|
+
(entry.deny === undefined || (Array.isArray(entry.deny) && entry.deny.length === 0))) {
|
|
106
|
+
throw new Error(`config.edges.allowDeny entry with source '${entry.source}' and targetNamespace '${entry.targetNamespace}' must specify allow or a non-empty deny list`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
assertEntries(edges.point, POINT_KEYS, "point");
|
|
110
|
+
for (const entry of assertEntries(edges.order, ORDER_KEYS, "order")) {
|
|
111
|
+
const sequence = entry.sequence;
|
|
112
|
+
if (sequence !== undefined && !isPlainObject(sequence)) {
|
|
113
|
+
throw new ReportError(`an edges.order entry's sequence must be an object keyed by the 'within' scope (e.g. { "": ["a", "b"] }), not ${describeShape(sequence)}`, "set that sequence to an object keyed by the within scope in archstrict.config.ts, then run archstrict check");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// compileGlob (classify.ts) only ever special-cases `*` and `**`; every
|
|
118
|
+
// other character - including brace (`{a,b}`), extglob (`+(a|b)`,
|
|
119
|
+
// `@(...)`, `!(...)`, `?(...)`), `?`, and bracket (`[...]`) syntax a shell
|
|
120
|
+
// or a real glob library would treat specially - falls through its own
|
|
121
|
+
// literal branch, escaped for RegExp use. A config author who writes one
|
|
122
|
+
// of those, expecting shell/minimatch semantics, gets a glob that matches
|
|
123
|
+
// nothing: every file it was meant to cover instead surfaces as
|
|
124
|
+
// uncovered-module, silently, with no hint the glob itself was the
|
|
125
|
+
// problem. Caught here, once, for every field a glob can appear in,
|
|
126
|
+
// rather than as a downstream "why is this file uncovered" mystery.
|
|
127
|
+
//
|
|
128
|
+
// A bare `+` or `@` is left alone (both appear in ordinary literal paths -
|
|
129
|
+
// a scoped package directory name, a filename with a `+` in it); only the
|
|
130
|
+
// bracket/brace/question-mark/bang characters below are checked, and `(`
|
|
131
|
+
// alone already catches the extglob forms (`+(`, `@(`, `!(`, `?(`) without
|
|
132
|
+
// needing to special-case them.
|
|
133
|
+
const UNSUPPORTED_GLOB_PATTERN = /[{}()[\]?!]/;
|
|
134
|
+
function assertGlobSupported(configPath, field, glob, verb) {
|
|
135
|
+
// A non-string value here is a different validator's problem (shape
|
|
136
|
+
// checks above, or the field's own type in Config) - this check only
|
|
137
|
+
// ever looks at strings that already made it this far.
|
|
138
|
+
if (typeof glob !== "string" || !UNSUPPORTED_GLOB_PATTERN.test(glob))
|
|
139
|
+
return;
|
|
140
|
+
throw new ReportError(`${configPath} field '${field}' has an unsupported glob '${glob}' - only '*' (any characters within one path segment) and '**' (any depth, including zero segments) are supported; '{', '}', '(', ')', '[', ']', '?', and '!' all match nothing, including in an extglob form like '+(...)' or '@(...)'`, `rewrite '${field}' in ${configPath} using only * and **, or split it into one entry per directory, in archstrict.config.ts, then run ${verb}`);
|
|
141
|
+
}
|
|
142
|
+
// One entry per glob-bearing field the config schema has (see Config's own
|
|
143
|
+
// fields above). Walked defensively (typeof/Array.isArray guards, not the
|
|
144
|
+
// Config type) because loadConfig calls this on a value ts.transpileModule
|
|
145
|
+
// only stripped types from, never type-checked - a field can hold any
|
|
146
|
+
// runtime shape a hand-written config puts there.
|
|
147
|
+
export function assertGlobsSupported(config, verb) {
|
|
148
|
+
const configPath = config.configPath;
|
|
149
|
+
for (const [i, glob] of (config.exclude ?? []).entries()) {
|
|
150
|
+
assertGlobSupported(configPath, `exclude[${i}]`, glob, verb);
|
|
151
|
+
}
|
|
152
|
+
for (const [i, entry] of (config.classify ?? []).entries()) {
|
|
153
|
+
assertGlobSupported(configPath, `classify[${i}].glob`, entry?.glob, verb);
|
|
154
|
+
}
|
|
155
|
+
for (const [i, entry] of (config.mustBeEmpty ?? []).entries()) {
|
|
156
|
+
assertGlobSupported(configPath, `mustBeEmpty[${i}].glob`, entry?.glob, verb);
|
|
157
|
+
}
|
|
158
|
+
for (const [i, mod] of (config.declaredModules ?? []).entries()) {
|
|
159
|
+
const glob = mod?.glob;
|
|
160
|
+
if (Array.isArray(glob)) {
|
|
161
|
+
for (const [j, entry] of glob.entries()) {
|
|
162
|
+
assertGlobSupported(configPath, `declaredModules[${i}].glob[${j}]`, entry, verb);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
assertGlobSupported(configPath, `declaredModules[${i}].glob`, glob, verb);
|
|
167
|
+
}
|
|
168
|
+
const surface = mod?.surface;
|
|
169
|
+
if (Array.isArray(surface)) {
|
|
170
|
+
for (const [j, s] of surface.entries()) {
|
|
171
|
+
assertGlobSupported(configPath, `declaredModules[${i}].surface[${j}]`, s, verb);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else if (surface !== undefined) {
|
|
175
|
+
assertGlobSupported(configPath, `declaredModules[${i}].surface`, surface, verb);
|
|
176
|
+
}
|
|
177
|
+
for (const [j, friend] of (mod?.friends ?? []).entries()) {
|
|
178
|
+
assertGlobSupported(configPath, `declaredModules[${i}].friends[${j}].file`, friend?.file, verb);
|
|
179
|
+
assertGlobSupported(configPath, `declaredModules[${i}].friends[${j}].from`, friend?.from, verb);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const [i, rule] of (config.edges?.allowDeny ?? []).entries()) {
|
|
183
|
+
for (const [j, exception] of (rule?.exceptions ?? []).entries()) {
|
|
184
|
+
assertGlobSupported(configPath, `edges.allowDeny[${i}].exceptions[${j}].from`, exception?.from, verb);
|
|
185
|
+
assertGlobSupported(configPath, `edges.allowDeny[${i}].exceptions[${j}].to`, exception?.to, verb);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// point's from/to are each either a glob (string) or a tag predicate
|
|
189
|
+
// (an object) - only the string form is a glob this check applies to.
|
|
190
|
+
for (const [i, rule] of (config.edges?.point ?? []).entries()) {
|
|
191
|
+
assertGlobSupported(configPath, `edges.point[${i}].from`, rule?.from, verb);
|
|
192
|
+
assertGlobSupported(configPath, `edges.point[${i}].to`, rule?.to, verb);
|
|
193
|
+
}
|
|
194
|
+
}
|