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.
Files changed (67) 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 +81 -0
  7. package/CHANGELOG.md +77 -0
  8. package/README.ja.md +142 -0
  9. package/README.md +143 -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 +243 -0
  14. package/dist/config-pointer.js +251 -0
  15. package/dist/config.js +194 -0
  16. package/dist/edge-cache.js +530 -0
  17. package/dist/gitignore.js +271 -0
  18. package/dist/mcp-server.js +111 -0
  19. package/dist/module-candidates.js +125 -0
  20. package/dist/module-graph.js +2179 -0
  21. package/dist/project-path.js +59 -0
  22. package/dist/report-error.js +13 -0
  23. package/dist/rules/config-meaning.js +143 -0
  24. package/dist/rules/constraints.js +419 -0
  25. package/dist/rules/cycles.js +285 -0
  26. package/dist/rules/deprecated.js +67 -0
  27. package/dist/rules/empty-rule.js +101 -0
  28. package/dist/rules/moves.js +79 -0
  29. package/dist/rules/must-be-empty.js +52 -0
  30. package/dist/rules/public-surface.js +100 -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/type-leak.js +590 -0
  36. package/dist/verbs/agents.js +116 -0
  37. package/dist/verbs/check.js +1011 -0
  38. package/dist/verbs/fix.js +170 -0
  39. package/dist/verbs/hotspots.js +261 -0
  40. package/dist/verbs/init.js +538 -0
  41. package/dist/verbs/map-shape.js +78 -0
  42. package/dist/verbs/recommend.js +863 -0
  43. package/dist/verbs/rules.js +188 -0
  44. package/dist/verbs/search.js +109 -0
  45. package/dist/verbs/simulate.js +220 -0
  46. package/dist/verbs/todo.js +180 -0
  47. package/dist/warm-graph.js +82 -0
  48. package/docs/boundary-patterns.md +374 -0
  49. package/docs/calibrated-rules-design.md +124 -0
  50. package/docs/init-singleton-modules.md +133 -0
  51. package/docs/maintenance.md +109 -0
  52. package/docs/releasing.md +58 -0
  53. package/docs/rules-edge-cache.md +50 -0
  54. package/docs/todo-single-file-migration.md +58 -0
  55. package/llms.txt +25 -0
  56. package/package.json +61 -4
  57. package/skills/archstrict/SKILL.md +54 -0
  58. package/skills/archstrict/references/agents-verb.md +39 -0
  59. package/skills/archstrict/references/config.md +116 -0
  60. package/skills/archstrict/references/hook.md +57 -0
  61. package/skills/archstrict/references/path-rules.md +57 -0
  62. package/skills/archstrict/references/patterns.md +915 -0
  63. package/skills/archstrict/references/prove-rules.md +58 -0
  64. package/skills/archstrict/references/rearchitect.md +66 -0
  65. package/skills/archstrict/references/recommend.md +98 -0
  66. package/skills/archstrict/references/rules.md +149 -0
  67. package/skills/archstrict/references/simulate.md +109 -0
@@ -0,0 +1,271 @@
1
+ // Responsibility: decide whether a project-relative path is gitignored, by
2
+ // parsing .gitignore files and the repository's info/exclude with git's own
3
+ // pattern rules (negation, directory-only patterns, anchoring, `**`), so the
4
+ // project walk can skip scratch output that was never project source.
5
+ // Boundary: this module reads ignore files only. It never spawns git, never
6
+ // walks a directory tree (module-graph.ts's walk calls in per directory), and
7
+ // never reads the user's global core.excludesFile: that file differs from
8
+ // machine to machine, so honoring it would make a laptop and CI analyze
9
+ // different file sets from the same checkout.
10
+ //
11
+ // Parsing instead of `git ls-files --ignored` keeps the walk independent of
12
+ // git: a copy of a checkout with no .git directory, or a machine with no git
13
+ // binary, still skips what the checkout's own .gitignore files name.
14
+ import { existsSync, readFileSync, statSync } from "node:fs";
15
+ import { dirname, join, relative, resolve, sep } from "node:path";
16
+ function makeLayer(rules, prefix, strip) {
17
+ const literalAny = new Map();
18
+ const literalDir = new Map();
19
+ const patterned = [];
20
+ rules.forEach((rule, index) => {
21
+ if (rule.literal === undefined)
22
+ patterned.push(index);
23
+ else
24
+ (rule.dirOnly ? literalDir : literalAny).set(rule.literal, index);
25
+ });
26
+ return { rules, prefix, strip, literalAny, literalDir, patterned };
27
+ }
28
+ function escapeRegex(char) {
29
+ return /[\\^$.*+?()[\]{}|/]/.test(char) ? `\\${char}` : char;
30
+ }
31
+ // One path segment (no "/") of a gitignore pattern, as a regex source.
32
+ function translateSegment(segment) {
33
+ let out = "";
34
+ for (let i = 0; i < segment.length; i++) {
35
+ const char = segment[i];
36
+ if (char === "\\" && i + 1 < segment.length) {
37
+ out += escapeRegex(segment[++i]);
38
+ }
39
+ else if (char === "*") {
40
+ // A "**" that is not a whole segment is an ordinary "*" (git's rule).
41
+ while (segment[i + 1] === "*")
42
+ i++;
43
+ out += "[^/]*";
44
+ }
45
+ else if (char === "?") {
46
+ out += "[^/]";
47
+ }
48
+ else if (char === "[") {
49
+ const close = segment.indexOf("]", i + 2);
50
+ if (close === -1) {
51
+ out += "\\[";
52
+ continue;
53
+ }
54
+ let body = segment.slice(i + 1, close);
55
+ let negated = false;
56
+ if (body.startsWith("!") || body.startsWith("^")) {
57
+ negated = true;
58
+ body = body.slice(1);
59
+ }
60
+ out += `[${negated ? "^" : ""}${body.replace(/[\\\]^]/g, (c) => `\\${c}`)}]`;
61
+ i = close;
62
+ }
63
+ else {
64
+ out += escapeRegex(char);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ // Exported for the parity test against `git check-ignore`.
70
+ export function parseGitignore(text) {
71
+ const rules = [];
72
+ for (const rawLine of text.split("\n")) {
73
+ let line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
74
+ if (line === "" || line.startsWith("#"))
75
+ continue;
76
+ // Trailing spaces are dropped unless the last one is escaped.
77
+ while (line.endsWith(" ") && !line.endsWith("\\ "))
78
+ line = line.slice(0, -1);
79
+ let negate = false;
80
+ if (line.startsWith("!")) {
81
+ negate = true;
82
+ line = line.slice(1);
83
+ }
84
+ let dirOnly = false;
85
+ if (line.endsWith("/")) {
86
+ dirOnly = true;
87
+ line = line.slice(0, -1);
88
+ }
89
+ if (line === "")
90
+ continue;
91
+ // A slash at the start or in the middle anchors the pattern to the
92
+ // ignore file's own directory; otherwise it matches at any depth.
93
+ const anchored = line.includes("/");
94
+ if (line.startsWith("/"))
95
+ line = line.slice(1);
96
+ const segments = line.split("/");
97
+ let source = "";
98
+ segments.forEach((segment, index) => {
99
+ const last = index === segments.length - 1;
100
+ if (segment === "**") {
101
+ source += last ? ".*" : "(?:.*/)?";
102
+ return;
103
+ }
104
+ source += translateSegment(segment) + (last ? "" : "/");
105
+ });
106
+ const regex = new RegExp(anchored ? `^${source}$` : `^(?:.*/)?${source}$`);
107
+ const literal = !anchored && !/[*?[\\]/.test(line) ? line : undefined;
108
+ rules.push(literal === undefined ? { regex, negate, dirOnly } : { regex, negate, dirOnly, literal });
109
+ }
110
+ return rules;
111
+ }
112
+ function toPosix(path) {
113
+ return sep === "/" ? path : path.split(sep).join("/");
114
+ }
115
+ function readText(path) {
116
+ try {
117
+ return readFileSync(path, "utf8");
118
+ }
119
+ catch {
120
+ return undefined;
121
+ }
122
+ }
123
+ // The repository's common git directory for `projectRoot`, found by walking
124
+ // up to the nearest `.git`. A linked worktree's `.git` is a file that points
125
+ // at its own git directory, whose `commondir` names the shared one where
126
+ // info/exclude lives.
127
+ function findRepository(projectRoot) {
128
+ let dir = projectRoot;
129
+ for (;;) {
130
+ const dotGit = join(dir, ".git");
131
+ if (existsSync(dotGit)) {
132
+ let gitDir = dotGit;
133
+ try {
134
+ if (statSync(dotGit).isFile()) {
135
+ const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"));
136
+ if (match === null)
137
+ return undefined;
138
+ gitDir = resolve(dir, match[1].trim());
139
+ }
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ const commonDirText = readText(join(gitDir, "commondir"));
145
+ const commonDir = commonDirText === undefined ? gitDir : resolve(gitDir, commonDirText.trim());
146
+ return { root: dir, commonDir };
147
+ }
148
+ const parent = dirname(dir);
149
+ if (parent === dir)
150
+ return undefined;
151
+ dir = parent;
152
+ }
153
+ }
154
+ // Every ignore file that applies above `projectRoot` itself: info/exclude
155
+ // and each .gitignore from the repository root down to the project root's
156
+ // parent. The project root's own .gitignore and every nested one are added
157
+ // by the caller as it reaches each directory (withGitignoreFile). Outside a
158
+ // repository this is empty, and the nested files still apply.
159
+ export function gitignoreStackAbove(projectRoot) {
160
+ const repository = findRepository(projectRoot);
161
+ if (repository === undefined)
162
+ return [];
163
+ const layers = [];
164
+ const add = (baseDir, text) => {
165
+ if (text === undefined)
166
+ return;
167
+ const rules = parseGitignore(text);
168
+ if (rules.length === 0)
169
+ return;
170
+ const fromBase = toPosix(relative(baseDir, projectRoot));
171
+ layers.push(makeLayer(rules, fromBase === "" ? "" : `${fromBase}/`, 0));
172
+ };
173
+ add(repository.root, readText(join(repository.commonDir, "info", "exclude")));
174
+ const between = [];
175
+ for (let dir = projectRoot; dir !== repository.root; dir = dirname(dir)) {
176
+ const parent = dirname(dir);
177
+ if (parent === dir)
178
+ break;
179
+ between.unshift(parent);
180
+ }
181
+ for (const dir of between)
182
+ add(dir, readText(join(dir, ".gitignore")));
183
+ return layers;
184
+ }
185
+ // `stack` plus the .gitignore whose text is `text`, found in the directory
186
+ // at project-relative `dirRel` ("" for the project root).
187
+ export function withGitignoreFile(stack, dirRel, text) {
188
+ const rules = parseGitignore(text);
189
+ if (rules.length === 0)
190
+ return stack;
191
+ return [...stack, makeLayer(rules, "", dirRel === "" ? 0 : dirRel.length + 1)];
192
+ }
193
+ // The deepest layer's last matching rule decides, the same precedence git
194
+ // uses. No match at all means not ignored.
195
+ export function isIgnoredBy(stack, rel, isDir) {
196
+ if (stack.length === 0)
197
+ return false;
198
+ const basename = rel.slice(rel.lastIndexOf("/") + 1);
199
+ for (let l = stack.length - 1; l >= 0; l--) {
200
+ const layer = stack[l];
201
+ let best = layer.literalAny.get(basename) ?? -1;
202
+ if (isDir)
203
+ best = Math.max(best, layer.literalDir.get(basename) ?? -1);
204
+ const local = layer.prefix + rel.slice(layer.strip);
205
+ for (let p = layer.patterned.length - 1; p >= 0; p--) {
206
+ const index = layer.patterned[p];
207
+ if (index < best)
208
+ break;
209
+ const rule = layer.rules[index];
210
+ if (rule.dirOnly && !isDir)
211
+ continue;
212
+ if (rule.regex.test(local)) {
213
+ best = index;
214
+ break;
215
+ }
216
+ }
217
+ if (best >= 0)
218
+ return !layer.rules[best].negate;
219
+ }
220
+ return false;
221
+ }
222
+ // The state of the entry at project-relative `rel`, given its parent's
223
+ // state. Git never re-includes a path under an ignored directory, so
224
+ // "ignored" is inherited; only a declared module's base directory (or
225
+ // single file) reverses it, for its whole subtree.
226
+ export function nextIgnoreState(parent, stack, rel, isDir, forcedBases) {
227
+ if (parent === "forced")
228
+ return "forced";
229
+ const ignored = parent === "ignored" || isIgnoredBy(stack, rel, isDir);
230
+ if (!ignored)
231
+ return "kept";
232
+ return forcedBases.has(rel) ? "forced" : "ignored";
233
+ }
234
+ // The walk's own decision for one path that the walk never visited: a file
235
+ // simulate proposes to create. Replays nextIgnoreState down the path's own
236
+ // directories, reading each directory's .gitignore on the way.
237
+ export function isPathGitignored(projectRoot, rel, forcedBases) {
238
+ let stack = gitignoreStackAbove(projectRoot);
239
+ const rootText = readText(join(projectRoot, ".gitignore"));
240
+ if (rootText !== undefined)
241
+ stack = withGitignoreFile(stack, "", rootText);
242
+ const parts = rel.split("/");
243
+ let state = "kept";
244
+ for (let i = 0; i < parts.length; i++) {
245
+ const sub = parts.slice(0, i + 1).join("/");
246
+ const isDir = i < parts.length - 1;
247
+ state = nextIgnoreState(state, stack, sub, isDir, forcedBases);
248
+ if (state === "kept" && isDir) {
249
+ const text = readText(join(projectRoot, sub, ".gitignore"));
250
+ if (text !== undefined)
251
+ stack = withGitignoreFile(stack, sub, text);
252
+ }
253
+ }
254
+ return state === "ignored";
255
+ }
256
+ // The project-relative base of every declared module whose glob names a
257
+ // real path (the literal part before the first wildcard). A base of ""
258
+ // (a glob like "**/*.ts") covers the whole project and forces nothing.
259
+ export function forcedBasesOf(bases) {
260
+ const set = new Set();
261
+ const ancestors = new Set();
262
+ for (const base of bases) {
263
+ if (base === "")
264
+ continue;
265
+ set.add(base);
266
+ const parts = base.split("/");
267
+ for (let i = 1; i < parts.length; i++)
268
+ ancestors.add(parts.slice(0, i).join("/"));
269
+ }
270
+ return { bases: set, ancestors };
271
+ }
@@ -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
+ }
@@ -0,0 +1,125 @@
1
+ // Responsibility: turn a list of analyzed files into the groups init
2
+ // declares as modules - one directory group per top-level (or per-container)
3
+ // directory that holds an analyzed file, one file group per loose file -
4
+ // plus the on-disk naming rule for each group and the literal
5
+ // declaredModules-entry text init pastes into the generated config. The
6
+ // same grouping and naming also produces the paste-ready suggestion for a
7
+ // file an EXISTING config doesn't cover yet (rule 3's own `do:`, `archstrict
8
+ // rules <path>`, and init's re-run listing all share `suggestUncovered`
9
+ // below, so the three can never drift into different phrasings of the same
10
+ // entry).
11
+ // Boundary: no I/O, and no opinion about WHICH files are analyzed - the
12
+ // caller (init's own walk, or a config-vs-graph consistency check) already
13
+ // decided that and hands this module the resulting file list, anchor set,
14
+ // and the config's own declaredModules (for the `taken`-name check).
15
+ import { moduleGlobBaseDir, moduleGlobList } from "./module-graph.js";
16
+ const byteSort = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
17
+ // Groups analyzed files by their nearest anchor: for a file `f`, the
18
+ // longest anchor that contains it. A file sitting directly in its anchor is
19
+ // its own group (a file group); otherwise the group is the first directory
20
+ // below the anchor on the path to `f` (a directory group) - so a deeply
21
+ // nested file (`src/runtime/db/pool.ts`) still groups under `src/runtime`,
22
+ // one entry per top-level directory, not one per leaf.
23
+ export function groupAnalyzedFiles(files, anchors) {
24
+ const sortedAnchors = [...anchors].sort((a, b) => b.length - a.length);
25
+ const groups = new Map();
26
+ for (const file of files) {
27
+ // "" is always the last (shortest) anchor tried, and always matches -
28
+ // every analyzed file is under the project root - so this always finds one.
29
+ const anchor = sortedAnchors.find((a) => a === "" || file.startsWith(`${a}/`));
30
+ const rest = anchor === "" ? file : file.slice(anchor.length + 1);
31
+ const [first, ...more] = rest.split("/");
32
+ const rel = anchor === "" ? first : `${anchor}/${first}`;
33
+ const kind = more.length === 0 ? "file" : "dir";
34
+ const existing = groups.get(rel);
35
+ if (existing !== undefined) {
36
+ existing.fileCount++;
37
+ continue;
38
+ }
39
+ groups.set(rel, { kind, rel, anchor, onDiskName: first, fileCount: 1 });
40
+ }
41
+ return [...groups.values()].sort((a, b) => byteSort(a.rel, b.rel));
42
+ }
43
+ // Naming, three cases: (1) a group's name is its on-disk name, unless that
44
+ // name collides with another group's own on-disk name at the SAME anchor
45
+ // depth - one directory cannot hold a file and a directory of the same
46
+ // name, so a top-level `cli.ts` and a `src/cli.ts` never collide with each
47
+ // other directly, only through rule 2; (2) a group below the project root
48
+ // whose on-disk name is already `taken` (by an existing config entry, or by
49
+ // another group sharing that name) instead takes its own project-relative
50
+ // path as its name; (3) a project-root group (anchor "") has no deeper
51
+ // path to fall back to - its on-disk name IS its rel - so a root name still
52
+ // `taken` after that takes a "./"-prefixed rel instead (measured: pasting
53
+ // `{ name: "./tools", glob: "tools/**" }` next to an existing "tools" gave
54
+ // 0 violations and tsc passed). This only fires for a re-run's or rule 3's
55
+ // suggestion - a fresh init never has a `taken` set with anything a fresh
56
+ // root group's own on-disk name could collide with.
57
+ export function nameCandidates(groups, taken) {
58
+ const onDiskCounts = new Map();
59
+ for (const g of groups)
60
+ onDiskCounts.set(g.onDiskName, (onDiskCounts.get(g.onDiskName) ?? 0) + 1);
61
+ return groups.map((g) => {
62
+ const collides = taken.has(g.onDiskName) || (g.anchor !== "" && (onDiskCounts.get(g.onDiskName) ?? 0) > 1);
63
+ const name = g.anchor === "" ? (collides ? `./${g.rel}` : g.onDiskName) : collides ? g.rel : g.onDiskName;
64
+ const glob = g.kind === "dir" ? `${g.rel}/**` : g.rel;
65
+ const entry = g.kind === "dir" ? { name, glob } : { name, glob, surface: g.onDiskName };
66
+ return { ...g, entry, excludeGlob: glob };
67
+ });
68
+ }
69
+ const q = JSON.stringify;
70
+ // The literal declaredModules[] entry text init pastes into the generated
71
+ // config, and the same text a later suggestion (for the root-name-collision
72
+ // case above) would paste for an uncovered path - kept as one function so
73
+ // the two can never drift into two different phrasings of the same entry
74
+ // shape. A directory entry carries no `surface` of its own (the top-level
75
+ // default, or the directory's own package.json exports map, applies
76
+ // instead) - a file
77
+ // entry always does, naming the file itself, since a file with no surface
78
+ // of its own would otherwise be entirely private (nothing else could ever
79
+ // export from it).
80
+ export function declaredModuleEntryText(entry) {
81
+ return entry.surface === undefined
82
+ ? `{ name: ${q(entry.name)}, glob: ${q(entry.glob)} }`
83
+ : `{ name: ${q(entry.name)}, glob: ${q(entry.glob)}, surface: ${q(entry.surface)} }`;
84
+ }
85
+ // The one shared entry point rule 3, `archstrict rules <path>`, and init's
86
+ // re-run all call: given the project-relative paths of files an EXISTING
87
+ // config's declaredModules doesn't cover, group and name them exactly as a
88
+ // fresh init would, with the config's own declaredModules entries counted
89
+ // as `taken` names. Anchors are the project root plus the parent directory
90
+ // of each existing entry's own glob base - the same depth a fresh init
91
+ // itself would have grouped that entry at, computed by string ops alone
92
+ // (moduleGlobBaseDir strips the glob down to its literal prefix; only
93
+ // that prefix's parent directory is the anchor, and a file list
94
+ // contributes one anchor per path).
95
+ export function suggestUncovered(uncoveredRelFiles, declaredModules) {
96
+ const anchors = new Set([""]);
97
+ for (const dm of declaredModules) {
98
+ // The parent of each glob's literal base, the same depth a fresh init
99
+ // groups at. A directory glob `src/app/**` anchors at `src`, so a
100
+ // sibling directory is its own group. A file glob `src/sqlite.ts`
101
+ // anchors at `src`, so the file itself is a file group. A file list
102
+ // anchors at the shared directory, once per path.
103
+ for (const glob of moduleGlobList(dm.glob)) {
104
+ const base = moduleGlobBaseDir(glob);
105
+ const slash = base.lastIndexOf("/");
106
+ anchors.add(slash === -1 ? "" : base.slice(0, slash));
107
+ }
108
+ }
109
+ const taken = new Set(declaredModules.map((dm) => dm.name));
110
+ return nameCandidates(groupAnalyzedFiles(uncoveredRelFiles, [...anchors]), taken);
111
+ }
112
+ // Which of `suggestUncovered`'s own groups a single project-relative file
113
+ // belongs to - a file group's own `rel` IS the file, a directory group's
114
+ // `rel` is its own directory, so the file sits somewhere below it.
115
+ export function groupForRelFile(rel, groups) {
116
+ return groups.find((g) => g.rel === rel || rel.startsWith(`${g.rel}/`));
117
+ }
118
+ // The one sentence rule 3's `do:` and `archstrict rules <path>` both print
119
+ // for a single uncovered file - the paste-ready entry, with the exclude
120
+ // alternative right beside it so the same suggestion never leads an agent
121
+ // to add a directory entry for a file that turns out not to be module
122
+ // content at all.
123
+ export function suggestionDoText(group) {
124
+ return `add ${declaredModuleEntryText(group.entry)} to declaredModules in archstrict.config.ts, or add ${q(group.excludeGlob)} to exclude if it is not module content; then run archstrict init`;
125
+ }