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,75 @@
1
+ // Responsibility: rule 3, uncovered files (deptrac's --fail-on-uncovered,
2
+ // generalized to v1's file-glob model). A real, in-scope file matching no
3
+ // declared module fails the check - the implementation of the project's
4
+ // own rule that a zero must never look like success when it is really an
5
+ // omission (a check that silently skipped a file must not look like that
6
+ // file passed). Under v0's directory-based discovery this was "a module
7
+ // no kind names"; under v1's glob-based declaredModules, the same idea is
8
+ // simpler and needs no pattern-matching of its own: module-graph.ts's own
9
+ // `outsideFiles` already tracks exactly this (a file in scope, matching no
10
+ // declared module) - this rule only reports it as a violation, one per
11
+ // file, instead of silence.
12
+ // Boundary: pure predicate over a ModuleGraph. No I/O, no output formatting;
13
+ // grouping the uncovered files into one paste-ready declaredModules
14
+ // suggestion per directory (or per loose file) is module-candidates.ts's
15
+ // job, shared with init's re-run and `archstrict rules <path>` so all three
16
+ // print the identical entry text for the same file.
17
+ import {} from "../module-graph.js";
18
+ import { groupForRelFile, suggestUncovered, suggestionDoText } from "../module-candidates.js";
19
+ const BECAUSE = "a file matching no declared module is unchecked, not passing";
20
+ // `group` is the caller's own suggestion for this file (from
21
+ // `suggestUncovered`/`groupForRelFile`) - a bare declaredModules entry with
22
+ // no `surface` would make a single-file module entirely private (its
23
+ // default surface, index.ts, resolves to a different file), so the do:
24
+ // text always carries the file's own name as `surface` for a file group.
25
+ export function uncoveredViolationFor(file, rootDir, group) {
26
+ // `path` stays absolute (a location every other rule's own `path`
27
+ // points at) - only the glob suggested in `do` needs to be
28
+ // project-relative, since that's a value meant to be pasted directly
29
+ // into declaredModules[].glob or exclude, both of which are always
30
+ // project-relative (config.md).
31
+ return {
32
+ rule: "uncovered-module",
33
+ path: file,
34
+ line: 1,
35
+ column: 1,
36
+ evidence: `'${file}' is in scope but matches no declared module`,
37
+ because: BECAUSE,
38
+ do: suggestionDoText(group),
39
+ };
40
+ }
41
+ // Every violation this rule returns is reported at `file` itself - any
42
+ // outside file at all, not one fixed path the way config.configPath-only
43
+ // rules report - but unlike rule 1's edges, an outside file's own
44
+ // naming/grouping (`suggestUncovered`, `nameCandidates`) is NOT
45
+ // independent per file: a group's own name can depend on colliding with
46
+ // ANOTHER group's on-disk name (module-candidates.ts's own
47
+ // `nameCandidates`), computed over every outside file at once. Narrowing
48
+ // `relFiles` down to just the focus file before grouping would compute
49
+ // that collision check against the wrong, smaller universe and could
50
+ // silently pick a different (wrong) name than the unscoped run - so
51
+ // `suggestUncovered` still runs over the WHOLE `relFiles` list regardless
52
+ // of `focus`, the same full-graph computation checkCycles also keeps.
53
+ // `focus`, when given, only skips building a Violation object (and its
54
+ // evidence/do strings) for a file whose own path isn't the focus file -
55
+ // `file` is already an absolute, real path (module-graph.ts's own
56
+ // `outsideFiles`), so it's compared directly, the same invariant rule 1's
57
+ // own comment documents.
58
+ export function checkUncoveredModules(graph, config, focus) {
59
+ const relFiles = graph.outsideFiles.map(graph.relativePath);
60
+ const groups = suggestUncovered(relFiles, config.declaredModules ?? []);
61
+ // `graph.outsideFiles` follows the edge build's own walk order (rootNames
62
+ // order - a directory scan, not a promise about reading order across
63
+ // files), not a promise about output order - sorted here by path so
64
+ // this rule's own output stays stable regardless of it.
65
+ return graph.outsideFiles.flatMap((file, i) => {
66
+ if (focus !== undefined && file !== focus)
67
+ return [];
68
+ const group = groupForRelFile(relFiles[i], groups);
69
+ // Every file in `relFiles` was grouped by the same call, so a match
70
+ // always exists - `suggestUncovered` never drops a file it was given.
71
+ return [uncoveredViolationFor(file, graph.rootDir, group)];
72
+ // Code-unit order (`<`/`>`), not localeCompare: a locale-aware compare
73
+ // can order the same two paths differently on different machines.
74
+ }).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
75
+ }
@@ -0,0 +1,112 @@
1
+ // Responsibility: reads the per-module todo layout archstrict wrote before
2
+ // the single-file archstrict.todo.json existed - one archstrict.todo.json
3
+ // inside each directory module, one <file>.archstrict.todo.json beside each
4
+ // single-file module, and a root .archstrict-todo-initialized marker - so
5
+ // todo.ts can fold it into the new file once, and check.ts can still pass
6
+ // on a project that adopted archstrict under the old layout and hasn't run
7
+ // `archstrict todo` since.
8
+ // Boundary: reading and detecting the OLD layout only. The new layout
9
+ // lives in todo-store.ts, which this module depends on but never the
10
+ // reverse. Slated for removal once the old layout is old enough that no
11
+ // adopted project could still be on it - the first release after this one.
12
+ import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
13
+ import { basename, dirname, isAbsolute, join } from "node:path";
14
+ import { toProjectRelativePosix } from "./module-graph.js";
15
+ import { todoFilePath } from "./todo-store.js";
16
+ export const LEGACY_MARKER_NAME = ".archstrict-todo-initialized";
17
+ function legacyMarkerPath(projectRoot) {
18
+ return join(projectRoot, LEGACY_MARKER_NAME);
19
+ }
20
+ function pathIsFile(path) {
21
+ try {
22
+ return statSync(path).isFile();
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ // Mirrors the pre-migration `todoPath`: a directory module keeps its todo
29
+ // inside itself; a single-file module (the glob names one file, with no
30
+ // directory of its own to hold a sibling archstrict.todo.json) keeps it
31
+ // beside the file, named from the file's own basename.
32
+ function legacyModuleTodoPath(moduleDir) {
33
+ if (pathIsFile(moduleDir))
34
+ return join(dirname(moduleDir), `${basename(moduleDir)}.archstrict.todo.json`);
35
+ return join(moduleDir, "archstrict.todo.json");
36
+ }
37
+ // A legacy entry's own stored `path` (and, for public-surface-bypass,
38
+ // `target`) is project-relative UNLESS it was frozen by an archstrict old
39
+ // enough to have stored the raw absolute form - normalized here the same
40
+ // way the pre-migration readTodo already did, so a file this old still
41
+ // matches today's algorithm without a dedicated migration step of its own.
42
+ function normalizeLegacyEntry(entry, projectRoot) {
43
+ const path = isAbsolute(entry.path) ? toProjectRelativePosix(entry.path, projectRoot) : entry.path;
44
+ const target = entry.target !== undefined && isAbsolute(entry.target)
45
+ ? toProjectRelativePosix(entry.target, projectRoot)
46
+ : entry.target;
47
+ return target === undefined ? { ...entry, path } : { ...entry, path, target };
48
+ }
49
+ function readLegacyModuleTodo(moduleDir, projectRoot) {
50
+ const p = legacyModuleTodoPath(moduleDir);
51
+ if (!existsSync(p))
52
+ return [];
53
+ const entries = JSON.parse(readFileSync(p, "utf8")).entries;
54
+ // Old entries carried a `fingerprint` field that today's shape drops
55
+ // (todo-store.ts's own comment on why it's no longer stored) - stripped
56
+ // here rather than carried forward into the new file.
57
+ return entries.map((e) => {
58
+ const { fingerprint: _fingerprint, ...rest } = e;
59
+ return normalizeLegacyEntry(rest, projectRoot);
60
+ });
61
+ }
62
+ // `moduleDirs` - a Map<name, dir> - comes from the live module graph (the
63
+ // caller already built one for `check`/`todo`'s own run), not from
64
+ // re-declaring modules here: a module renamed or removed since the old
65
+ // layout was written is exactly the case this function cannot help with
66
+ // (see entriesByModule's own comment), and the caller decides what happens
67
+ // to entries under a name this run no longer declares.
68
+ export function readLegacyTodoState(projectRoot, moduleDirs) {
69
+ // A module whose own glob covers the project root itself (e.g. "**")
70
+ // has its legacy per-module path (join(dir, "archstrict.todo.json"))
71
+ // equal to the new single file's own path - todo-store.ts's own
72
+ // readTodoFile already treats a file at this path with no schemaVersion
73
+ // as "the new shape isn't present" and returns undefined so this
74
+ // function runs at all. That file must never be queued for deletion:
75
+ // freezeOrPrune writes the migrated, schema-versioned content to this
76
+ // SAME path before deleteLegacyTodoFiles ever runs, and deleting it
77
+ // afterward would erase the migration this run just did, not an old
78
+ // file left behind by it.
79
+ const newFilePath = todoFilePath(projectRoot);
80
+ const entriesByModule = new Map();
81
+ const filesToDelete = [];
82
+ // Counts every legacy file FOUND, even the one colliding path excluded
83
+ // from filesToDelete above - `present` must stay true for that case too
84
+ // (the project genuinely was on the old layout), not fall back to only
85
+ // filesToDelete's own length, which the collision deliberately excludes.
86
+ let legacyFilesFound = 0;
87
+ for (const [name, dir] of moduleDirs) {
88
+ const p = legacyModuleTodoPath(dir);
89
+ if (!existsSync(p))
90
+ continue;
91
+ legacyFilesFound++;
92
+ if (p !== newFilePath)
93
+ filesToDelete.push(p);
94
+ const entries = readLegacyModuleTodo(dir, projectRoot);
95
+ if (entries.length > 0)
96
+ entriesByModule.set(name, entries);
97
+ }
98
+ const marker = legacyMarkerPath(projectRoot);
99
+ const markerPresent = existsSync(marker);
100
+ if (markerPresent)
101
+ filesToDelete.push(marker);
102
+ return { entriesByModule, filesToDelete, present: markerPresent || legacyFilesFound > 0 };
103
+ }
104
+ // Deletes every legacy file this run found, once the new single file has
105
+ // already been written - never before, so a crash between the two leaves
106
+ // the old layout still fully readable rather than silently dropping debt.
107
+ export function deleteLegacyTodoFiles(files) {
108
+ for (const f of files) {
109
+ if (existsSync(f))
110
+ unlinkSync(f);
111
+ }
112
+ }
@@ -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 "./rules/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
+ }