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,100 @@
1
+ // Responsibility: rule 1, the public-surface bypass. A module is private by
2
+ // default, the same posture Bazel's build visibility takes: an import from
3
+ // outside a module that reaches a file other than that module's configured
4
+ // public surface is a violation, and a module with no surface file present
5
+ // is entirely private, so every external import into it violates.
6
+ // This counts a type-only (`import type`) edge the same as a value edge:
7
+ // reaching an internal file for its types alone still reaches past the
8
+ // public surface (module-graph.ts's own header has the contrasting
9
+ // decision for cycles).
10
+ // Boundary: pure predicate over a ModuleGraph's cross-module edges. No I/O,
11
+ // no output formatting (that is the `check` verb's job), no todo handling
12
+ // (that is `todo`'s job).
13
+ import { compileGlob } from "../classify.js";
14
+ import {} from "../module-graph.js";
15
+ const BECAUSE = "a module's public surface is its only public surface; everything else is private";
16
+ // A friend exception (ArchUnit's term): the target file is public to
17
+ // exactly the importers `from` matches, private to everyone else - unlike
18
+ // `surface`, which is public to every importer equally. Checked only once
19
+ // a bypass candidate is already known (surface itself didn't match), the
20
+ // same order rule 1's own violation-vs-suppression logic already follows.
21
+ function isExemptedByFriend(edge, targetModule, relativePath) {
22
+ const targetRel = relativePath(edge.resolvedFile);
23
+ const fromRel = relativePath(edge.fromFile);
24
+ return targetModule.friends.some((friend) => compileGlob(friend.fileGlob).test(targetRel) && compileGlob(friend.from).test(fromRel));
25
+ }
26
+ // Every violation this rule returns is reported at `edge.fromFile` (the
27
+ // importing file) - never at the target module's own directory or any
28
+ // other file. `focus`, when given, is check()'s own realpath'd target for
29
+ // a `check <file>` run: filtering `graph.crossModuleEdges` down to the
30
+ // ones whose `fromFile` is that exact file, before this rule ever builds a
31
+ // Violation object (evidence/do strings, both built by concatenation, are
32
+ // this rule's own real cost on a large project), gives back exactly the
33
+ // set `filterToFile` would keep from the unscoped result - the same
34
+ // (edge -> violation) mapping runs either way, only over fewer edges.
35
+ // `edge.fromFile` is compared directly, not through `resolve()`: every
36
+ // file in `graph.crossModuleEdges` is already an absolute, real path (the
37
+ // module graph builds it that way), the same invariant `filterToFile`
38
+ // itself already trusts before comparing a violation's own `path`.
39
+ export function checkPublicSurfaceBypass(graph, focus) {
40
+ const violations = [];
41
+ const edges = focus === undefined ? graph.crossModuleEdges : graph.crossModuleEdges.filter((e) => e.fromFile === focus);
42
+ for (const edge of edges) {
43
+ const targetModule = graph.modules.get(edge.toModule);
44
+ if (targetModule === undefined)
45
+ continue; // resolved outside any module; not this rule's concern
46
+ if (targetModule.surfaceFiles.includes(edge.resolvedFile))
47
+ continue; // reached the public surface itself
48
+ if (isExemptedByFriend(edge, targetModule, graph.relativePath))
49
+ continue;
50
+ violations.push(violationFor(edge, targetModule.name, targetModule.surfaceFiles, targetModule.surfaceName,
51
+ // A file module has nowhere to "add a index.ts". The relative path
52
+ // is the file the glob already names, so the fix can point at it.
53
+ targetModule.rootIsFile ? graph.relativePath(targetModule.dir) : undefined));
54
+ }
55
+ // `graph.crossModuleEdges` follows the edge build's own walk order
56
+ // (rootNames order - a directory scan, not a promise about reading
57
+ // order across files), not a promise about output order - sorted here
58
+ // so this rule's own output stays stable regardless of it, by the same
59
+ // (path, line, column) a reader would scan a file top to bottom.
60
+ // Code-unit order (`<`/`>`), not localeCompare: a locale-aware compare
61
+ // can order the same two paths differently on different machines.
62
+ return violations.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0) || a.line - b.line || a.column - b.column);
63
+ }
64
+ function violationFor(edge, targetModuleName, surfaceFiles, surface,
65
+ // Set when the module root is a file. Undefined for a directory module,
66
+ // whose remediation still names `<module>/<surface>`.
67
+ fileModuleRel) {
68
+ const surfaceList = Array.isArray(surface) ? surface : [surface];
69
+ // Singular reads exactly as before (existing messages, unchanged);
70
+ // plural names every real, configured entry point instead of picking
71
+ // one arbitrarily.
72
+ const surfaceDisplay = surfaceList.join(", ");
73
+ const addArticle = surfaceList.length === 1 ? "a" : "one of";
74
+ const importTargets = surfaceList.map((s) => `${targetModuleName}/${s}`).join(", ");
75
+ const evidence = surfaceFiles.length === 0
76
+ ? `'${edge.specifier}' resolved to module '${targetModuleName}', which has no ${surfaceDisplay}`
77
+ : `'${edge.specifier}' resolved to a file inside module '${targetModuleName}' other than its ${surfaceDisplay}`;
78
+ // `<module>/` is a directory. A glob that names one file has no such
79
+ // directory, so the fix names that file instead of telling the reader
80
+ // to add a surface file inside the module name.
81
+ const doText = fileModuleRel !== undefined
82
+ ? surfaceFiles.length === 0
83
+ ? `set surface on '${targetModuleName}' to match ${fileModuleRel}, or stop importing it; this module is that file, not a directory`
84
+ : `import from ${fileModuleRel} instead, or add the needed export there`
85
+ : surfaceFiles.length === 0
86
+ ? `add ${addArticle} ${surfaceDisplay} to ${targetModuleName}/ naming what it exports`
87
+ : `import from ${importTargets} instead, or add the needed export there`;
88
+ return {
89
+ rule: "public-surface-bypass",
90
+ path: edge.fromFile,
91
+ line: edge.fromPosition.line,
92
+ column: edge.fromPosition.column,
93
+ evidence,
94
+ because: BECAUSE,
95
+ do: doText,
96
+ todoModule: targetModuleName,
97
+ specifier: edge.specifier,
98
+ target: edge.resolvedFile,
99
+ };
100
+ }
@@ -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
+ }