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
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// Responsibility: store and validate a per-file, incrementally-updatable
|
|
2
|
+
// snapshot of each analyzed file's own syntactic import walk, module
|
|
3
|
+
// augmentations, and resolved specifiers. Shards avoid whole-cache I/O.
|
|
4
|
+
// Boundary: cache failures fall back to analysis; this module never parses
|
|
5
|
+
// a file or resolves an import itself - module-graph.ts owns both, and
|
|
6
|
+
// hands this module only the results to persist or read back.
|
|
7
|
+
//
|
|
8
|
+
// On-disk layout: one header file (`edges.json`, at the path the caller
|
|
9
|
+
// passes) plus a fixed SHARD_COUNT of shard files beside it, under an
|
|
10
|
+
// `edges/` directory. A file's own shard is `shardIndexForRelativePath` of
|
|
11
|
+
// its path relative to the project root - stable across runs and across
|
|
12
|
+
// which files happen to exist, so a build only ever touches the shards
|
|
13
|
+
// whose own membership or content actually changed. The header records
|
|
14
|
+
// each shard's file name and a sha256 of its exact on-disk bytes; a reader
|
|
15
|
+
// that finds a shard missing, unreadable, or hash-mismatched treats only
|
|
16
|
+
// that shard's own files as a cache miss (re-walked and re-resolved by
|
|
17
|
+
// module-graph.ts's own per-file loop) - never the rest of the cache, and
|
|
18
|
+
// never an error.
|
|
19
|
+
//
|
|
20
|
+
// Each shard's own JSON is a compact encoding, not one object per file:
|
|
21
|
+
// a `paths` string table (each path stored relative to the project root,
|
|
22
|
+
// via node:path's own relative result - reversible for a resolved file
|
|
23
|
+
// outside the root too, since the result can carry leading `..` segments),
|
|
24
|
+
// a `specifiers` string table, a
|
|
25
|
+
// `packageNames` string table, and one array-of-indexes tuple per file
|
|
26
|
+
// (never an object keyed by that file's own path). Each file's own
|
|
27
|
+
// `resolutions` are encoded inline, aligned by position with that file's
|
|
28
|
+
// own `imports` (never as a second object keyed by specifier) - a `null`
|
|
29
|
+
// slot is exactly the imports this project's own walk never gives a
|
|
30
|
+
// resolutions entry to (a node builtin), not "unresolved" (which encodes
|
|
31
|
+
// as `0`, distinct from `null`).
|
|
32
|
+
//
|
|
33
|
+
// Correctness contract - every input that can change a resolved edge, and
|
|
34
|
+
// where it is covered:
|
|
35
|
+
// - A file's own text (syntax, imports, exports, `require(...)`) - covered
|
|
36
|
+
// by that file's own mtimeMs+size (module-graph.ts's own reparse gate).
|
|
37
|
+
// Limit: an edit that keeps the exact same byte size AND whose mtime is
|
|
38
|
+
// restored (or never advances - some filesystems and some tools truncate
|
|
39
|
+
// mtime precision) is invisible to this gate; nothing else in this
|
|
40
|
+
// fingerprint covers it either.
|
|
41
|
+
// - The nearest package.json "type" a file resolves under (decides
|
|
42
|
+
// ESM-vs-CJS `impliedNodeFormat`, which each of that file's own imports'
|
|
43
|
+
// `mode` is derived from) - covered by each file's own `impliedNodeFormat`
|
|
44
|
+
// field, recomputed every build with no parse needed
|
|
45
|
+
// (ts.getImpliedNodeFormatForFile reads only package.json), and compared
|
|
46
|
+
// against the stored value.
|
|
47
|
+
// - The nearest tsconfig's own effective compiler options (module,
|
|
48
|
+
// moduleResolution, paths, ...) - covered by each file's own
|
|
49
|
+
// `optionsIndex` into `optionsTable` below, recomputed every build the
|
|
50
|
+
// same cheap way (module-graph.ts's own per-directory
|
|
51
|
+
// `compilerOptionsForFile` cache) and compared against the stored value.
|
|
52
|
+
// A file whose `impliedNodeFormat` or `optionsIndex` changed is reparsed
|
|
53
|
+
// exactly like a file whose own text changed, without dropping the whole
|
|
54
|
+
// cache.
|
|
55
|
+
// - Which files are analyzed at all (a file added, deleted, or renamed) -
|
|
56
|
+
// covered by `resolutionFingerprint`'s own `filesHash`, the sorted
|
|
57
|
+
// analyzed-file list hashed once, not embedded per file. A file moving
|
|
58
|
+
// into or out of a declared module is a different input: `filesHash`
|
|
59
|
+
// does not move for it (the file was already analyzed either way) -
|
|
60
|
+
// covered instead by each file's own per-specifier resolution record,
|
|
61
|
+
// resolved (never assumed unresolved) the moment a record is missing
|
|
62
|
+
// for a specifier this build now has a reason to ask about - see
|
|
63
|
+
// module-graph.ts's own per-file loop.
|
|
64
|
+
// - Every package.json under the project root outside node_modules (its
|
|
65
|
+
// own `exports`/`imports` map, or a plain `main`/`type`) - covered by
|
|
66
|
+
// `resolutionFingerprint`'s own `packages` map (path -> mtime).
|
|
67
|
+
// - Every file outside node_modules with a resolvable extension
|
|
68
|
+
// (.ts/.tsx/.mts/.cts/.d.ts/.d.mts/.d.cts/.js/.mjs/.cjs/.jsx/.json),
|
|
69
|
+
// analyzed or not, excluded or not, in dist/ or not - a specifier can
|
|
70
|
+
// resolve to any of these, and only its existence (never its content)
|
|
71
|
+
// matters - covered by `resolutionFingerprint`'s own
|
|
72
|
+
// `resolvableFilesHash`.
|
|
73
|
+
// - The lockfile in use (which real dependency version - and so which
|
|
74
|
+
// real files - a bare specifier resolves to) - covered by
|
|
75
|
+
// `resolutionFingerprint`'s own `lockPath`/`lockMtime`, the nearest
|
|
76
|
+
// lockfile found at the project root or any ancestor directory.
|
|
77
|
+
// - Every node_modules directory on the path module resolution actually
|
|
78
|
+
// walks (the project root's own, and each ancestor's, up to the nearest
|
|
79
|
+
// lockfile's own directory or the filesystem root) - covered by
|
|
80
|
+
// `resolutionFingerprint`'s own `nodeModules` map, one entry per such
|
|
81
|
+
// directory, each a map of top-level package name to that package's own
|
|
82
|
+
// package.json mtime (read after following a symlink, so `npm link` and
|
|
83
|
+
// a workspace's own symlinked sibling both count). Limit: an edit made
|
|
84
|
+
// directly to an already-installed package's own file (not its
|
|
85
|
+
// package.json) is invisible to this map, and to every other input this
|
|
86
|
+
// fingerprint reads - deleting node_modules/.cache/archstrict is the
|
|
87
|
+
// only way to force a rebuild for that case.
|
|
88
|
+
// - Every distinct effective compiler-options object across the project -
|
|
89
|
+
// covered by `optionsTable` (hashed once per distinct object, not once
|
|
90
|
+
// per file) folded into `resolutionFingerprint`.
|
|
91
|
+
// - This project's own built code (module-graph.js, edge-cache.js) and
|
|
92
|
+
// the installed typescript's own version - a local build with a
|
|
93
|
+
// changed walker or resolver, or a different typescript resolving the
|
|
94
|
+
// same specifiers differently, produces entries the old combination
|
|
95
|
+
// never would have; covered by `codeVersionHash` and `typescriptVersion`,
|
|
96
|
+
// together with `archstrictVersion`, any one mismatch dropping the
|
|
97
|
+
// whole cache.
|
|
98
|
+
// - Which shard a given file's own entry landed in, and whether that
|
|
99
|
+
// shard's own bytes on disk still match what this cache last wrote -
|
|
100
|
+
// covered by the header's own per-shard content hash (above); a shard
|
|
101
|
+
// whose file is missing or whose hash no longer matches contributes no
|
|
102
|
+
// entries at all, which module-graph.ts's own per-file loop already
|
|
103
|
+
// treats exactly like a brand-new file (no old entry -> reparse and
|
|
104
|
+
// re-resolve), scoped to that one shard's own files.
|
|
105
|
+
// A file whose own reparse gate holds AND whose `resolutionFingerprint`
|
|
106
|
+
// still matches reuses its stored `resolutions` outright, with no
|
|
107
|
+
// `ts.resolveModuleName` call at all - the common, nothing-changed case a
|
|
108
|
+
// `check` hook run hits on every keystroke that isn't an import edit.
|
|
109
|
+
// A changed `resolutionFingerprint` alone (nothing in this file's own
|
|
110
|
+
// reparse gate) re-resolves every specifier project-wide, from each file's
|
|
111
|
+
// own already-cached `imports` - no file is reparsed just for that. Every
|
|
112
|
+
// walked file gets a resolution record for every one of its own
|
|
113
|
+
// specifiers, whether or not it currently belongs to a declared module,
|
|
114
|
+
// and a specifier with no record is resolved rather than assumed
|
|
115
|
+
// unresolved - see module-graph.ts's own per-file loop for why.
|
|
116
|
+
// `fromModule`/`toModule`/`externalPackage` are never stored here: they
|
|
117
|
+
// depend on the current `declaredModules` alone, which a graph build
|
|
118
|
+
// already has in hand for free, and storing them would mean invalidating
|
|
119
|
+
// this whole cache on every config edit instead of none.
|
|
120
|
+
import { readFileSync, mkdirSync, writeFileSync, renameSync, rmSync } from "node:fs";
|
|
121
|
+
import { dirname, join } from "node:path";
|
|
122
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
123
|
+
import { makeAbsolutePosix, makeProjectRelativePosix } from "./project-path.js";
|
|
124
|
+
// Bumped whenever the on-disk shape (header or shard encoding) changes -
|
|
125
|
+
// an old cache is then a silent miss (parseHeader rejects the unknown
|
|
126
|
+
// schema number), never a crash on a shape this code no longer produces.
|
|
127
|
+
export const CACHE_SCHEMA = 11;
|
|
128
|
+
// Fixed, not derived from project size - see this module's own header.
|
|
129
|
+
// module-graph.ts's own per-file loop marks a shard dirty by this same
|
|
130
|
+
// function; both sides agree only because they share it.
|
|
131
|
+
export const SHARD_COUNT = 64;
|
|
132
|
+
export function shardIndexForRelativePath(relativePath) {
|
|
133
|
+
const digest = createHash("sha256").update(relativePath).digest();
|
|
134
|
+
return digest.readUInt32BE(0) % SHARD_COUNT;
|
|
135
|
+
}
|
|
136
|
+
export function resolutionKey(imp) {
|
|
137
|
+
return `${imp.specifier}\u0000${imp.mode ?? ""}`;
|
|
138
|
+
}
|
|
139
|
+
function record(value) {
|
|
140
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
141
|
+
}
|
|
142
|
+
function strings(value) {
|
|
143
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
144
|
+
}
|
|
145
|
+
function isMode(value) {
|
|
146
|
+
return value === undefined || (typeof value === "number" && Number.isInteger(value));
|
|
147
|
+
}
|
|
148
|
+
function isImportRecord(value) {
|
|
149
|
+
if (!record(value) || !record(value.fromPosition))
|
|
150
|
+
return false;
|
|
151
|
+
return typeof value.specifier === "string" && typeof value.isTypeOnly === "boolean" && typeof value.isDynamic === "boolean" &&
|
|
152
|
+
isMode(value.mode) &&
|
|
153
|
+
[value.fromPosition.line, value.fromPosition.column].every((n) => Number.isInteger(n) && Number(n) > 0);
|
|
154
|
+
}
|
|
155
|
+
function isModuleAugmentationSpecifier(value) {
|
|
156
|
+
return record(value) && typeof value.specifier === "string" && isMode(value.mode);
|
|
157
|
+
}
|
|
158
|
+
function isResolution(value) {
|
|
159
|
+
if (value === "unresolved")
|
|
160
|
+
return true;
|
|
161
|
+
return record(value) && typeof value.resolvedFile === "string" &&
|
|
162
|
+
(value.isExternalLibraryImport === undefined || value.isExternalLibraryImport === true) &&
|
|
163
|
+
(value.packageName === undefined || typeof value.packageName === "string");
|
|
164
|
+
}
|
|
165
|
+
function isFileEntry(value) {
|
|
166
|
+
if (!record(value))
|
|
167
|
+
return false;
|
|
168
|
+
if (typeof value.mtimeMs !== "number" || !Number.isFinite(value.mtimeMs))
|
|
169
|
+
return false;
|
|
170
|
+
if (typeof value.size !== "number" || !Number.isFinite(value.size))
|
|
171
|
+
return false;
|
|
172
|
+
if (!Number.isInteger(value.optionsIndex) || Number(value.optionsIndex) < 0)
|
|
173
|
+
return false;
|
|
174
|
+
if (!isMode(value.impliedNodeFormat))
|
|
175
|
+
return false;
|
|
176
|
+
if (!Array.isArray(value.imports) || !value.imports.every(isImportRecord))
|
|
177
|
+
return false;
|
|
178
|
+
if (!Number.isInteger(value.unsupportedSyntaxCount) || Number(value.unsupportedSyntaxCount) < 0)
|
|
179
|
+
return false;
|
|
180
|
+
if (typeof value.isScript !== "boolean" || typeof value.hasAmbientDeclarations !== "boolean" ||
|
|
181
|
+
typeof value.hasModuleAugmentation !== "boolean")
|
|
182
|
+
return false;
|
|
183
|
+
if (!Array.isArray(value.moduleAugmentationSpecifiers) ||
|
|
184
|
+
!value.moduleAugmentationSpecifiers.every(isModuleAugmentationSpecifier) ||
|
|
185
|
+
value.hasModuleAugmentation !== (value.moduleAugmentationSpecifiers.length > 0))
|
|
186
|
+
return false;
|
|
187
|
+
if (value.unreadable !== undefined && value.unreadable !== true)
|
|
188
|
+
return false;
|
|
189
|
+
if (!record(value.resolutions) || !Object.values(value.resolutions).every(isResolution))
|
|
190
|
+
return false;
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
function isShardHeaderEntry(value) {
|
|
194
|
+
return record(value) && typeof value.file === "string" && typeof value.hash === "string";
|
|
195
|
+
}
|
|
196
|
+
function parseHeader(value) {
|
|
197
|
+
if (!record(value) || value.schema !== CACHE_SCHEMA)
|
|
198
|
+
return undefined;
|
|
199
|
+
if (typeof value.archstrictVersion !== "string" || typeof value.codeVersionHash !== "string" ||
|
|
200
|
+
typeof value.typescriptVersion !== "string" || !strings(value.optionsTable) ||
|
|
201
|
+
typeof value.resolutionFingerprint !== "string")
|
|
202
|
+
return undefined;
|
|
203
|
+
if (!Array.isArray(value.shards) || value.shards.length !== SHARD_COUNT ||
|
|
204
|
+
!value.shards.every((s) => s === null || isShardHeaderEntry(s)))
|
|
205
|
+
return undefined;
|
|
206
|
+
return {
|
|
207
|
+
archstrictVersion: value.archstrictVersion, codeVersionHash: value.codeVersionHash,
|
|
208
|
+
typescriptVersion: value.typescriptVersion, optionsTable: value.optionsTable,
|
|
209
|
+
resolutionFingerprint: value.resolutionFingerprint, shards: value.shards,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const FLAG_IS_SCRIPT = 1;
|
|
213
|
+
const FLAG_HAS_AMBIENT_DECLARATIONS = 2;
|
|
214
|
+
const FLAG_UNREADABLE = 4;
|
|
215
|
+
const FLAG_HAS_MODULE_AUGMENTATION = 8;
|
|
216
|
+
function makeStringTable() {
|
|
217
|
+
const table = [];
|
|
218
|
+
const indexByValue = new Map();
|
|
219
|
+
return { table, index: (value) => {
|
|
220
|
+
let idx = indexByValue.get(value);
|
|
221
|
+
if (idx === undefined) {
|
|
222
|
+
idx = table.length;
|
|
223
|
+
table.push(value);
|
|
224
|
+
indexByValue.set(value, idx);
|
|
225
|
+
}
|
|
226
|
+
return idx;
|
|
227
|
+
} };
|
|
228
|
+
}
|
|
229
|
+
// One shard's own entries, sorted by relative path - deterministic bytes
|
|
230
|
+
// for the same content, so a rewrite that changes nothing produces the
|
|
231
|
+
// same hash (and so `writeEdgeCache` can tell "unchanged" from "changed"
|
|
232
|
+
// without a byte compare).
|
|
233
|
+
function encodeShard(entries, relativePath) {
|
|
234
|
+
const paths = makeStringTable();
|
|
235
|
+
const specifiers = makeStringTable();
|
|
236
|
+
const packageNames = makeStringTable();
|
|
237
|
+
const encodeResolution = (res) => {
|
|
238
|
+
if (res === "unresolved")
|
|
239
|
+
return 0;
|
|
240
|
+
return [paths.index(relativePath(res.resolvedFile)), res.isExternalLibraryImport ? 1 : 0,
|
|
241
|
+
res.packageName === undefined ? null : packageNames.index(res.packageName)];
|
|
242
|
+
};
|
|
243
|
+
const files = [];
|
|
244
|
+
for (const relPath of [...entries.keys()].sort()) {
|
|
245
|
+
const entry = entries.get(relPath);
|
|
246
|
+
const flags = (entry.isScript ? FLAG_IS_SCRIPT : 0) | (entry.hasAmbientDeclarations ? FLAG_HAS_AMBIENT_DECLARATIONS : 0) |
|
|
247
|
+
(entry.unreadable ? FLAG_UNREADABLE : 0) | (entry.hasModuleAugmentation ? FLAG_HAS_MODULE_AUGMENTATION : 0);
|
|
248
|
+
const encodedImports = entry.imports.map((imp) => {
|
|
249
|
+
const key = resolutionKey(imp);
|
|
250
|
+
const res = Object.hasOwn(entry.resolutions, key) ? entry.resolutions[key] : undefined;
|
|
251
|
+
return [
|
|
252
|
+
specifiers.index(imp.specifier), imp.fromPosition.line, imp.fromPosition.column,
|
|
253
|
+
imp.isTypeOnly ? 1 : 0, imp.isDynamic ? 1 : 0, imp.mode ?? null,
|
|
254
|
+
res === undefined ? null : encodeResolution(res),
|
|
255
|
+
];
|
|
256
|
+
});
|
|
257
|
+
const encodedModuleAugmentations = entry.moduleAugmentationSpecifiers.map((augmentation) => [specifiers.index(augmentation.specifier), augmentation.mode ?? null]);
|
|
258
|
+
files.push([
|
|
259
|
+
paths.index(relPath), entry.mtimeMs, entry.size, entry.optionsIndex,
|
|
260
|
+
entry.impliedNodeFormat ?? null, entry.unsupportedSyntaxCount, flags, encodedImports, encodedModuleAugmentations,
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
return { paths: paths.table, specifiers: specifiers.table, packageNames: packageNames.table, files };
|
|
264
|
+
}
|
|
265
|
+
// The exact inverse of encodeShard - `undefined` means the shard's own
|
|
266
|
+
// JSON does not have this module's own shape (a version mismatch that
|
|
267
|
+
// slipped past the header's schema check, or a hand-edited file); the
|
|
268
|
+
// caller then drops the whole shard, never a single bad file inside it,
|
|
269
|
+
// matching the header's own hash check (also whole-shard).
|
|
270
|
+
function decodeShard(raw, restoreAbsolutePath, optionsCount) {
|
|
271
|
+
if (!record(raw))
|
|
272
|
+
return undefined;
|
|
273
|
+
const { paths, specifiers, packageNames, files } = raw;
|
|
274
|
+
if (!strings(paths) || !strings(specifiers) || !strings(packageNames) || !Array.isArray(files))
|
|
275
|
+
return undefined;
|
|
276
|
+
const result = new Map();
|
|
277
|
+
for (const tuple of files) {
|
|
278
|
+
if (!Array.isArray(tuple) || tuple.length !== 9)
|
|
279
|
+
return undefined;
|
|
280
|
+
const [pathIdx, mtimeMs, size, optionsIndex, impliedRaw, unsupportedSyntaxCount, flags, importsRaw, augmentationsRaw] = tuple;
|
|
281
|
+
if (!Number.isInteger(pathIdx) || pathIdx < 0 || pathIdx >= paths.length)
|
|
282
|
+
return undefined;
|
|
283
|
+
if (!Number.isInteger(optionsIndex) || optionsIndex < 0 || optionsIndex >= optionsCount)
|
|
284
|
+
return undefined;
|
|
285
|
+
if (!isMode(impliedRaw === null ? undefined : impliedRaw))
|
|
286
|
+
return undefined;
|
|
287
|
+
if (typeof flags !== "number")
|
|
288
|
+
return undefined;
|
|
289
|
+
if (!Array.isArray(importsRaw))
|
|
290
|
+
return undefined;
|
|
291
|
+
if (!Array.isArray(augmentationsRaw))
|
|
292
|
+
return undefined;
|
|
293
|
+
const imports = [];
|
|
294
|
+
const resolutions = {};
|
|
295
|
+
let ok = true;
|
|
296
|
+
for (const impTuple of importsRaw) {
|
|
297
|
+
if (!ok)
|
|
298
|
+
break;
|
|
299
|
+
if (!Array.isArray(impTuple) || impTuple.length !== 7) {
|
|
300
|
+
ok = false;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
const [specIdx, line, column, isTypeOnly, isDynamic, modeRaw, resRaw] = impTuple;
|
|
304
|
+
if (!Number.isInteger(specIdx) || specIdx < 0 || specIdx >= specifiers.length) {
|
|
305
|
+
ok = false;
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
const mode = (modeRaw === null ? undefined : modeRaw);
|
|
309
|
+
const imp = {
|
|
310
|
+
specifier: specifiers[specIdx],
|
|
311
|
+
fromPosition: { line: line, column: column },
|
|
312
|
+
isTypeOnly: isTypeOnly === 1, isDynamic: isDynamic === 1, mode,
|
|
313
|
+
};
|
|
314
|
+
if (!isImportRecord(imp)) {
|
|
315
|
+
ok = false;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
imports.push(imp);
|
|
319
|
+
if (resRaw === null)
|
|
320
|
+
continue;
|
|
321
|
+
let resolution;
|
|
322
|
+
if (resRaw === 0)
|
|
323
|
+
resolution = "unresolved";
|
|
324
|
+
else if (Array.isArray(resRaw) && resRaw.length === 3) {
|
|
325
|
+
const [resPathIdx, isExternal, packageNameIdx] = resRaw;
|
|
326
|
+
if (!Number.isInteger(resPathIdx) || resPathIdx < 0 || resPathIdx >= paths.length) {
|
|
327
|
+
ok = false;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
if (packageNameIdx !== null && (!Number.isInteger(packageNameIdx) || packageNameIdx < 0 || packageNameIdx >= packageNames.length)) {
|
|
331
|
+
ok = false;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
resolution = {
|
|
335
|
+
resolvedFile: restoreAbsolutePath(paths[resPathIdx]),
|
|
336
|
+
...(isExternal === 1 ? { isExternalLibraryImport: true } : {}),
|
|
337
|
+
...(packageNameIdx !== null ? { packageName: packageNames[packageNameIdx] } : {}),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
ok = false;
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
if (!isResolution(resolution)) {
|
|
345
|
+
ok = false;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
resolutions[resolutionKey(imp)] = resolution;
|
|
349
|
+
}
|
|
350
|
+
if (!ok)
|
|
351
|
+
return undefined;
|
|
352
|
+
const moduleAugmentationSpecifiers = [];
|
|
353
|
+
for (const augmentationTuple of augmentationsRaw) {
|
|
354
|
+
if (!Array.isArray(augmentationTuple) || augmentationTuple.length !== 2)
|
|
355
|
+
return undefined;
|
|
356
|
+
const [specifierIdx, modeRaw] = augmentationTuple;
|
|
357
|
+
if (!Number.isInteger(specifierIdx) || specifierIdx < 0 ||
|
|
358
|
+
specifierIdx >= specifiers.length || !isMode(modeRaw === null ? undefined : modeRaw))
|
|
359
|
+
return undefined;
|
|
360
|
+
moduleAugmentationSpecifiers.push({
|
|
361
|
+
specifier: specifiers[specifierIdx],
|
|
362
|
+
mode: (modeRaw === null ? undefined : modeRaw),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const entry = {
|
|
366
|
+
mtimeMs: mtimeMs, size: size, optionsIndex: optionsIndex,
|
|
367
|
+
...(impliedRaw !== null ? { impliedNodeFormat: impliedRaw } : {}),
|
|
368
|
+
imports, unsupportedSyntaxCount: unsupportedSyntaxCount,
|
|
369
|
+
isScript: (flags & FLAG_IS_SCRIPT) !== 0,
|
|
370
|
+
hasAmbientDeclarations: (flags & FLAG_HAS_AMBIENT_DECLARATIONS) !== 0,
|
|
371
|
+
hasModuleAugmentation: (flags & FLAG_HAS_MODULE_AUGMENTATION) !== 0,
|
|
372
|
+
moduleAugmentationSpecifiers,
|
|
373
|
+
...((flags & FLAG_UNREADABLE) !== 0 ? { unreadable: true } : {}),
|
|
374
|
+
resolutions,
|
|
375
|
+
};
|
|
376
|
+
if (!isFileEntry(entry))
|
|
377
|
+
return undefined;
|
|
378
|
+
result.set(restoreAbsolutePath(paths[pathIdx]), entry);
|
|
379
|
+
}
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
382
|
+
// Reads the header, then each shard it names, one at a time - never the
|
|
383
|
+
// whole cache as one string (this module's own header). A shard that is
|
|
384
|
+
// missing, hash-mismatched, or fails to decode contributes no entries and
|
|
385
|
+
// is never an error; the header itself failing to parse (an unknown
|
|
386
|
+
// schema, or any other malformed shape) is the one case that misses the
|
|
387
|
+
// whole cache.
|
|
388
|
+
export function readEdgeCache(path, projectRoot) {
|
|
389
|
+
let headerRaw;
|
|
390
|
+
try {
|
|
391
|
+
headerRaw = JSON.parse(readFileSync(path).toString("utf8"));
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
const header = parseHeader(headerRaw);
|
|
397
|
+
if (header === undefined)
|
|
398
|
+
return undefined;
|
|
399
|
+
const dir = dirname(path);
|
|
400
|
+
const files = {};
|
|
401
|
+
const restoreAbsolutePath = makeAbsolutePosix(projectRoot);
|
|
402
|
+
for (const shardEntry of header.shards) {
|
|
403
|
+
if (shardEntry === null)
|
|
404
|
+
continue;
|
|
405
|
+
let buf;
|
|
406
|
+
try {
|
|
407
|
+
buf = readFileSync(join(dir, shardEntry.file));
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (createHash("sha256").update(buf).digest("hex") !== shardEntry.hash)
|
|
413
|
+
continue;
|
|
414
|
+
let raw;
|
|
415
|
+
try {
|
|
416
|
+
raw = JSON.parse(buf.toString("utf8"));
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const decoded = decodeShard(raw, restoreAbsolutePath, header.optionsTable.length);
|
|
422
|
+
if (decoded === undefined)
|
|
423
|
+
continue;
|
|
424
|
+
for (const [absPath, entry] of decoded)
|
|
425
|
+
files[absPath] = entry;
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
schema: CACHE_SCHEMA, archstrictVersion: header.archstrictVersion, codeVersionHash: header.codeVersionHash,
|
|
429
|
+
typescriptVersion: header.typescriptVersion, optionsTable: header.optionsTable,
|
|
430
|
+
resolutionFingerprint: header.resolutionFingerprint, files, shards: header.shards,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
// Writes only the shards that actually need it, then the header last (so
|
|
434
|
+
// a process killed mid-write leaves the header pointing at shards that
|
|
435
|
+
// are each internally consistent, never a header naming a shard this
|
|
436
|
+
// write never finished).
|
|
437
|
+
//
|
|
438
|
+
// `forceAll`: true the moment `archstrictVersion`/`codeVersionHash`/
|
|
439
|
+
// `typescriptVersion` mismatched, or the resolution fingerprint moved -
|
|
440
|
+
// either one can change every file's own entry, so every shard that has
|
|
441
|
+
// any current file is rewritten regardless of `dirtyPaths`.
|
|
442
|
+
// `dirtyPaths`/`deletedPaths`: every file whose own entry changed this
|
|
443
|
+
// build (reparsed or re-resolved) or disappeared - each marks its own
|
|
444
|
+
// shard for a rewrite; every other shard's own header line is copied
|
|
445
|
+
// forward from `oldShards` untouched (no read of its own bytes, no
|
|
446
|
+
// rewrite).
|
|
447
|
+
export function writeEdgeCache(path, projectRoot, cache, oldShards, dirtyPaths, deletedPaths, forceAll) {
|
|
448
|
+
const dir = dirname(path);
|
|
449
|
+
const shardsDir = join(dir, "edges");
|
|
450
|
+
const relativePath = makeProjectRelativePosix(projectRoot);
|
|
451
|
+
const groups = new Map();
|
|
452
|
+
for (const [absPath, entry] of Object.entries(cache.files)) {
|
|
453
|
+
const relPath = relativePath(absPath);
|
|
454
|
+
const idx = shardIndexForRelativePath(relPath);
|
|
455
|
+
let group = groups.get(idx);
|
|
456
|
+
if (group === undefined) {
|
|
457
|
+
group = new Map();
|
|
458
|
+
groups.set(idx, group);
|
|
459
|
+
}
|
|
460
|
+
group.set(relPath, entry);
|
|
461
|
+
}
|
|
462
|
+
const dirtyShards = new Set();
|
|
463
|
+
for (const absPath of dirtyPaths)
|
|
464
|
+
dirtyShards.add(shardIndexForRelativePath(relativePath(absPath)));
|
|
465
|
+
for (const absPath of deletedPaths)
|
|
466
|
+
dirtyShards.add(shardIndexForRelativePath(relativePath(absPath)));
|
|
467
|
+
try {
|
|
468
|
+
mkdirSync(shardsDir, { recursive: true });
|
|
469
|
+
}
|
|
470
|
+
catch { /* best-effort; each shard write below no-ops on failure too */ }
|
|
471
|
+
const shards = [];
|
|
472
|
+
for (let i = 0; i < SHARD_COUNT; i++) {
|
|
473
|
+
const group = groups.get(i);
|
|
474
|
+
if (group === undefined) {
|
|
475
|
+
shards.push(null);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const oldEntry = oldShards?.[i] ?? null;
|
|
479
|
+
// A group with files but no prior header line can only happen for a
|
|
480
|
+
// shard this build must write anyway (forceAll, or every one of its
|
|
481
|
+
// files freshly dirty) - defended here too, so a bug in that
|
|
482
|
+
// reasoning loses no data silently.
|
|
483
|
+
const mustRewrite = forceAll || dirtyShards.has(i) || oldEntry === null;
|
|
484
|
+
if (!mustRewrite) {
|
|
485
|
+
shards.push(oldEntry);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const file = `edges/${i}.json`;
|
|
489
|
+
const json = JSON.stringify(encodeShard(group, relativePath));
|
|
490
|
+
const hash = createHash("sha256").update(json).digest("hex");
|
|
491
|
+
const fullPath = join(dir, file);
|
|
492
|
+
const temporary = `${fullPath}.${randomUUID()}.tmp`;
|
|
493
|
+
try {
|
|
494
|
+
writeFileSync(temporary, json, { flag: "wx" });
|
|
495
|
+
renameSync(temporary, fullPath);
|
|
496
|
+
shards.push({ file, hash });
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
// A read-only shard directory must not prevent a fresh analysis
|
|
500
|
+
// result - the next successful write replaces this line too.
|
|
501
|
+
shards.push(null);
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
try {
|
|
505
|
+
rmSync(temporary, { force: true });
|
|
506
|
+
}
|
|
507
|
+
catch { /* best-effort cleanup on read-only filesystems */ }
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const header = {
|
|
511
|
+
schema: CACHE_SCHEMA, archstrictVersion: cache.archstrictVersion, codeVersionHash: cache.codeVersionHash,
|
|
512
|
+
typescriptVersion: cache.typescriptVersion, optionsTable: cache.optionsTable,
|
|
513
|
+
resolutionFingerprint: cache.resolutionFingerprint, shards,
|
|
514
|
+
};
|
|
515
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
516
|
+
try {
|
|
517
|
+
mkdirSync(dir, { recursive: true });
|
|
518
|
+
writeFileSync(temporary, JSON.stringify(header), { flag: "wx" });
|
|
519
|
+
renameSync(temporary, path);
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
// A read-only cache directory must not prevent a fresh analysis result.
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
try {
|
|
526
|
+
rmSync(temporary, { force: true });
|
|
527
|
+
}
|
|
528
|
+
catch { /* best-effort cleanup on read-only filesystems */ }
|
|
529
|
+
}
|
|
530
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Responsibility: expose architecture queries through the MCP protocol.
|
|
2
|
+
// Boundary: delegates rule evaluation to verbs and retains one warm graph per server.
|
|
3
|
+
// McpServer's registration helpers typically expect zod input schemas. These inputs
|
|
4
|
+
// are simple enough for manual validation, so Server uses raw JSON Schema instead.
|
|
5
|
+
// This avoids a second direct dependency: zod remains an SDK dependency, not a project import.
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { createWarmGraph } from "./warm-graph.js";
|
|
10
|
+
import { check } from "./verbs/check.js";
|
|
11
|
+
import { rules } from "./verbs/rules.js";
|
|
12
|
+
import { search } from "./verbs/search.js";
|
|
13
|
+
import { simulate } from "./verbs/simulate.js";
|
|
14
|
+
const tools = [
|
|
15
|
+
{ name: "check", description: "Check project architecture, optionally focused on one file.",
|
|
16
|
+
inputSchema: { type: "object", properties: { file: { type: "string" } }, additionalProperties: false } },
|
|
17
|
+
{ name: "rules", description: "Show the rules that govern a path.",
|
|
18
|
+
inputSchema: { type: "object", properties: { path: { type: "string" } }, required: ["path"], additionalProperties: false } },
|
|
19
|
+
{ name: "search", description: "Search public exports by name.",
|
|
20
|
+
inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false } },
|
|
21
|
+
{ name: "simulate", description: "Check a proposed change set in memory. A change to the project's archstrict.config.ts previews a proposed config.",
|
|
22
|
+
inputSchema: { type: "object", properties: { changes: { type: "array", items: {
|
|
23
|
+
type: "object", properties: { path: { type: "string" }, content: { type: ["string", "null"] } },
|
|
24
|
+
required: ["path", "content"], additionalProperties: false,
|
|
25
|
+
} } }, required: ["changes"], additionalProperties: false } },
|
|
26
|
+
];
|
|
27
|
+
// The declared inputSchema supports client discovery; the low-level SDK does not
|
|
28
|
+
// enforce it before this handler runs. A client can skip validation, so these
|
|
29
|
+
// simple fields need manual checks without another schema library.
|
|
30
|
+
function stringField(args, field) {
|
|
31
|
+
const value = args[field];
|
|
32
|
+
if (typeof value !== "string")
|
|
33
|
+
throw new TypeError(`${field} must be a string`);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
export function createArchstrictMcpServer(projectRoot) {
|
|
37
|
+
// One server spans many calls in the same process, unlike a one-shot CLI invocation.
|
|
38
|
+
// Retain each file's import records across refresh calls; a new holder per call would lose that reuse.
|
|
39
|
+
const warm = createWarmGraph();
|
|
40
|
+
const server = new Server({ name: "archstrict", version: "0.0.0" }, { capabilities: { tools: {} } });
|
|
41
|
+
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools }));
|
|
42
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
43
|
+
// A failed call must leave the session available for later calls. Convert bad input,
|
|
44
|
+
// config-load failures, and invalid paths into tool errors the agent can read as data.
|
|
45
|
+
// Keep rejected promises inside this handler rather than letting them escape through the transport.
|
|
46
|
+
try {
|
|
47
|
+
const args = request.params.arguments ?? {};
|
|
48
|
+
// Discovery lists allowed tools and keys, but clients can send others.
|
|
49
|
+
// Reject them here rather than trusting the advertised schema.
|
|
50
|
+
const tool = tools.find(tool => tool.name === request.params.name);
|
|
51
|
+
if (tool === undefined)
|
|
52
|
+
throw new TypeError(`Unknown tool: ${request.params.name}`);
|
|
53
|
+
for (const key of Object.keys(args)) {
|
|
54
|
+
if (!Object.hasOwn(tool.inputSchema.properties ?? {}, key))
|
|
55
|
+
throw new TypeError(`Unexpected argument: ${key}`);
|
|
56
|
+
}
|
|
57
|
+
let result;
|
|
58
|
+
switch (request.params.name) {
|
|
59
|
+
case "check":
|
|
60
|
+
// Agents can check after every edit, as the PostToolUse hook does, so parsed-source reuse matters here.
|
|
61
|
+
// Rules already uses a lighter graph builder without a type checker.
|
|
62
|
+
// Search keeps its separate cold, one-shot design. Simulate reads
|
|
63
|
+
// the disk cache without persisting its in-memory proposal.
|
|
64
|
+
result = await check(projectRoot, args.file === undefined ? undefined : stringField(args, "file"), { buildGraph: warm.refresh });
|
|
65
|
+
break;
|
|
66
|
+
case "rules":
|
|
67
|
+
result = await rules(projectRoot, stringField(args, "path"));
|
|
68
|
+
break;
|
|
69
|
+
case "search":
|
|
70
|
+
result = await search(projectRoot, stringField(args, "query"));
|
|
71
|
+
break;
|
|
72
|
+
case "simulate": {
|
|
73
|
+
// Clients can also bypass the nested change schema. Check each entry by hand
|
|
74
|
+
// so malformed paths or content cannot reach the simulation as trusted values.
|
|
75
|
+
if (!Array.isArray(args.changes))
|
|
76
|
+
throw new TypeError("changes must be an array");
|
|
77
|
+
const changes = args.changes.map((change) => {
|
|
78
|
+
if (typeof change !== "object" || change === null || Array.isArray(change)) {
|
|
79
|
+
throw new TypeError("Each change must be an object");
|
|
80
|
+
}
|
|
81
|
+
const entry = change;
|
|
82
|
+
if (Object.keys(entry).some(key => key !== "path" && key !== "content")) {
|
|
83
|
+
throw new TypeError("Each change must contain only path and content");
|
|
84
|
+
}
|
|
85
|
+
const path = stringField(entry, "path");
|
|
86
|
+
if (entry.content !== null && typeof entry.content !== "string") {
|
|
87
|
+
throw new TypeError("content must be a string or null");
|
|
88
|
+
}
|
|
89
|
+
return { path, content: entry.content };
|
|
90
|
+
});
|
|
91
|
+
result = await simulate(projectRoot, changes);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
default:
|
|
95
|
+
throw new TypeError(`Unknown tool: ${request.params.name}`);
|
|
96
|
+
}
|
|
97
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
return { isError: true, content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }] };
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return server;
|
|
104
|
+
}
|
|
105
|
+
// The CLI and plugin wrapper need identical server construction and stdio setup.
|
|
106
|
+
// A shared entry point keeps those callers aligned when startup behavior changes.
|
|
107
|
+
export async function startArchstrictMcpServer(projectRoot) {
|
|
108
|
+
const server = createArchstrictMcpServer(projectRoot);
|
|
109
|
+
await server.connect(new StdioServerTransport());
|
|
110
|
+
return server;
|
|
111
|
+
}
|