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,118 @@
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 } 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 already strips the glob down to its literal prefix;
93
+ // only its own parent directory is needed here, not whether that prefix
94
+ // names a real file or directory on disk).
95
+ export function suggestUncovered(uncoveredRelFiles, declaredModules) {
96
+ const anchors = new Set([""]);
97
+ for (const dm of declaredModules) {
98
+ const base = moduleGlobBaseDir(dm.glob);
99
+ const slash = base.lastIndexOf("/");
100
+ anchors.add(slash === -1 ? "" : base.slice(0, slash));
101
+ }
102
+ const taken = new Set(declaredModules.map((dm) => dm.name));
103
+ return nameCandidates(groupAnalyzedFiles(uncoveredRelFiles, [...anchors]), taken);
104
+ }
105
+ // Which of `suggestUncovered`'s own groups a single project-relative file
106
+ // belongs to - a file group's own `rel` IS the file, a directory group's
107
+ // `rel` is its own directory, so the file sits somewhere below it.
108
+ export function groupForRelFile(rel, groups) {
109
+ return groups.find((g) => g.rel === rel || rel.startsWith(`${g.rel}/`));
110
+ }
111
+ // The one sentence rule 3's `do:` and `archstrict rules <path>` both print
112
+ // for a single uncovered file - the paste-ready entry, with the exclude
113
+ // alternative right beside it so the same suggestion never leads an agent
114
+ // to add a directory entry for a file that turns out not to be module
115
+ // content at all.
116
+ export function suggestionDoText(group) {
117
+ 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`;
118
+ }