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
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// Responsibility: the on-disk shape of the project's one frozen-violation
|
|
2
|
+
// file (archstrict.todo.json, at the project root) and the identity
|
|
3
|
+
// (fingerprint) that ties a todo entry to a violation across runs. Shared
|
|
4
|
+
// by check.ts (suppresses a violation whose fingerprint is already frozen,
|
|
5
|
+
// and flags a todo entry that matches nothing as stale) and todo.ts
|
|
6
|
+
// (writes the file) so neither has to depend on the other - both depend
|
|
7
|
+
// on this instead.
|
|
8
|
+
// Boundary: file I/O, the single-file shape, and the fingerprint's own
|
|
9
|
+
// definition only. No rule logic, no freeze/prune policy (that's todo.ts's
|
|
10
|
+
// job), no migration policy (that's todo-migration.ts's job, kept out of
|
|
11
|
+
// this file so it can be deleted whole after the first release).
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { isAbsolute, join } from "node:path";
|
|
15
|
+
import ts from "typescript";
|
|
16
|
+
import { REFERENCED_BY_MARKER } from "./type-leak.js";
|
|
17
|
+
import { ReportError } from "./report-error.js";
|
|
18
|
+
import { toProjectRelativePosix } from "./module-graph.js";
|
|
19
|
+
// Identity for a violation across runs: rule, importing path, and the
|
|
20
|
+
// evidence text — no line number (a line moving is not a new violation;
|
|
21
|
+
// archspec's own fingerprint makes the same choice). Three rules replace
|
|
22
|
+
// `path` and/or `evidence` with something narrower, each because the
|
|
23
|
+
// literal field can change for a reason that has nothing to do with
|
|
24
|
+
// whether the underlying debt is still the same edge:
|
|
25
|
+
//
|
|
26
|
+
// "cycle": `path` is `firstEdge.fromFile` — the file of one arbitrary edge
|
|
27
|
+
// in the cycle, an implementation detail of which edge the shortest-path
|
|
28
|
+
// search happened to return first, not the cycle's own identity. The
|
|
29
|
+
// cycle itself (`evidence`, e.g. "a -> b -> c -> a") is the identity; if
|
|
30
|
+
// that one file moved but the same cycle still existed, including `path`
|
|
31
|
+
// would change the fingerprint and the frozen entry would go stale for a
|
|
32
|
+
// cycle that never actually changed.
|
|
33
|
+
//
|
|
34
|
+
// "type-leak": `path` is the surface file whose declaration site sorts
|
|
35
|
+
// earliest among however many surface files the leak's own module has -
|
|
36
|
+
// an accident of which surface file a later one happens to be added
|
|
37
|
+
// alongside, not part of the leak's own identity (module, internal type,
|
|
38
|
+
// and its declaring file already fully identify it, and all three appear
|
|
39
|
+
// in evidence's own stable prefix - see stableEvidence below). A module
|
|
40
|
+
// moving from one surface file to two, with the second sorting earlier,
|
|
41
|
+
// would otherwise re-anchor `path` and stale an unrelated, still-real leak.
|
|
42
|
+
//
|
|
43
|
+
// Every other rule's `path` names the real thing the violation is about
|
|
44
|
+
// (the importing file, or the config file), so only these two are
|
|
45
|
+
// excluded. Hashed so a sortable, stable key is short regardless of how
|
|
46
|
+
// long the evidence text is - buildTodoIndex's own comment covers why
|
|
47
|
+
// matching itself never trusts a stored string.
|
|
48
|
+
//
|
|
49
|
+
// `stableEvidence` additionally strips a rule's own known-mutable slice of
|
|
50
|
+
// `evidence` before it's hashed, for two rules:
|
|
51
|
+
//
|
|
52
|
+
// - "type-leak": evidence embeds a mutable, informational list of every
|
|
53
|
+
// exported symbol CURRENTLY referencing a leaked internal type, after
|
|
54
|
+
// REFERENCED_BY_MARKER - not part of the leak's own identity. Without
|
|
55
|
+
// stripping it, one more real caller of an already-frozen leak appearing
|
|
56
|
+
// would change the fingerprint and reopen a frozen entry for a leak that
|
|
57
|
+
// hasn't newly appeared - measured directly: freezing a leak referenced
|
|
58
|
+
// by one export, then adding a second real export referencing the same
|
|
59
|
+
// internal type, produced both a stale-todo violation for the old entry
|
|
60
|
+
// and a fresh, unfrozen one for what is still the same leak.
|
|
61
|
+
// - "tag-order": evidence embeds the full configured sequence
|
|
62
|
+
// (`(<namespace> sequence: a -> b -> c)`) purely to explain why the edge
|
|
63
|
+
// is forbidden - a value added anywhere in that sequence, even one this
|
|
64
|
+
// edge's own two layers never touch, changes the text without changing
|
|
65
|
+
// which edge is forbidden or why. Stripped back to the sentence naming
|
|
66
|
+
// the specifier and the two real layers it connects.
|
|
67
|
+
//
|
|
68
|
+
// A fourth rule, public-surface-bypass, doesn't fit this "trim the
|
|
69
|
+
// evidence" shape at all: its evidence names the target module and
|
|
70
|
+
// whether that module has a surface, and BOTH change when that one
|
|
71
|
+
// module (not evidence's own surrounding text) gains a surface - not a
|
|
72
|
+
// mutable suffix to strip, but a different sentence template entirely.
|
|
73
|
+
// See `bypassIdentity` below for why it keys off structured fields
|
|
74
|
+
// instead.
|
|
75
|
+
const TAG_ORDER_SEQUENCE_MARKER = " sequence: ";
|
|
76
|
+
function stableEvidence(rule, evidence) {
|
|
77
|
+
if (rule === "type-leak") {
|
|
78
|
+
const i = evidence.indexOf(REFERENCED_BY_MARKER);
|
|
79
|
+
return i === -1 ? evidence : evidence.slice(0, i);
|
|
80
|
+
}
|
|
81
|
+
if (rule === "tag-order") {
|
|
82
|
+
// rules/constraints.ts's own template puts this clause last, wrapped
|
|
83
|
+
// in one paren pair with no nested parens - trimming from the LAST
|
|
84
|
+
// "(" before the marker keeps `sourceLayer -> targetLayer` (still text
|
|
85
|
+
// before the marker) intact while dropping only the sequence list.
|
|
86
|
+
const markerAt = evidence.indexOf(TAG_ORDER_SEQUENCE_MARKER);
|
|
87
|
+
if (markerAt === -1)
|
|
88
|
+
return evidence;
|
|
89
|
+
const openParenAt = evidence.lastIndexOf("(", markerAt);
|
|
90
|
+
return openParenAt === -1 ? evidence : evidence.slice(0, openParenAt).trimEnd();
|
|
91
|
+
}
|
|
92
|
+
return evidence;
|
|
93
|
+
}
|
|
94
|
+
function pathExcludedFromKey(rule) {
|
|
95
|
+
return rule === "cycle" || rule === "type-leak";
|
|
96
|
+
}
|
|
97
|
+
// `path`/`target` MUST be project-relative before either reaches
|
|
98
|
+
// fingerprintOf - never the raw, absolute form a live violation's own
|
|
99
|
+
// `path`/`target` field actually holds. An absolute path is machine- and
|
|
100
|
+
// checkout-specific (a different clone, a different CI runner, even the
|
|
101
|
+
// same machine's own `/tmp` vs `/private/tmp`), so baking one into a key
|
|
102
|
+
// that gets compared across process runs - the whole point of a todo file
|
|
103
|
+
// - would silently stop matching the moment either side ran somewhere
|
|
104
|
+
// else. Every caller (todo.ts's freeze/prune, check.ts's own matching,
|
|
105
|
+
// simulate.ts's and fix.ts's live-vs-live diffing) relativizes through
|
|
106
|
+
// this one function rather than repeating the "only if target is present"
|
|
107
|
+
// check inline.
|
|
108
|
+
export function relativizeForTodo(v, relativePath) {
|
|
109
|
+
return v.target === undefined
|
|
110
|
+
? { ...v, path: relativePath(v.path) }
|
|
111
|
+
: { ...v, path: relativePath(v.path), target: relativePath(v.target) };
|
|
112
|
+
}
|
|
113
|
+
// public-surface-bypass's own evidence names the target module and
|
|
114
|
+
// whether THAT module has a surface - true facts, but ones that read
|
|
115
|
+
// differently the moment this bypass's own target module gains or loses
|
|
116
|
+
// a surface (or its own `surface` config changes), even though the edge
|
|
117
|
+
// itself (which file imports which file, through which specifier) never
|
|
118
|
+
// moved. specifier/target are edge-intrinsic instead: the same import
|
|
119
|
+
// into the same resolved file is the same debt regardless of what
|
|
120
|
+
// evidence's own sentence says today. Returns undefined for a rule this
|
|
121
|
+
// doesn't apply to, or for an entry migrated from before these fields
|
|
122
|
+
// existed (readTodoFile never invents them).
|
|
123
|
+
function bypassIdentity(v) {
|
|
124
|
+
if (v.rule !== "public-surface-bypass" || v.specifier === undefined || v.target === undefined)
|
|
125
|
+
return undefined;
|
|
126
|
+
return { specifier: v.specifier, target: v.target };
|
|
127
|
+
}
|
|
128
|
+
export function fingerprintOf(v) {
|
|
129
|
+
const identity = bypassIdentity(v);
|
|
130
|
+
const key = identity !== undefined
|
|
131
|
+
? `${v.rule}\n${v.path}\n${identity.specifier}\n${identity.target}`
|
|
132
|
+
: pathExcludedFromKey(v.rule)
|
|
133
|
+
? `${v.rule}\n${stableEvidence(v.rule, v.evidence)}`
|
|
134
|
+
: `${v.rule}\n${v.path}\n${stableEvidence(v.rule, v.evidence)}`;
|
|
135
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 12);
|
|
136
|
+
}
|
|
137
|
+
// Migration only: a public-surface-bypass entry frozen before specifier/
|
|
138
|
+
// target existed carries only the sentence violationFor built. Both of
|
|
139
|
+
// evidence's own sentence shapes ("resolved to module 'm', which has no
|
|
140
|
+
// ..." and "resolved to a file inside module 'm' other than its ...")
|
|
141
|
+
// start with the same quoted specifier, so a small, fixed prefix
|
|
142
|
+
// recovers it without knowing which shape produced this entry. This
|
|
143
|
+
// parse exists only for an old entry with no stored `specifier` - a
|
|
144
|
+
// fresh one always carries the field, and skips it entirely.
|
|
145
|
+
function parseSpecifierFromLegacyBypassEvidence(evidence) {
|
|
146
|
+
const match = /^'(.+?)' resolved to /.exec(evidence);
|
|
147
|
+
return match?.[1];
|
|
148
|
+
}
|
|
149
|
+
export function buildTodoIndex(entries) {
|
|
150
|
+
const byFingerprint = new Map();
|
|
151
|
+
const byLegacyBypassKey = new Map();
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
// Keyed by a FRESH recompute from the entry's own stored fields
|
|
154
|
+
// (already project-relative - readTodoFile normalizes a legacy
|
|
155
|
+
// absolute one before this ever runs), never by a stored fingerprint
|
|
156
|
+
// string: this file's own entries carry no such field at all - a
|
|
157
|
+
// stored copy could only ever drift from what matching actually needs
|
|
158
|
+
// (today's recompute), and a reader wanting to name an entry uses its
|
|
159
|
+
// own line in the file (ParsedTodoFile.entryLocation) instead. A value
|
|
160
|
+
// read from an entry migrated off the old per-module layout would
|
|
161
|
+
// anyway be a hash of whatever formula was current when it was
|
|
162
|
+
// frozen, not today's. A rule whose formula hasn't changed recomputes
|
|
163
|
+
// to the exact same value it always had; a rule whose formula changed
|
|
164
|
+
// (type-leak's own path exclusion, tag-order's own sequence-display
|
|
165
|
+
// exclusion) recomputes to the value it always should have had, with
|
|
166
|
+
// no rule-specific migration needed at all - stableEvidence is a pure
|
|
167
|
+
// function of the evidence text alone, unaffected by which archstrict
|
|
168
|
+
// version produced it. Only public-surface-bypass has a real
|
|
169
|
+
// pre-migration format (no stored specifier/target at all, not just a
|
|
170
|
+
// different formula over the same fields), which is what
|
|
171
|
+
// byLegacyBypassKey is for.
|
|
172
|
+
byFingerprint.set(fingerprintOf(entry), entry);
|
|
173
|
+
if (entry.rule === "public-surface-bypass" && entry.specifier === undefined) {
|
|
174
|
+
const specifier = parseSpecifierFromLegacyBypassEvidence(entry.evidence);
|
|
175
|
+
if (specifier !== undefined)
|
|
176
|
+
byLegacyBypassKey.set(`${entry.path}\n${specifier}`, entry);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { byFingerprint, byLegacyBypassKey };
|
|
180
|
+
}
|
|
181
|
+
export const EMPTY_TODO_INDEX = { byFingerprint: new Map(), byLegacyBypassKey: new Map() };
|
|
182
|
+
// The one place check.ts/todo.ts ask "does some entry in this index still
|
|
183
|
+
// name this live violation" - a fingerprint lookup first (covers every
|
|
184
|
+
// rule, recomputed identically on both sides - see buildTodoIndex's own
|
|
185
|
+
// comment), falling back to the legacy (path, specifier) index only for a
|
|
186
|
+
// public-surface-bypass violation, since that's the only rule with a
|
|
187
|
+
// recorded pre-migration format at all. `relativePath` puts `v`'s own
|
|
188
|
+
// (absolute) path/target into the same project-relative form entries are
|
|
189
|
+
// always stored in, for BOTH branches - the primary lookup needs this
|
|
190
|
+
// exactly as much as the fallback does (fingerprintOf never relativizes
|
|
191
|
+
// on its own; see relativizeForTodo's own comment for why a caller must).
|
|
192
|
+
export function findMatchingEntry(index, v, relativePath) {
|
|
193
|
+
const relativized = relativizeForTodo(v, relativePath);
|
|
194
|
+
const exact = index.byFingerprint.get(fingerprintOf(relativized));
|
|
195
|
+
if (exact !== undefined)
|
|
196
|
+
return exact;
|
|
197
|
+
if (relativized.rule !== "public-surface-bypass")
|
|
198
|
+
return undefined;
|
|
199
|
+
const identity = bypassIdentity(relativized);
|
|
200
|
+
if (identity === undefined)
|
|
201
|
+
return undefined;
|
|
202
|
+
return index.byLegacyBypassKey.get(`${relativized.path}\n${identity.specifier}`);
|
|
203
|
+
}
|
|
204
|
+
// Builds the on-disk row for a live violation: `path`/`target` relativized,
|
|
205
|
+
// and specifier/target included only when the violation itself carries
|
|
206
|
+
// them (public-surface-bypass). No stored fingerprint (see writeTodoFile's
|
|
207
|
+
// own comment) - a reader that wants one recomputes it with fingerprintOf.
|
|
208
|
+
// Used both to freeze a brand-new entry and to refresh one that survived
|
|
209
|
+
// pruning, so an entry is always in the current format after either verb
|
|
210
|
+
// runs over it, not just at first freeze.
|
|
211
|
+
export function buildTodoEntry(v, relativePath) {
|
|
212
|
+
const relativized = relativizeForTodo(v, relativePath);
|
|
213
|
+
const base = { rule: relativized.rule, path: relativized.path, evidence: relativized.evidence };
|
|
214
|
+
return relativized.specifier !== undefined && relativized.target !== undefined
|
|
215
|
+
? { ...base, specifier: relativized.specifier, target: relativized.target }
|
|
216
|
+
: base;
|
|
217
|
+
}
|
|
218
|
+
export const TODO_FILE_NAME = "archstrict.todo.json";
|
|
219
|
+
// Bumped only if this shape itself ever changes again - readTodoFile
|
|
220
|
+
// refuses a file stamped with a version it doesn't recognize (a newer
|
|
221
|
+
// archstrict wrote it, or a hand edit changed the number) rather than
|
|
222
|
+
// silently misreading it.
|
|
223
|
+
export const TODO_SCHEMA_VERSION = 1;
|
|
224
|
+
export function todoFilePath(projectRoot) {
|
|
225
|
+
return join(projectRoot, TODO_FILE_NAME);
|
|
226
|
+
}
|
|
227
|
+
function propertyKeyName(name) {
|
|
228
|
+
return ts.isStringLiteral(name) || ts.isIdentifier(name) ? name.text : undefined;
|
|
229
|
+
}
|
|
230
|
+
function locationOf(source, pos) {
|
|
231
|
+
const { line, character } = source.getLineAndCharacterOfPosition(pos);
|
|
232
|
+
return { line: line + 1, column: character + 1 };
|
|
233
|
+
}
|
|
234
|
+
const ENTRY_STRING_FIELDS = ["rule", "path", "evidence", "specifier", "target"];
|
|
235
|
+
// `projectRoot`, when given, normalizes a raw absolute `path`/`target`
|
|
236
|
+
// (a hand-written fixture, or a file from before entries were always
|
|
237
|
+
// stored relative) into the project-relative POSIX form buildTodoEntry
|
|
238
|
+
// always writes today - the same normalization the pre-single-file
|
|
239
|
+
// readTodo already applied on every read, kept here so a caller never has
|
|
240
|
+
// to special-case an absolute entry itself.
|
|
241
|
+
function entryFromObjectLiteral(el, projectRoot) {
|
|
242
|
+
const fields = {};
|
|
243
|
+
for (const prop of el.properties) {
|
|
244
|
+
if (!ts.isPropertyAssignment(prop))
|
|
245
|
+
continue;
|
|
246
|
+
const key = propertyKeyName(prop.name);
|
|
247
|
+
if (key === undefined || !ts.isStringLiteral(prop.initializer))
|
|
248
|
+
continue;
|
|
249
|
+
if (ENTRY_STRING_FIELDS.includes(key))
|
|
250
|
+
fields[key] = prop.initializer.text;
|
|
251
|
+
}
|
|
252
|
+
if (fields.rule === undefined || fields.path === undefined || fields.evidence === undefined)
|
|
253
|
+
return undefined;
|
|
254
|
+
const path = projectRoot !== undefined && isAbsolute(fields.path) ? toProjectRelativePosix(fields.path, projectRoot) : fields.path;
|
|
255
|
+
const entry = { rule: fields.rule, path, evidence: fields.evidence };
|
|
256
|
+
if (fields.specifier !== undefined && fields.target !== undefined) {
|
|
257
|
+
// `specifier` is an import specifier, never a filesystem path - no
|
|
258
|
+
// normalization applies to it.
|
|
259
|
+
entry.specifier = fields.specifier;
|
|
260
|
+
entry.target = projectRoot !== undefined && isAbsolute(fields.target)
|
|
261
|
+
? toProjectRelativePosix(fields.target, projectRoot)
|
|
262
|
+
: fields.target;
|
|
263
|
+
}
|
|
264
|
+
return entry;
|
|
265
|
+
}
|
|
266
|
+
function malformed(path) {
|
|
267
|
+
return new ReportError(`${path}: not a valid archstrict.todo.json (expected { schemaVersion, modules })`, "restore it from version control, or delete it and run archstrict todo to regenerate it");
|
|
268
|
+
}
|
|
269
|
+
// Parses archstrict.todo.json's own text directly (not JSON.parse, which
|
|
270
|
+
// would give back plain values with no position information at all) -
|
|
271
|
+
// exported so a Hegel round-trip test can feed writeTodoFile's own output
|
|
272
|
+
// straight back in without going through the filesystem.
|
|
273
|
+
export function parseTodoFileText(path, text, projectRoot) {
|
|
274
|
+
const source = ts.parseJsonText(path, text);
|
|
275
|
+
const root = source.statements[0]?.expression;
|
|
276
|
+
if (root === undefined || !ts.isObjectLiteralExpression(root))
|
|
277
|
+
throw malformed(path);
|
|
278
|
+
let schemaVersion;
|
|
279
|
+
const modules = new Map();
|
|
280
|
+
const moduleKeyLocation = new Map();
|
|
281
|
+
const entryLocation = new Map();
|
|
282
|
+
for (const prop of root.properties) {
|
|
283
|
+
if (!ts.isPropertyAssignment(prop))
|
|
284
|
+
continue;
|
|
285
|
+
const key = propertyKeyName(prop.name);
|
|
286
|
+
if (key === "schemaVersion" && ts.isNumericLiteral(prop.initializer)) {
|
|
287
|
+
schemaVersion = Number(prop.initializer.text);
|
|
288
|
+
}
|
|
289
|
+
else if (key === "modules" && ts.isObjectLiteralExpression(prop.initializer)) {
|
|
290
|
+
for (const moduleProp of prop.initializer.properties) {
|
|
291
|
+
if (!ts.isPropertyAssignment(moduleProp))
|
|
292
|
+
continue;
|
|
293
|
+
const name = propertyKeyName(moduleProp.name);
|
|
294
|
+
if (name === undefined)
|
|
295
|
+
continue;
|
|
296
|
+
moduleKeyLocation.set(name, locationOf(source, moduleProp.name.getStart(source)));
|
|
297
|
+
const entries = [];
|
|
298
|
+
if (ts.isArrayLiteralExpression(moduleProp.initializer)) {
|
|
299
|
+
for (const el of moduleProp.initializer.elements) {
|
|
300
|
+
if (!ts.isObjectLiteralExpression(el))
|
|
301
|
+
continue;
|
|
302
|
+
const entry = entryFromObjectLiteral(el, projectRoot);
|
|
303
|
+
if (entry === undefined)
|
|
304
|
+
continue;
|
|
305
|
+
entries.push(entry);
|
|
306
|
+
entryLocation.set(entry, locationOf(source, el.getStart(source)));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
modules.set(name, entries);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (schemaVersion === undefined)
|
|
314
|
+
throw malformed(path);
|
|
315
|
+
if (schemaVersion !== TODO_SCHEMA_VERSION) {
|
|
316
|
+
throw new ReportError(`${path}: schemaVersion ${schemaVersion} is not supported (archstrict writes ${TODO_SCHEMA_VERSION})`, "upgrade archstrict, or delete the file and run archstrict todo to regenerate it");
|
|
317
|
+
}
|
|
318
|
+
return { schemaVersion, path, modules, moduleKeyLocation, entryLocation };
|
|
319
|
+
}
|
|
320
|
+
export function readTodoFile(projectRoot) {
|
|
321
|
+
const p = todoFilePath(projectRoot);
|
|
322
|
+
if (!existsSync(p))
|
|
323
|
+
return undefined;
|
|
324
|
+
const text = readFileSync(p, "utf8");
|
|
325
|
+
// JSON.parse first, ahead of ts.parseJsonText (used below only for
|
|
326
|
+
// positions): ts.parseJsonText recovers from a syntax error by parsing
|
|
327
|
+
// whatever prefix it can and returning the rest as an error node, rather
|
|
328
|
+
// than throwing - exactly the shape an unresolved git merge conflict
|
|
329
|
+
// marker or a truncated write leaves behind. Silently reading a partial
|
|
330
|
+
// parse would make `todo` write back only the entries that happened to
|
|
331
|
+
// survive the truncation, discarding the rest - the one failure mode
|
|
332
|
+
// this whole layout (one entry per line, so a genuine three-way merge
|
|
333
|
+
// resolves cleanly) exists to avoid. JSON.parse rejects that same input
|
|
334
|
+
// outright, so a real syntax error surfaces as a ReportError instead of
|
|
335
|
+
// silent data loss.
|
|
336
|
+
let raw;
|
|
337
|
+
try {
|
|
338
|
+
raw = JSON.parse(text);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
throw malformed(p);
|
|
342
|
+
}
|
|
343
|
+
// Undefined (not thrown) ONLY for the one shape a legacy file at this
|
|
344
|
+
// exact path can actually have: { entries: [...] }, no schemaVersion at
|
|
345
|
+
// all - a module whose own glob covers the project root itself (e.g.
|
|
346
|
+
// "**") has its legacy per-module path equal to this new file's own
|
|
347
|
+
// path (todo-store.ts's own todoFilePath and todo-migration.ts's own
|
|
348
|
+
// legacy path collide there). The caller (todo.ts, check.ts's own
|
|
349
|
+
// readCurrentTodo) then falls back to todo-migration.ts's own reader,
|
|
350
|
+
// which recognizes that shape. Anything else missing schemaVersion - a
|
|
351
|
+
// hand-edited `{ "modules": {...} }` that lost its version, a bare `{}`,
|
|
352
|
+
// any other malformed object - is NOT silently read as "absent": that
|
|
353
|
+
// would make the next `todo` run treat it as a genuine first run and
|
|
354
|
+
// overwrite it, discarding whatever was really there.
|
|
355
|
+
if (typeof raw === "object" && raw !== null && !("schemaVersion" in raw)
|
|
356
|
+
&& Array.isArray(raw.entries)) {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
if (typeof raw !== "object" || raw === null || !("schemaVersion" in raw))
|
|
360
|
+
throw malformed(p);
|
|
361
|
+
return parseTodoFileText(p, text, projectRoot);
|
|
362
|
+
}
|
|
363
|
+
// Plain code-unit order, never String.prototype.localeCompare: locale
|
|
364
|
+
// collation (accents, case, punctuation folding) depends on the ICU data
|
|
365
|
+
// installed on whichever machine runs `archstrict todo`, so two
|
|
366
|
+
// developers on two locales could write two different byte orderings for
|
|
367
|
+
// the identical entry set - defeating the whole point of a canonical,
|
|
368
|
+
// diffable serialization.
|
|
369
|
+
function codeUnitCompare(a, b) {
|
|
370
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
371
|
+
}
|
|
372
|
+
function serializeEntry(entry) {
|
|
373
|
+
const ordered = entry.specifier !== undefined && entry.target !== undefined
|
|
374
|
+
? { rule: entry.rule, path: entry.path, evidence: entry.evidence, specifier: entry.specifier, target: entry.target }
|
|
375
|
+
: { rule: entry.rule, path: entry.path, evidence: entry.evidence };
|
|
376
|
+
return JSON.stringify(ordered);
|
|
377
|
+
}
|
|
378
|
+
// The sort that makes two branches touching different modules produce a
|
|
379
|
+
// text diff confined to those modules' own blocks, and two branches that
|
|
380
|
+
// each delete a different entry from the SAME module merge cleanly (each
|
|
381
|
+
// entry is its own line - deleting one line in each branch is an ordinary
|
|
382
|
+
// three-way text merge, not a JSON-structural one): module names in
|
|
383
|
+
// order, then within a module, entries by path, then rule, then
|
|
384
|
+
// fingerprint (the same identity fingerprintOf already gives every entry,
|
|
385
|
+
// reused here purely as a deterministic tiebreak - two entries that share
|
|
386
|
+
// path AND rule but differ in specifier/target, e.g. two distinct
|
|
387
|
+
// public-surface-bypass edges into the same target from the same importer
|
|
388
|
+
// via two different specifiers, would otherwise sort in whatever order
|
|
389
|
+
// they happened to arrive in), then the entry's own serialized line as a
|
|
390
|
+
// final tiebreak (two type-leak entries can share path, rule, AND
|
|
391
|
+
// fingerprint while differing only in evidence's own mutable
|
|
392
|
+
// "referenced by" suffix - stableEvidence strips that suffix before
|
|
393
|
+
// hashing, so it never enters the fingerprint at all).
|
|
394
|
+
function sortedEntries(entries) {
|
|
395
|
+
return [...entries].sort((a, b) => {
|
|
396
|
+
return codeUnitCompare(a.path, b.path)
|
|
397
|
+
|| codeUnitCompare(a.rule, b.rule)
|
|
398
|
+
|| codeUnitCompare(fingerprintOf(a), fingerprintOf(b))
|
|
399
|
+
|| codeUnitCompare(serializeEntry(a), serializeEntry(b));
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
// Hand-built, not JSON.stringify(file, null, 2): stringify's own pretty
|
|
403
|
+
// printer wraps one entry object across several lines, which would make a
|
|
404
|
+
// single added or removed field inside one entry look, to a line-based
|
|
405
|
+
// diff/merge, like it touched every entry after it in the same array.
|
|
406
|
+
// One compact JSON object per line keeps a diff (and a merge) confined to
|
|
407
|
+
// exactly the lines that changed.
|
|
408
|
+
export function serializeTodoFile(modulesByName) {
|
|
409
|
+
const names = [...modulesByName.keys()]
|
|
410
|
+
.filter((name) => (modulesByName.get(name)?.length ?? 0) > 0)
|
|
411
|
+
.sort(codeUnitCompare);
|
|
412
|
+
const lines = ["{", ` "schemaVersion": ${TODO_SCHEMA_VERSION},`, ' "modules": {'];
|
|
413
|
+
names.forEach((name, moduleIndex) => {
|
|
414
|
+
const entries = sortedEntries(modulesByName.get(name) ?? []);
|
|
415
|
+
lines.push(` ${JSON.stringify(name)}: [`);
|
|
416
|
+
entries.forEach((entry, entryIndex) => {
|
|
417
|
+
lines.push(` ${serializeEntry(entry)}${entryIndex < entries.length - 1 ? "," : ""}`);
|
|
418
|
+
});
|
|
419
|
+
lines.push(` ]${moduleIndex < names.length - 1 ? "," : ""}`);
|
|
420
|
+
});
|
|
421
|
+
lines.push(" }", "}");
|
|
422
|
+
return lines.join("\n") + "\n";
|
|
423
|
+
}
|
|
424
|
+
// Always writes the file, even with an empty module map (a project with
|
|
425
|
+
// no debt after its first run still gets one, so the ratchet's own state
|
|
426
|
+
// - "todo has run" - stays visible on disk instead of looking identical
|
|
427
|
+
// to "todo has never run"). Never deletes it: unlike the old per-module
|
|
428
|
+
// file (which vanished the moment a module's own debt hit zero),
|
|
429
|
+
// existence of the single root file IS the "first run happened" signal
|
|
430
|
+
// now - see todo.ts's own firstRun check, which replaces the old
|
|
431
|
+
// `.archstrict-todo-initialized` marker with this file's own existence.
|
|
432
|
+
export function writeTodoFile(projectRoot, modulesByName) {
|
|
433
|
+
writeFileSync(todoFilePath(projectRoot), serializeTodoFile(modulesByName));
|
|
434
|
+
}
|