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
@@ -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
+ }