archstrict 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/hooks/hooks.json +29 -0
- package/.agents/hooks/post-tool-use.mjs +107 -0
- package/.agents/hooks/pre-tool-use.mjs +182 -0
- package/.agents/mcp/server.mjs +71 -0
- package/.agents/plugin.json +19 -0
- package/AGENTS.md +81 -0
- package/CHANGELOG.md +77 -0
- package/README.ja.md +142 -0
- package/README.md +143 -2
- package/dist/augmentation-cache.js +65 -0
- package/dist/check-options.js +40 -0
- package/dist/classify.js +148 -0
- package/dist/cli.js +243 -0
- package/dist/config-pointer.js +251 -0
- package/dist/config.js +194 -0
- package/dist/edge-cache.js +530 -0
- package/dist/gitignore.js +271 -0
- package/dist/mcp-server.js +111 -0
- package/dist/module-candidates.js +125 -0
- package/dist/module-graph.js +2179 -0
- package/dist/project-path.js +59 -0
- package/dist/report-error.js +13 -0
- package/dist/rules/config-meaning.js +143 -0
- package/dist/rules/constraints.js +419 -0
- package/dist/rules/cycles.js +285 -0
- package/dist/rules/deprecated.js +67 -0
- package/dist/rules/empty-rule.js +101 -0
- package/dist/rules/moves.js +79 -0
- package/dist/rules/must-be-empty.js +52 -0
- package/dist/rules/public-surface.js +100 -0
- package/dist/rules/uncovered.js +75 -0
- package/dist/todo-migration.js +112 -0
- package/dist/todo-store.js +434 -0
- package/dist/type-closure.js +959 -0
- package/dist/type-leak.js +590 -0
- package/dist/verbs/agents.js +116 -0
- package/dist/verbs/check.js +1011 -0
- package/dist/verbs/fix.js +170 -0
- package/dist/verbs/hotspots.js +261 -0
- package/dist/verbs/init.js +538 -0
- package/dist/verbs/map-shape.js +78 -0
- package/dist/verbs/recommend.js +863 -0
- package/dist/verbs/rules.js +188 -0
- package/dist/verbs/search.js +109 -0
- package/dist/verbs/simulate.js +220 -0
- package/dist/verbs/todo.js +180 -0
- package/dist/warm-graph.js +82 -0
- package/docs/boundary-patterns.md +374 -0
- package/docs/calibrated-rules-design.md +124 -0
- package/docs/init-singleton-modules.md +133 -0
- package/docs/maintenance.md +109 -0
- package/docs/releasing.md +58 -0
- package/docs/rules-edge-cache.md +50 -0
- package/docs/todo-single-file-migration.md +58 -0
- package/llms.txt +25 -0
- package/package.json +61 -4
- package/skills/archstrict/SKILL.md +54 -0
- package/skills/archstrict/references/agents-verb.md +39 -0
- package/skills/archstrict/references/config.md +116 -0
- package/skills/archstrict/references/hook.md +57 -0
- package/skills/archstrict/references/path-rules.md +57 -0
- package/skills/archstrict/references/patterns.md +915 -0
- package/skills/archstrict/references/prove-rules.md +58 -0
- package/skills/archstrict/references/rearchitect.md +66 -0
- package/skills/archstrict/references/recommend.md +98 -0
- package/skills/archstrict/references/rules.md +149 -0
- package/skills/archstrict/references/simulate.md +109 -0
|
@@ -0,0 +1,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
|
+
}
|