kataster 0.0.0-stage → 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/.claude/agents/acceptance-tester.md +53 -0
- package/.claude/agents/code-reviewer.md +56 -0
- package/.claude/agents/domain-architect.md +40 -0
- package/.claude/agents/lead-dev.md +50 -0
- package/.claude/agents/product-owner.md +39 -0
- package/.claude/agents/repo-analyst.md +78 -0
- package/.claude/agents/requirements-engineer.md +44 -0
- package/.claude/agents/ux-designer.md +64 -0
- package/.claude/hooks/bash-guard.ts +92 -0
- package/.claude/hooks/code-check.ts +37 -0
- package/.claude/hooks/path-guard.ts +34 -0
- package/.claude/hooks/path-rules.ts +66 -0
- package/.claude/hooks/project-check.ts +108 -0
- package/.claude/hooks/protect-files.ts +55 -0
- package/.claude/hooks/session-start.ts +213 -0
- package/.claude/hooks/stop-gate.ts +56 -0
- package/.claude/hooks/validate-docs.ts +58 -0
- package/.claude/lib/architecture.ts +736 -0
- package/.claude/lib/backlog-github.ts +454 -0
- package/.claude/lib/backlog-jira.ts +347 -0
- package/.claude/lib/backlog-sync.ts +42 -0
- package/.claude/lib/backlog.ts +396 -0
- package/.claude/lib/bazel.ts +212 -0
- package/.claude/lib/config.ts +122 -0
- package/.claude/lib/discovery.ts +821 -0
- package/.claude/lib/engineering.ts +406 -0
- package/.claude/lib/nx.ts +129 -0
- package/.claude/lib/repo.ts +1194 -0
- package/.claude/lib/toolgraph.ts +74 -0
- package/.claude/lib/turbo.ts +166 -0
- package/.claude/package-lock.json +427 -0
- package/.claude/package.json +21 -0
- package/.claude/settings.json +82 -0
- package/.claude/skills/adopt/SKILL.md +283 -0
- package/.claude/skills/adopt/scripts/boundary-check.ts +156 -0
- package/.claude/skills/adopt/scripts/package-doc.ts +71 -0
- package/.claude/skills/adopt/scripts/repo-map.ts +123 -0
- package/.claude/skills/adopt/scripts/rule-doc.ts +80 -0
- package/.claude/skills/architect/ADR-FORMAT.md +97 -0
- package/.claude/skills/architect/SKILL.md +64 -0
- package/.claude/skills/architect/TECH-STACK.md +21 -0
- package/.claude/skills/backlog/SKILL.md +198 -0
- package/.claude/skills/backlog/scripts/backlog-pull.ts +45 -0
- package/.claude/skills/backlog/scripts/backlog-push.ts +38 -0
- package/.claude/skills/backlog/scripts/backlog-status.ts +32 -0
- package/.claude/skills/backlog/scripts/jira-import.ts +40 -0
- package/.claude/skills/backlog/scripts/jira-plan.ts +26 -0
- package/.claude/skills/backlog/scripts/jira-record.ts +41 -0
- package/.claude/skills/bootstrap/SKILL.md +119 -0
- package/.claude/skills/codebase-design/DEEPENING.md +37 -0
- package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/.claude/skills/codebase-design/SKILL.md +114 -0
- package/.claude/skills/domain-model/CONTEXT-FORMAT.md +47 -0
- package/.claude/skills/domain-model/SKILL.md +64 -0
- package/.claude/skills/domain-model/templates/data-model.md +29 -0
- package/.claude/skills/engineering-conventions/SKILL.md +31 -0
- package/.claude/skills/event-storming/SKILL.md +44 -0
- package/.claude/skills/event-storming/schema/events.schema.json +124 -0
- package/.claude/skills/event-storming/scripts/render-event-storming.ts +11 -0
- package/.claude/skills/event-storming/scripts/validate-events.ts +11 -0
- package/.claude/skills/event-storming/templates/events.example.yaml +91 -0
- package/.claude/skills/feature/SKILL.md +86 -0
- package/.claude/skills/feature/scripts/intake.ts +356 -0
- package/.claude/skills/grill-me/SKILL.md +7 -0
- package/.claude/skills/grill-with-docs/SKILL.md +7 -0
- package/.claude/skills/grilling/SKILL.md +30 -0
- package/.claude/skills/groom-backlog/SKILL.md +93 -0
- package/.claude/skills/groom-backlog/scripts/backlog-health.ts +238 -0
- package/.claude/skills/implement/SKILL.md +129 -0
- package/.claude/skills/implement/scripts/affected.ts +245 -0
- package/.claude/skills/implement/scripts/check-commands.ts +37 -0
- package/.claude/skills/implement/scripts/check-language.ts +15 -0
- package/.claude/skills/implement/scripts/check-scenarios.ts +59 -0
- package/.claude/skills/implement/scripts/next-story.ts +23 -0
- package/.claude/skills/implement/scripts/story-context.ts +135 -0
- package/.claude/skills/implement/scripts/story-status.ts +47 -0
- package/.claude/skills/implement/scripts/verify.ts +92 -0
- package/.claude/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/.claude/skills/improve-codebase-architecture/SKILL.md +71 -0
- package/.claude/skills/retro/SKILL.md +53 -0
- package/.claude/skills/review-stories/SKILL.md +31 -0
- package/.claude/skills/shape/SKILL.md +193 -0
- package/.claude/skills/tdd/SKILL.md +38 -0
- package/.claude/skills/tdd/mocking.md +59 -0
- package/.claude/skills/tdd/tests.md +77 -0
- package/.claude/skills/user-stories/SKILL.md +138 -0
- package/.claude/skills/user-stories/schema/story.schema.json +39 -0
- package/.claude/skills/user-stories/scripts/render-backlog.ts +4 -0
- package/.claude/skills/user-stories/scripts/validate-stories.ts +13 -0
- package/.claude/skills/user-stories/templates/story-template.md +38 -0
- package/.claude/skills/ux-design/SKILL.md +107 -0
- package/.claude/skills/writing-for-agents/SKILL-MECHANICS.md +22 -0
- package/.claude/skills/writing-for-agents/SKILL.md +81 -0
- package/.claude/tsconfig.json +15 -0
- package/.opencode/agents/acceptance-tester.md +64 -0
- package/.opencode/agents/code-reviewer.md +69 -0
- package/.opencode/agents/domain-architect.md +59 -0
- package/.opencode/agents/lead-dev.md +67 -0
- package/.opencode/agents/product-owner.md +59 -0
- package/.opencode/agents/repo-analyst.md +86 -0
- package/.opencode/agents/requirements-engineer.md +58 -0
- package/.opencode/agents/ux-designer.md +83 -0
- package/.opencode/commands/adopt.md +7 -0
- package/.opencode/commands/backlog-sync.md +7 -0
- package/.opencode/commands/bootstrap.md +7 -0
- package/.opencode/commands/event-storming.md +7 -0
- package/.opencode/commands/feature.md +7 -0
- package/.opencode/commands/grill-me.md +7 -0
- package/.opencode/commands/grill-with-docs.md +7 -0
- package/.opencode/commands/groom-backlog.md +7 -0
- package/.opencode/commands/implement.md +7 -0
- package/.opencode/commands/improve-codebase-architecture.md +7 -0
- package/.opencode/commands/retro.md +7 -0
- package/.opencode/commands/review-stories.md +7 -0
- package/.opencode/commands/shape.md +7 -0
- package/.opencode/opencode.jsonc +15 -0
- package/.opencode/plugins/kataster/index.ts +283 -0
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +82 -0
- package/LICENSE +21 -0
- package/README.md +324 -2
- package/bin/kataster.mjs +7 -0
- package/extras/skills/shadcn/SKILL.md +277 -0
- package/extras/skills/shadcn/agents/openai.yml +5 -0
- package/extras/skills/shadcn/assets/shadcn-small.png +0 -0
- package/extras/skills/shadcn/assets/shadcn.png +0 -0
- package/extras/skills/shadcn/cli.md +290 -0
- package/extras/skills/shadcn/customization.md +209 -0
- package/extras/skills/shadcn/evals/evals.json +77 -0
- package/extras/skills/shadcn/mcp.md +105 -0
- package/extras/skills/shadcn/registry.md +277 -0
- package/extras/skills/shadcn/rules/base-vs-radix.md +306 -0
- package/extras/skills/shadcn/rules/chat.md +250 -0
- package/extras/skills/shadcn/rules/composition.md +213 -0
- package/extras/skills/shadcn/rules/forms.md +192 -0
- package/extras/skills/shadcn/rules/icons.md +101 -0
- package/extras/skills/shadcn/rules/styling.md +185 -0
- package/guide/README.md +9 -0
- package/guide/brownfield.md +242 -0
- package/guide/concepts.md +109 -0
- package/guide/greenfield.md +84 -0
- package/guide/install.md +144 -0
- package/guide/release.md +62 -0
- package/package.json +56 -4
- package/src/cli.mjs +302 -0
- package/src/detect.mjs +237 -0
- package/src/install.mjs +427 -0
- package/src/repo.mjs +129 -0
- package/src/util.mjs +110 -0
- package/templates/CONTEXT.md +21 -0
- package/templates/conventions.md +63 -0
- package/templates/docs/adr/README.md +7 -0
- package/templates/docs/architecture/data-model.md +31 -0
- package/templates/docs/domain/context-map.md +21 -0
- package/templates/docs/domain/events.yaml +23 -0
- package/templates/docs/product/initiatives/README.md +41 -0
- package/templates/docs/product/ux-guidelines.md +27 -0
- package/templates/docs/product/vision.md +36 -0
- package/templates/docs/reviews/.gitkeep +0 -0
- package/templates/docs/stories/OPEN_QUESTIONS.md +8 -0
- package/templates/docs/stories/README.md +26 -0
- package/templates/docs/workflow.md +135 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Architecture boundaries and project checks.
|
|
3
|
+
*
|
|
4
|
+
* Two files are the source of truth:
|
|
5
|
+
* - `docs/architecture/boundaries.yaml` – allowed/forbidden dependency edges between packages
|
|
6
|
+
* - `docs/engineering/checks.yaml` – which checks run at edit time and at stop/CI
|
|
7
|
+
*
|
|
8
|
+
* The boundary checker has two engines:
|
|
9
|
+
* - `scan`: built-in import scanner for JS/TS (no extra dependency)
|
|
10
|
+
* - `command`: run the project's own architecture check (dependency-cruiser, Nx module boundaries,
|
|
11
|
+
* ArchUnit, import-linter, Bazel, …) and ratchet its failure output
|
|
12
|
+
*
|
|
13
|
+
* Existing violations are frozen in `docs/architecture/boundaries.baseline.json`. Only **new** violations
|
|
14
|
+
* fail, so a brownfield repository is green on day one and the rules tighten as the code is cleaned up.
|
|
15
|
+
*
|
|
16
|
+
* Rules are evaluated in order; the first matching `from` → `to` pair decides. No match means allow.
|
|
17
|
+
* Patterns are repo-relative globs (`*`, `**`, `?`).
|
|
18
|
+
*
|
|
19
|
+
* Node >= 22.18, erasable TypeScript only. Runtime dependency: `yaml` (already in .claude/package.json).
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
25
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
26
|
+
import { ROOT, scanRepo, readRepoMap, type RepoMap } from "./repo.ts";
|
|
27
|
+
|
|
28
|
+
export const BOUNDARIES_FILE = join("docs", "architecture", "boundaries.yaml");
|
|
29
|
+
export const BASELINE_FILE = join("docs", "architecture", "boundaries.baseline.json");
|
|
30
|
+
export const CHECKS_FILE = join("docs", "engineering", "checks.yaml");
|
|
31
|
+
export const RULES_DIR = join(".claude", "rules");
|
|
32
|
+
|
|
33
|
+
export type Engine = "auto" | "scan" | "command";
|
|
34
|
+
export type Phase = "edit" | "stop" | "ci";
|
|
35
|
+
|
|
36
|
+
export interface BoundaryRule {
|
|
37
|
+
id: string;
|
|
38
|
+
from: string;
|
|
39
|
+
to: string | string[];
|
|
40
|
+
effect: "allow" | "deny";
|
|
41
|
+
reason?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Boundaries {
|
|
45
|
+
version: 1;
|
|
46
|
+
engine: Engine;
|
|
47
|
+
command: string;
|
|
48
|
+
rules: BoundaryRule[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Check {
|
|
52
|
+
name: string;
|
|
53
|
+
command: string;
|
|
54
|
+
phase: Phase;
|
|
55
|
+
paths: string[];
|
|
56
|
+
timeout: number;
|
|
57
|
+
advisory: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ChecksFile {
|
|
61
|
+
version: 1;
|
|
62
|
+
checks: Check[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ImportEdge {
|
|
66
|
+
fromFile: string;
|
|
67
|
+
fromPackage?: string;
|
|
68
|
+
toFile?: string;
|
|
69
|
+
toPackage?: string;
|
|
70
|
+
specifier: string;
|
|
71
|
+
line: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface Violation {
|
|
75
|
+
key: string;
|
|
76
|
+
rule: string;
|
|
77
|
+
fromFile: string;
|
|
78
|
+
fromPackage?: string;
|
|
79
|
+
toFile?: string;
|
|
80
|
+
toPackage?: string;
|
|
81
|
+
specifier?: string;
|
|
82
|
+
line?: number;
|
|
83
|
+
reason?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CommandFailure {
|
|
87
|
+
key: string;
|
|
88
|
+
name: string;
|
|
89
|
+
output: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface Baseline {
|
|
93
|
+
version: 1;
|
|
94
|
+
violations: { key: string; rule: string; fromFile: string; toFile?: string; specifier?: string }[];
|
|
95
|
+
commands: { key: string; name?: string; output?: string }[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface BoundaryResult {
|
|
99
|
+
engine: Engine;
|
|
100
|
+
checked: number;
|
|
101
|
+
violations: Violation[];
|
|
102
|
+
command?: CommandFailure;
|
|
103
|
+
note?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const CODE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte", ".astro"]);
|
|
107
|
+
const SCAN_IGNORED = new Set([
|
|
108
|
+
"node_modules", "dist", "build", "out", "coverage", ".next", ".nuxt", ".svelte-kit", ".turbo", ".nx",
|
|
109
|
+
"vendor", "target", ".git", ".claude", ".opencode", ".kataster", "docs", ".github", "__pycache__",
|
|
110
|
+
".venv", "venv", "tmp", "temp",
|
|
111
|
+
]);
|
|
112
|
+
const MAX_SCAN_FILES = 5000;
|
|
113
|
+
|
|
114
|
+
const list = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
|
|
115
|
+
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
116
|
+
const toPosix = (p: string): string => p.split(sep).join("/");
|
|
117
|
+
|
|
118
|
+
function readText(path: string): string | undefined {
|
|
119
|
+
try {
|
|
120
|
+
return readFileSync(path, "utf8");
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Project-relative POSIX path. */
|
|
127
|
+
export function relPath(root: string, path: string): string {
|
|
128
|
+
return toPosix(relative(resolve(root), resolve(root, path)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function inside(root: string, path: string): boolean {
|
|
132
|
+
const r = relPath(root, path);
|
|
133
|
+
return r !== "" && !r.startsWith("..") && !isAbsolute(r);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --------------------------------------------------------------------------
|
|
137
|
+
// Globs
|
|
138
|
+
// --------------------------------------------------------------------------
|
|
139
|
+
export function globToRegExp(glob: string): RegExp {
|
|
140
|
+
const pattern = toPosix(glob);
|
|
141
|
+
let out = "^";
|
|
142
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
143
|
+
const c = pattern[i];
|
|
144
|
+
if (c === "*") {
|
|
145
|
+
if (pattern[i + 1] === "*") {
|
|
146
|
+
i += 1;
|
|
147
|
+
if (pattern[i + 1] === "/") {
|
|
148
|
+
i += 1;
|
|
149
|
+
out += "(?:.*/)?";
|
|
150
|
+
} else {
|
|
151
|
+
out += ".*";
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
out += "[^/]*";
|
|
155
|
+
}
|
|
156
|
+
} else if (c === "?") {
|
|
157
|
+
out += "[^/]";
|
|
158
|
+
} else {
|
|
159
|
+
out += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return new RegExp(`${out}$`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function matchesAny(path: string, patterns: string[]): boolean {
|
|
166
|
+
return patterns.some((pattern) => globToRegExp(pattern).test(path));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --------------------------------------------------------------------------
|
|
170
|
+
// Configuration
|
|
171
|
+
// --------------------------------------------------------------------------
|
|
172
|
+
function parseBoundaries(data: unknown, where: string): Boundaries {
|
|
173
|
+
if (!isObject(data)) throw new Error(`${where}: root must be an object`);
|
|
174
|
+
const engine = data.engine === "scan" || data.engine === "command" ? data.engine : "auto";
|
|
175
|
+
const rules: BoundaryRule[] = [];
|
|
176
|
+
if (Array.isArray(data.rules)) {
|
|
177
|
+
data.rules.forEach((raw, index) => {
|
|
178
|
+
if (!isObject(raw)) throw new Error(`${where}: rules[${index}] must be an object`);
|
|
179
|
+
const id = typeof raw.id === "string" ? raw.id : `rule-${index + 1}`;
|
|
180
|
+
const from = raw.from;
|
|
181
|
+
const to = raw.to;
|
|
182
|
+
const effect = raw.effect;
|
|
183
|
+
if (typeof from !== "string" || !from) throw new Error(`${where}: ${id}.from is missing`);
|
|
184
|
+
if (!(typeof to === "string" || Array.isArray(to)) || (Array.isArray(to) && !to.length)) {
|
|
185
|
+
throw new Error(`${where}: ${id}.to is missing`);
|
|
186
|
+
}
|
|
187
|
+
if (effect !== "allow" && effect !== "deny") throw new Error(`${where}: ${id}.effect must be allow or deny`);
|
|
188
|
+
rules.push({
|
|
189
|
+
id,
|
|
190
|
+
from,
|
|
191
|
+
to: Array.isArray(to) ? list(to) : to,
|
|
192
|
+
effect,
|
|
193
|
+
reason: typeof raw.reason === "string" ? raw.reason : undefined,
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
version: 1,
|
|
199
|
+
engine,
|
|
200
|
+
command: typeof data.command === "string" ? data.command : "",
|
|
201
|
+
rules,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function loadBoundaries(root: string): Boundaries | undefined {
|
|
206
|
+
const path = join(root, BOUNDARIES_FILE);
|
|
207
|
+
const text = readText(path);
|
|
208
|
+
if (text === undefined || !text.trim()) return undefined;
|
|
209
|
+
return parseBoundaries(parseYaml(text), BOUNDARIES_FILE);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function loadChecks(root: string): ChecksFile | undefined {
|
|
213
|
+
const path = join(root, CHECKS_FILE);
|
|
214
|
+
const text = readText(path);
|
|
215
|
+
if (text === undefined || !text.trim()) return { version: 1, checks: [] };
|
|
216
|
+
const data: unknown = parseYaml(text);
|
|
217
|
+
if (!isObject(data)) throw new Error(`${CHECKS_FILE}: root must be an object`);
|
|
218
|
+
const checks: Check[] = [];
|
|
219
|
+
if (Array.isArray(data.checks)) {
|
|
220
|
+
data.checks.forEach((raw, index) => {
|
|
221
|
+
if (!isObject(raw)) throw new Error(`${CHECKS_FILE}: checks[${index}] must be an object`);
|
|
222
|
+
const name = typeof raw.name === "string" ? raw.name : `check-${index + 1}`;
|
|
223
|
+
if (typeof raw.command !== "string" || !raw.command.trim()) throw new Error(`${CHECKS_FILE}: ${name}.command is missing`);
|
|
224
|
+
const phase: Phase = raw.phase === "stop" || raw.phase === "ci" ? raw.phase : "edit";
|
|
225
|
+
checks.push({
|
|
226
|
+
name,
|
|
227
|
+
command: raw.command,
|
|
228
|
+
phase,
|
|
229
|
+
paths: typeof raw.paths === "string" ? [raw.paths] : list(raw.paths),
|
|
230
|
+
timeout: typeof raw.timeout === "number" && raw.timeout > 0 ? raw.timeout : 60,
|
|
231
|
+
advisory: raw.advisory === true,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return { version: 1, checks };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --------------------------------------------------------------------------
|
|
239
|
+
// Defaults for --init
|
|
240
|
+
// --------------------------------------------------------------------------
|
|
241
|
+
export function defaultRules(map: RepoMap): BoundaryRule[] {
|
|
242
|
+
const dirs = new Set(
|
|
243
|
+
map.packages.map((p) => (p.path === "." ? "" : p.path.split("/")[0])).filter(Boolean),
|
|
244
|
+
);
|
|
245
|
+
const rules: BoundaryRule[] = [];
|
|
246
|
+
if (dirs.has("libs")) {
|
|
247
|
+
rules.push({
|
|
248
|
+
id: "shared-libs-are-leaves",
|
|
249
|
+
from: "libs/**",
|
|
250
|
+
to: ["apps/**", "services/**"],
|
|
251
|
+
effect: "deny",
|
|
252
|
+
reason: "Shared libraries never import applications or services.",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (dirs.has("packages")) {
|
|
256
|
+
rules.push({
|
|
257
|
+
id: "shared-packages-are-leaves",
|
|
258
|
+
from: "packages/**",
|
|
259
|
+
to: ["apps/**", "services/**"],
|
|
260
|
+
effect: "deny",
|
|
261
|
+
reason: "Shared packages never import applications or services.",
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (dirs.has("apps")) {
|
|
265
|
+
rules.push({
|
|
266
|
+
id: "apps-do-not-import-apps",
|
|
267
|
+
from: "apps/**",
|
|
268
|
+
to: "apps/**",
|
|
269
|
+
effect: "deny",
|
|
270
|
+
reason: "Applications communicate through contracts or events, not imports.",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (dirs.has("services")) {
|
|
274
|
+
rules.push({
|
|
275
|
+
id: "services-do-not-import-apps",
|
|
276
|
+
from: "services/**",
|
|
277
|
+
to: "apps/**",
|
|
278
|
+
effect: "deny",
|
|
279
|
+
reason: "Services communicate through contracts or events, not imports.",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return rules;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Practical hints for stacks where the built-in JS/TS scanner is not the right engine. */
|
|
286
|
+
export function adapterHints(map: RepoMap): string[] {
|
|
287
|
+
const manager = map.tooling.manager || "npm";
|
|
288
|
+
switch (map.tooling.kind) {
|
|
289
|
+
case "nx":
|
|
290
|
+
return ["Nx detected: enable @nx/enforce-module-boundaries, then set engine: command and command: `npx nx run-many -t lint`."];
|
|
291
|
+
case "turbo":
|
|
292
|
+
return [`Turborepo detected: set engine: command and command: \`${manager} turbo run lint\` (or your boundary task).`];
|
|
293
|
+
case "bazel":
|
|
294
|
+
return ["Bazel detected: set engine: command and command: `bazel test //...`; Bazel visibility can carry the package boundaries."];
|
|
295
|
+
case "go-work":
|
|
296
|
+
return ["Go workspace detected: set engine: command to a script that checks `go list -deps` edges."];
|
|
297
|
+
case "cargo-workspace":
|
|
298
|
+
return ["Cargo workspace detected: set engine: command to a script that checks crate edges from `cargo metadata`."];
|
|
299
|
+
case "gradle":
|
|
300
|
+
return ["Gradle detected: add an ArchUnit test and set engine: command to `./gradlew test`."];
|
|
301
|
+
case "maven":
|
|
302
|
+
return ["Maven detected: add an ArchUnit test and set engine: command to `mvn test`."];
|
|
303
|
+
case "pnpm":
|
|
304
|
+
case "workspaces":
|
|
305
|
+
return ["JS/TS scan resolves relative imports and workspace-package names; TS path aliases need engine: command."];
|
|
306
|
+
default:
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function defaultChecks(): Check[] {
|
|
312
|
+
return [
|
|
313
|
+
{
|
|
314
|
+
name: "architecture-boundaries",
|
|
315
|
+
command: "node .claude/skills/adopt/scripts/boundary-check.ts --changed",
|
|
316
|
+
phase: "edit",
|
|
317
|
+
paths: ["apps/**", "libs/**", "services/**", "packages/**", "crates/**", "cmd/**", "internal/**", "src/**"],
|
|
318
|
+
timeout: 30,
|
|
319
|
+
advisory: false,
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: "architecture-boundaries-full",
|
|
323
|
+
command: "node .claude/skills/adopt/scripts/boundary-check.ts",
|
|
324
|
+
phase: "stop",
|
|
325
|
+
paths: [],
|
|
326
|
+
timeout: 120,
|
|
327
|
+
advisory: false,
|
|
328
|
+
},
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function writeBoundaries(root: string, boundaries: Boundaries, force = false): string {
|
|
333
|
+
const path = join(root, BOUNDARIES_FILE);
|
|
334
|
+
if (existsSync(path) && !force) throw new Error(`${BOUNDARIES_FILE} already exists – edit it, or pass --force`);
|
|
335
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
336
|
+
const header = [
|
|
337
|
+
"# Architecture boundaries – confirmed with the user during /adopt.",
|
|
338
|
+
"# Rules are evaluated in order; the first matching from -> to pair decides. No match means allow.",
|
|
339
|
+
"# Patterns are repository-relative globs. engine: auto scans JS/TS imports (relative and workspace packages);",
|
|
340
|
+
"# engine: command runs your own check (dependency-cruiser, Nx, Bazel, ArchUnit, import-linter) and ratchets its output.",
|
|
341
|
+
"",
|
|
342
|
+
].join("\n");
|
|
343
|
+
writeFileSync(path, `${header}${stringifyYaml(boundaries)}`, "utf8");
|
|
344
|
+
return path;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function writeChecks(root: string, checks: ChecksFile, force = false): string {
|
|
348
|
+
const path = join(root, CHECKS_FILE);
|
|
349
|
+
if (existsSync(path) && !force) throw new Error(`${CHECKS_FILE} already exists – edit it, or pass --force`);
|
|
350
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
351
|
+
const header = [
|
|
352
|
+
"# Project checks – what runs at edit time and at stop/CI.",
|
|
353
|
+
"# `paths` narrows a check to matching changed files (globs). `advisory: true` reports without blocking.",
|
|
354
|
+
"",
|
|
355
|
+
].join("\n");
|
|
356
|
+
writeFileSync(path, `${header}${stringifyYaml(checks)}`, "utf8");
|
|
357
|
+
return path;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// --------------------------------------------------------------------------
|
|
361
|
+
// Baseline
|
|
362
|
+
// --------------------------------------------------------------------------
|
|
363
|
+
export function emptyBaseline(): Baseline {
|
|
364
|
+
return { version: 1, violations: [], commands: [] };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function loadBaseline(root: string): Baseline {
|
|
368
|
+
const path = join(root, BASELINE_FILE);
|
|
369
|
+
const text = readText(path);
|
|
370
|
+
if (text === undefined) return emptyBaseline();
|
|
371
|
+
try {
|
|
372
|
+
const data: unknown = JSON.parse(text);
|
|
373
|
+
if (!isObject(data)) return emptyBaseline();
|
|
374
|
+
return {
|
|
375
|
+
version: 1,
|
|
376
|
+
violations: Array.isArray(data.violations) ? data.violations.filter(isObject).map((v) => ({
|
|
377
|
+
key: String(v.key ?? ""),
|
|
378
|
+
rule: String(v.rule ?? ""),
|
|
379
|
+
fromFile: String(v.fromFile ?? ""),
|
|
380
|
+
toFile: v.toFile === undefined ? undefined : String(v.toFile),
|
|
381
|
+
specifier: v.specifier === undefined ? undefined : String(v.specifier),
|
|
382
|
+
})) : [],
|
|
383
|
+
commands: Array.isArray(data.commands) ? data.commands.filter(isObject).map((c) => ({
|
|
384
|
+
key: String(c.key ?? ""),
|
|
385
|
+
name: c.name === undefined ? undefined : String(c.name),
|
|
386
|
+
output: c.output === undefined ? undefined : String(c.output),
|
|
387
|
+
})) : [],
|
|
388
|
+
};
|
|
389
|
+
} catch {
|
|
390
|
+
return emptyBaseline();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function writeBaseline(root: string, baseline: Baseline): string {
|
|
395
|
+
const path = join(root, BASELINE_FILE);
|
|
396
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
397
|
+
writeFileSync(path, `${JSON.stringify(baseline, null, 2)}\n`, "utf8");
|
|
398
|
+
return path;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// --------------------------------------------------------------------------
|
|
402
|
+
// Import graph (JS/TS)
|
|
403
|
+
// --------------------------------------------------------------------------
|
|
404
|
+
const IMPORT_PATTERNS = [
|
|
405
|
+
/\bimport\s+(?:[^'"]*?\s+from\s+)?["']([^"']+)["']/g,
|
|
406
|
+
/\bexport\s+(?:[^'"]*?\s+from\s+)["']([^"']+)["']/g,
|
|
407
|
+
/\brequire\s*\(\s*["']([^"']+)["']\s*\)/g,
|
|
408
|
+
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
|
|
409
|
+
];
|
|
410
|
+
|
|
411
|
+
function lineOf(text: string, index: number): number {
|
|
412
|
+
let line = 1;
|
|
413
|
+
for (let i = 0; i < index; i += 1) if (text[i] === "\n") line += 1;
|
|
414
|
+
return line;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function importsOf(text: string): { specifier: string; line: number }[] {
|
|
418
|
+
const out: { specifier: string; line: number }[] = [];
|
|
419
|
+
for (const pattern of IMPORT_PATTERNS) {
|
|
420
|
+
pattern.lastIndex = 0;
|
|
421
|
+
let match;
|
|
422
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
423
|
+
if (match[1]) out.push({ specifier: match[1], line: lineOf(text, match.index) });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function packageForPath(path: string, map: RepoMap): string | undefined {
|
|
430
|
+
const candidates = map.packages
|
|
431
|
+
.filter((p) => p.path === "." || path === p.path || path.startsWith(`${p.path}/`))
|
|
432
|
+
.sort((a, b) => b.path.length - a.path.length);
|
|
433
|
+
return candidates[0]?.path;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function resolveImport(root: string, fromFile: string, specifier: string, map: RepoMap): { file?: string; pkg?: string } {
|
|
437
|
+
if (specifier.startsWith(".")) {
|
|
438
|
+
const base = resolve(root, dirname(fromFile), specifier);
|
|
439
|
+
const candidates = [base, ...["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].map((ext) => `${base}${ext}`)];
|
|
440
|
+
for (const ext of [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]) {
|
|
441
|
+
candidates.push(join(base, `index${ext}`));
|
|
442
|
+
}
|
|
443
|
+
const hit = candidates.find((c) => existsSync(c));
|
|
444
|
+
if (!hit) return {};
|
|
445
|
+
const rel = relPath(root, hit);
|
|
446
|
+
if (rel.startsWith("..")) return {};
|
|
447
|
+
return { file: rel, pkg: packageForPath(rel, map) };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Workspace package import (`@acme/shared`, `@acme/shared/sub`) – resolve by package name.
|
|
451
|
+
const byName = map.packages.find((p) => specifier === p.name || specifier.startsWith(`${p.name}/`));
|
|
452
|
+
if (byName) return { file: byName.path, pkg: byName.path };
|
|
453
|
+
|
|
454
|
+
// TS path alias (`@/…`) is common but project-specific; the command engine covers projects that need it.
|
|
455
|
+
return {};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function walkCodeFiles(root: string, dirs: string[], only?: string): string[] {
|
|
459
|
+
if (only) {
|
|
460
|
+
const rel = relPath(root, only);
|
|
461
|
+
return CODE_EXT.has(extname(rel)) && inside(root, rel) ? [rel] : [];
|
|
462
|
+
}
|
|
463
|
+
const out: string[] = [];
|
|
464
|
+
const walk = (relDir: string): void => {
|
|
465
|
+
if (out.length >= MAX_SCAN_FILES) return;
|
|
466
|
+
const abs = join(root, relDir);
|
|
467
|
+
let entries;
|
|
468
|
+
try {
|
|
469
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
470
|
+
} catch {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
for (const entry of entries) {
|
|
474
|
+
if (out.length >= MAX_SCAN_FILES) return;
|
|
475
|
+
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
476
|
+
if (entry.isDirectory()) {
|
|
477
|
+
if (SCAN_IGNORED.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
478
|
+
walk(rel);
|
|
479
|
+
} else if (entry.isFile() && CODE_EXT.has(extname(entry.name))) {
|
|
480
|
+
out.push(rel);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
for (const dir of dirs) if (dir && existsSync(join(root, dir))) walk(dir === "." ? "" : dir);
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function scanImports(root: string, map: RepoMap, only?: string): ImportEdge[] {
|
|
489
|
+
const dirs = map.packages.map((p) => p.path);
|
|
490
|
+
const files = walkCodeFiles(root, dirs.length ? dirs : ["."], only);
|
|
491
|
+
const edges: ImportEdge[] = [];
|
|
492
|
+
const seen = new Set<string>();
|
|
493
|
+
for (const file of files) {
|
|
494
|
+
const text = readText(join(root, file));
|
|
495
|
+
if (text === undefined) continue;
|
|
496
|
+
for (const found of importsOf(text)) {
|
|
497
|
+
const key = `${file}|${found.specifier}|${found.line}`;
|
|
498
|
+
if (seen.has(key)) continue;
|
|
499
|
+
seen.add(key);
|
|
500
|
+
const target = resolveImport(root, file, found.specifier, map);
|
|
501
|
+
edges.push({
|
|
502
|
+
fromFile: file,
|
|
503
|
+
fromPackage: packageForPath(file, map),
|
|
504
|
+
toFile: target.file,
|
|
505
|
+
toPackage: target.pkg,
|
|
506
|
+
specifier: found.specifier,
|
|
507
|
+
line: found.line,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return edges;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// --------------------------------------------------------------------------
|
|
515
|
+
// Rule evaluation
|
|
516
|
+
// --------------------------------------------------------------------------
|
|
517
|
+
function ruleMatches(rule: BoundaryRule, edge: ImportEdge): boolean {
|
|
518
|
+
const fromTargets = [edge.fromFile, edge.fromPackage].filter((v): v is string => Boolean(v));
|
|
519
|
+
if (!fromTargets.some((target) => globToRegExp(rule.from).test(target))) return false;
|
|
520
|
+
const toPatterns = Array.isArray(rule.to) ? rule.to : [rule.to];
|
|
521
|
+
const toTargets = [edge.toFile, edge.toPackage, edge.specifier].filter((v): v is string => Boolean(v));
|
|
522
|
+
return toTargets.some((target) => toPatterns.some((pattern) => globToRegExp(pattern).test(target)));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function violationOf(edge: ImportEdge, rule: BoundaryRule): Violation {
|
|
526
|
+
return {
|
|
527
|
+
key: `${rule.id}|${edge.fromFile}|${edge.toFile ?? edge.specifier}`,
|
|
528
|
+
rule: rule.id,
|
|
529
|
+
fromFile: edge.fromFile,
|
|
530
|
+
fromPackage: edge.fromPackage,
|
|
531
|
+
toFile: edge.toFile,
|
|
532
|
+
toPackage: edge.toPackage,
|
|
533
|
+
specifier: edge.specifier,
|
|
534
|
+
line: edge.line,
|
|
535
|
+
reason: rule.reason,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function evaluate(edges: ImportEdge[], boundaries: Boundaries): Violation[] {
|
|
540
|
+
const out = new Map<string, Violation>();
|
|
541
|
+
for (const edge of edges) {
|
|
542
|
+
for (const rule of boundaries.rules) {
|
|
543
|
+
if (!ruleMatches(rule, edge)) continue;
|
|
544
|
+
if (rule.effect === "deny") {
|
|
545
|
+
const violation = violationOf(edge, rule);
|
|
546
|
+
if (!out.has(violation.key)) out.set(violation.key, violation);
|
|
547
|
+
}
|
|
548
|
+
break; // first matching rule decides
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return [...out.values()];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function commandFailure(name: string, output: string): CommandFailure {
|
|
555
|
+
const normalized = output.trim().split("\n").slice(-80).join("\n");
|
|
556
|
+
const key = `command|${createHash("sha256").update(`${name}\n${normalized}`).digest("hex")}`;
|
|
557
|
+
return { key, name, output: normalized };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function runBoundaryCommand(root: string, command: string, timeoutSeconds = 120): CommandFailure | undefined {
|
|
561
|
+
const result = spawnSync(command, {
|
|
562
|
+
cwd: root,
|
|
563
|
+
shell: true,
|
|
564
|
+
encoding: "utf8",
|
|
565
|
+
timeout: timeoutSeconds * 1000,
|
|
566
|
+
env: process.env,
|
|
567
|
+
});
|
|
568
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
|
569
|
+
if (result.status === 0) return undefined;
|
|
570
|
+
return commandFailure("architecture-command", output || `exit ${result.status ?? "?"}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function checkBoundaries(root: string, boundaries: Boundaries, map: RepoMap, only?: string): BoundaryResult {
|
|
574
|
+
const jsPath = map.packages.some((p) => p.language === "TypeScript" || p.language === "JavaScript");
|
|
575
|
+
const engine: Engine = boundaries.engine === "auto" ? (jsPath ? "scan" : "command") : boundaries.engine;
|
|
576
|
+
|
|
577
|
+
if (engine === "command") {
|
|
578
|
+
if (only) {
|
|
579
|
+
return { engine, checked: 0, violations: [], note: "command engine ignores --changed – run the full check at stop/CI" };
|
|
580
|
+
}
|
|
581
|
+
if (!boundaries.command) {
|
|
582
|
+
return { engine, checked: 0, violations: [], note: "engine: command without a command – nothing checked" };
|
|
583
|
+
}
|
|
584
|
+
const failure = runBoundaryCommand(root, boundaries.command);
|
|
585
|
+
return { engine, checked: 1, violations: [], command: failure };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const edges = scanImports(root, map, only);
|
|
589
|
+
return { engine, checked: edges.length, violations: evaluate(edges, boundaries) };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// --------------------------------------------------------------------------
|
|
593
|
+
// Reporting
|
|
594
|
+
// --------------------------------------------------------------------------
|
|
595
|
+
export interface BoundaryReport {
|
|
596
|
+
ok: boolean;
|
|
597
|
+
engine: Engine;
|
|
598
|
+
checked: number;
|
|
599
|
+
violations: Violation[];
|
|
600
|
+
newViolations: Violation[];
|
|
601
|
+
stale: { key: string; rule: string; fromFile: string; toFile?: string; specifier?: string }[];
|
|
602
|
+
command?: CommandFailure;
|
|
603
|
+
commandBaselined: boolean;
|
|
604
|
+
note?: string;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function evaluateBaseline(result: BoundaryResult, baseline: Baseline): BoundaryReport {
|
|
608
|
+
const known = new Set(baseline.violations.map((v) => v.key));
|
|
609
|
+
const currentKeys = new Set(result.violations.map((v) => v.key));
|
|
610
|
+
const newViolations = result.violations.filter((v) => !known.has(v.key));
|
|
611
|
+
const stale = baseline.violations.filter((v) => !currentKeys.has(v.key));
|
|
612
|
+
const commandBaselined = Boolean(result.command && baseline.commands.some((c) => c.key === result.command?.key));
|
|
613
|
+
return {
|
|
614
|
+
ok: newViolations.length === 0 && (!result.command || commandBaselined),
|
|
615
|
+
engine: result.engine,
|
|
616
|
+
checked: result.checked,
|
|
617
|
+
violations: result.violations,
|
|
618
|
+
newViolations,
|
|
619
|
+
stale,
|
|
620
|
+
command: result.command,
|
|
621
|
+
commandBaselined,
|
|
622
|
+
note: result.note,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function baselineFrom(result: BoundaryResult): Baseline {
|
|
627
|
+
const baseline = emptyBaseline();
|
|
628
|
+
for (const v of result.violations) {
|
|
629
|
+
baseline.violations.push({ key: v.key, rule: v.rule, fromFile: v.fromFile, toFile: v.toFile, specifier: v.specifier });
|
|
630
|
+
}
|
|
631
|
+
if (result.command) baseline.commands.push({ key: result.command.key, name: result.command.name, output: result.command.output });
|
|
632
|
+
return baseline;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function formatViolation(v: Violation): string {
|
|
636
|
+
const at = v.line ? `${v.fromFile}:${v.line}` : v.fromFile;
|
|
637
|
+
const target = v.toFile ?? v.specifier ?? "?";
|
|
638
|
+
return `- ${at} → ${target} [${v.rule}]${v.reason ? ` – ${v.reason}` : ""}`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// --------------------------------------------------------------------------
|
|
642
|
+
// Rules (`.claude/rules/*.md`)
|
|
643
|
+
// --------------------------------------------------------------------------
|
|
644
|
+
export interface RuleFile {
|
|
645
|
+
path: string;
|
|
646
|
+
paths: string[];
|
|
647
|
+
title: string;
|
|
648
|
+
/** The markdown body without the frontmatter, including the `# Title` heading. */
|
|
649
|
+
body: string;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function parseRuleFile(path: string, root = ROOT): { rule?: RuleFile; error?: string } {
|
|
653
|
+
const text = readText(path);
|
|
654
|
+
if (text === undefined) return { error: "unreadable" };
|
|
655
|
+
if (!text.startsWith("---\n")) return { error: "frontmatter is missing (file must start with '---')" };
|
|
656
|
+
const end = text.indexOf("\n---", 4);
|
|
657
|
+
if (end === -1) return { error: "frontmatter is not closed with '---'" };
|
|
658
|
+
let meta: unknown;
|
|
659
|
+
try {
|
|
660
|
+
meta = parseYaml(text.slice(4, end)) ?? {};
|
|
661
|
+
} catch (error) {
|
|
662
|
+
return { error: `invalid YAML: ${error instanceof Error ? error.message : String(error)}` };
|
|
663
|
+
}
|
|
664
|
+
if (!isObject(meta)) return { error: "frontmatter must be an object" };
|
|
665
|
+
const paths = typeof meta.paths === "string" ? [meta.paths] : list(meta.paths);
|
|
666
|
+
if (!paths.length) return { error: "paths is missing or empty" };
|
|
667
|
+
const body = text.slice(end + 4).replace(/^\n+/, "").trim();
|
|
668
|
+
if (!body) return { error: "rule body is empty" };
|
|
669
|
+
const title = /^#\s+(.+)$/m.exec(body)?.[1]?.trim() ?? "";
|
|
670
|
+
if (!title) return { error: "rule body needs a '# Title' heading" };
|
|
671
|
+
return { rule: { path: toPosix(relative(root, path)), paths, title, body } };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function ruleFiles(root: string): string[] {
|
|
675
|
+
const out: string[] = [];
|
|
676
|
+
const walk = (dir: string): void => {
|
|
677
|
+
let entries;
|
|
678
|
+
try {
|
|
679
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
680
|
+
} catch {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
for (const entry of entries) {
|
|
684
|
+
const path = join(dir, entry.name);
|
|
685
|
+
if (entry.isDirectory()) walk(path);
|
|
686
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) out.push(path);
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
walk(join(root, RULES_DIR));
|
|
690
|
+
return out.sort();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export function validateRules(root: string): { rules: RuleFile[]; errors: string[] } {
|
|
694
|
+
const rules: RuleFile[] = [];
|
|
695
|
+
const errors: string[] = [];
|
|
696
|
+
for (const path of ruleFiles(root)) {
|
|
697
|
+
const parsed = parseRuleFile(path, root);
|
|
698
|
+
if (parsed.error) errors.push(`${relPath(root, path)}: ${parsed.error}`);
|
|
699
|
+
else if (parsed.rule) rules.push(parsed.rule);
|
|
700
|
+
}
|
|
701
|
+
return { rules, errors };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export function writeRule(root: string, slug: string, paths: string[], title: string): string {
|
|
705
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error("slug must be kebab-case (a-z, 0-9, -)");
|
|
706
|
+
if (!paths.length) throw new Error("at least one --paths glob is required");
|
|
707
|
+
const path = join(root, RULES_DIR, `${slug}.md`);
|
|
708
|
+
if (existsSync(path)) throw new Error(`${relPath(root, path)} already exists – edit it instead of overwriting`);
|
|
709
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
710
|
+
const frontmatter = `paths:\n${paths.map((p) => ` - "${p}"`).join("\n")}`;
|
|
711
|
+
const content = [
|
|
712
|
+
"---",
|
|
713
|
+
frontmatter,
|
|
714
|
+
"---",
|
|
715
|
+
"",
|
|
716
|
+
`# ${title}`,
|
|
717
|
+
"",
|
|
718
|
+
"[OPEN] One rule per file. Write it as a checkable sentence and name where it is enforced",
|
|
719
|
+
"(lint, test, `.claude/rules`, `docs/engineering/checks.yaml`). Keep it under 40 lines.",
|
|
720
|
+
"",
|
|
721
|
+
].join("\n");
|
|
722
|
+
writeFileSync(path, content, "utf8");
|
|
723
|
+
return path;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// --------------------------------------------------------------------------
|
|
727
|
+
// Helpers used by the scripts
|
|
728
|
+
// --------------------------------------------------------------------------
|
|
729
|
+
export function rootMap(root: string): RepoMap {
|
|
730
|
+
// Boundary checks do not need the Turbo graph; skip the external command so they stay fast.
|
|
731
|
+
return readRepoMap(root) ?? scanRepo(root, { toolGraph: false });
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export function defaultRoot(): string {
|
|
735
|
+
return ROOT;
|
|
736
|
+
}
|