@windyroad/c4 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.
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "wr-c4",
3
+ "version": "0.1.0",
4
+ "description": "C4 architecture diagram generation and validation for Claude Code"
5
+ }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolve, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const utils = await import(resolve(__dirname, "../../shared/install-utils.mjs"));
8
+
9
+ const PLUGIN = "wr-c4";
10
+ const DEPS = [];
11
+
12
+ const flags = utils.parseStandardArgs(process.argv);
13
+
14
+ if (flags.help) {
15
+ console.log(`
16
+ Usage: npx @windyroad/c4 [options]
17
+
18
+ C4 architecture diagram generation and validation
19
+
20
+ Options:
21
+ --update Update this plugin and its skills
22
+ --uninstall Remove this plugin
23
+ --dry-run Show what would be done without executing
24
+ --help, -h Show this help
25
+ `);
26
+ process.exit(0);
27
+ }
28
+
29
+ if (flags.dryRun) {
30
+ utils.setDryRun(true);
31
+ console.log("[dry-run mode — no commands will be executed]\n");
32
+ }
33
+
34
+ utils.checkPrerequisites();
35
+
36
+ if (flags.uninstall) {
37
+ utils.uninstallPackage(PLUGIN);
38
+ } else if (flags.update) {
39
+ utils.updatePackage(PLUGIN);
40
+ } else {
41
+ utils.installPackage(PLUGIN, { deps: DEPS });
42
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@windyroad/c4",
3
+ "version": "0.2.0",
4
+ "description": "C4 architecture diagram generation and validation",
5
+ "bin": {
6
+ "windyroad-c4": "./bin/install.mjs"
7
+ },
8
+ "type": "module",
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/windyroad/agent-plugins.git",
13
+ "directory": "packages/c4"
14
+ },
15
+ "keywords": [
16
+ "claude-code",
17
+ "claude-code-plugin",
18
+ "ai-agent",
19
+ "ai-coding"
20
+ ],
21
+ "files": [
22
+ "bin/",
23
+ "agents/",
24
+ "hooks/",
25
+ "skills/",
26
+ ".claude-plugin/"
27
+ ]
28
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: wr:c4
3
+ description: Regenerate C4 architecture diagrams (C3 component + C4 code views) from source code
4
+ allowed-tools: Bash(node *)
5
+ ---
6
+
7
+ Run the C4 generator script to regenerate architecture diagrams from source code:
8
+
9
+ ```
10
+ node ${CLAUDE_SKILL_DIR}/scripts/c4-generate.mjs
11
+ ```
12
+
13
+ Report what files changed. Do not commit.
14
+
15
+ $ARGUMENTS
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * c4-generate.mjs — Regenerate C4 architecture diagrams from source code.
5
+ * Portable, self-contained (no npm deps). Run via: node c4-generate.mjs
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { detectSourceRoot, buildModel, toC3Mermaid, toC4Mermaid, toJson } from "./c4-lib.mjs";
11
+
12
+ const ROOT = process.cwd();
13
+ const OUT_DIR = path.join(ROOT, "docs", "architecture", "generated");
14
+ const OUT_JSON = path.join(OUT_DIR, "components.json");
15
+ const OUT_MERMAID = path.join(OUT_DIR, "components.mmd");
16
+ const C4_MODEL = path.join(ROOT, "docs", "architecture", "C4_MODEL.md");
17
+
18
+ const C3_START = "<!-- c3:generated:start -->";
19
+ const C3_END = "<!-- c3:generated:end -->";
20
+ const C4_START = "<!-- c4:generated:start -->";
21
+ const C4_END = "<!-- c4:generated:end -->";
22
+
23
+ const C4_SCAFFOLD = `# C4 Architecture Model
24
+
25
+ This repo uses a hybrid C4 approach:
26
+ - C1/C2 are curated for intent and business context.
27
+ - C3/C4 are generated from code to reduce drift.
28
+
29
+ ## C3: Component View (Generated)
30
+
31
+ ${C3_START}
32
+
33
+ ${C3_END}
34
+
35
+ ## C4: Code View (Generated)
36
+
37
+ File-level dependency diagrams per component. Dashed arrows indicate cross-component imports. Grey nodes are external files.
38
+
39
+ ${C4_START}
40
+
41
+ ${C4_END}
42
+
43
+ Regenerate: \`/c4\`
44
+ Check freshness: \`/c4-check\`
45
+ `;
46
+
47
+ function inlineGenerated(startMarker, endMarker, content) {
48
+ if (!fs.existsSync(C4_MODEL)) return;
49
+ const doc = fs.readFileSync(C4_MODEL, "utf8");
50
+ const startIdx = doc.indexOf(startMarker);
51
+ const endIdx = doc.indexOf(endMarker);
52
+ if (startIdx === -1 || endIdx === -1) return;
53
+
54
+ const before = doc.slice(0, startIdx + startMarker.length);
55
+ const after = doc.slice(endIdx);
56
+ const updated = `${before}\n\n${content}\n\n${after}`;
57
+ fs.writeFileSync(C4_MODEL, updated);
58
+ }
59
+
60
+ function main() {
61
+ const srcRoot = detectSourceRoot(ROOT);
62
+ const model = buildModel(srcRoot);
63
+ const json = toJson(model);
64
+ const c3Mermaid = toC3Mermaid(model);
65
+ const c4Mermaid = toC4Mermaid(model);
66
+
67
+ fs.mkdirSync(OUT_DIR, { recursive: true });
68
+
69
+ // Create scaffold if C4_MODEL.md doesn't exist
70
+ if (!fs.existsSync(C4_MODEL)) {
71
+ fs.mkdirSync(path.dirname(C4_MODEL), { recursive: true });
72
+ fs.writeFileSync(C4_MODEL, C4_SCAFFOLD);
73
+ }
74
+
75
+ fs.writeFileSync(OUT_JSON, json);
76
+ fs.writeFileSync(OUT_MERMAID, c3Mermaid);
77
+ inlineGenerated(C3_START, C3_END, `\`\`\`mermaid\n${c3Mermaid.trimEnd()}\n\`\`\``);
78
+ inlineGenerated(C4_START, C4_END, c4Mermaid);
79
+
80
+ console.log("PASS: C4 artifacts generated:");
81
+ console.log(`- ${path.relative(ROOT, OUT_JSON)}`);
82
+ console.log(`- ${path.relative(ROOT, OUT_MERMAID)}`);
83
+ console.log(`- ${path.relative(ROOT, C4_MODEL)} (C3 + C4 sections updated)`);
84
+ }
85
+
86
+ main();
@@ -0,0 +1,259 @@
1
+ /**
2
+ * c4-lib.mjs — Portable C4 model builder (pure Node.js, no npm deps).
3
+ * Shared by c4-generate.mjs and c4-check.mjs.
4
+ */
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Source root detection
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export function detectSourceRoot(projectRoot) {
14
+ // 1. Try tsconfig.json
15
+ const tsconfigPath = path.join(projectRoot, "tsconfig.json");
16
+ if (fs.existsSync(tsconfigPath)) {
17
+ try {
18
+ const raw = fs.readFileSync(tsconfigPath, "utf8");
19
+ // Strip single-line comments for lenient JSON parse
20
+ const stripped = raw.replace(/\/\/.*$/gm, "");
21
+ const tsconfig = JSON.parse(stripped);
22
+ const rootDir = tsconfig?.compilerOptions?.rootDir;
23
+ if (rootDir) {
24
+ const candidate = path.resolve(projectRoot, rootDir);
25
+ if (fs.existsSync(candidate)) return candidate;
26
+ }
27
+ const includes = tsconfig?.include;
28
+ if (Array.isArray(includes) && includes.length > 0) {
29
+ // Strip glob suffixes like /**/*
30
+ const first = includes[0].replace(/\/\*.*$/, "");
31
+ const candidate = path.resolve(projectRoot, first);
32
+ if (fs.existsSync(candidate)) return candidate;
33
+ }
34
+ } catch {
35
+ // Fall through to probing
36
+ }
37
+ }
38
+
39
+ // 2. Probe common directories
40
+ for (const probe of ["app/src", "src", "lib"]) {
41
+ const candidate = path.join(projectRoot, probe);
42
+ if (fs.existsSync(candidate)) return candidate;
43
+ }
44
+
45
+ // 3. Fall back to project root
46
+ const fallback = projectRoot;
47
+
48
+ // 4. Verify .ts files exist somewhere
49
+ if (!hasFilesWithExtension(fallback, ".ts")) {
50
+ for (const [ext, lang] of [[".py", "Python"], [".go", "Go"], [".rs", "Rust"], [".java", "Java"]]) {
51
+ if (hasFilesWithExtension(fallback, ext)) {
52
+ throw new Error(`C4 generation does not yet support ${lang} projects`);
53
+ }
54
+ }
55
+ throw new Error("No TypeScript source files found");
56
+ }
57
+
58
+ return fallback;
59
+ }
60
+
61
+ function hasFilesWithExtension(dir, ext) {
62
+ try {
63
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
64
+ for (const entry of entries) {
65
+ const full = path.join(dir, entry.name);
66
+ if (entry.isDirectory()) {
67
+ if (hasFilesWithExtension(full, ext)) return true;
68
+ } else if (entry.isFile() && entry.name.endsWith(ext)) {
69
+ return true;
70
+ }
71
+ }
72
+ } catch {
73
+ // Directory not readable
74
+ }
75
+ return false;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // File walking
80
+ // ---------------------------------------------------------------------------
81
+
82
+ function walk(dir, out) {
83
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
84
+ for (const entry of entries) {
85
+ const full = path.join(dir, entry.name);
86
+ if (entry.isDirectory()) {
87
+ walk(full, out);
88
+ continue;
89
+ }
90
+ if (!entry.isFile()) continue;
91
+ if (!entry.name.endsWith(".ts") || entry.name.endsWith(".test.ts")) continue;
92
+ out.push(full);
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Import parsing & resolution
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function parseImports(text) {
101
+ const specs = [];
102
+ const importRe = /import\s+[^"']*?["']([^"']+)["']/g;
103
+ const dynamicRe = /import\(\s*["']([^"']+)["']\s*\)/g;
104
+ const requireRe = /require\(\s*["']([^"']+)["']\s*\)/g;
105
+ let match;
106
+ while ((match = importRe.exec(text)) !== null) specs.push(match[1]);
107
+ while ((match = dynamicRe.exec(text)) !== null) specs.push(match[1]);
108
+ while ((match = requireRe.exec(text)) !== null) specs.push(match[1]);
109
+ return specs;
110
+ }
111
+
112
+ function resolveImport(fromFile, spec, srcRoot) {
113
+ if (!spec.startsWith(".")) return null;
114
+ const stripped = spec.replace(/\.js$/, "");
115
+ const base = path.resolve(path.dirname(fromFile), stripped);
116
+ const candidates = [base, `${base}.ts`, path.join(base, "index.ts")];
117
+ for (const candidate of candidates) {
118
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
119
+ return candidate;
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Model building
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function relToSrc(absPath, srcRoot) {
130
+ return path.relative(srcRoot, absPath).split(path.sep).join("/");
131
+ }
132
+
133
+ function componentIdForRel(rel) {
134
+ const [first] = rel.split("/");
135
+ if (!first || !rel.includes("/")) return "app";
136
+ return first;
137
+ }
138
+
139
+ export function buildModel(srcRoot) {
140
+ const files = [];
141
+ walk(srcRoot, files);
142
+
143
+ const componentFiles = new Map();
144
+ const dependencies = new Map();
145
+ const fileDeps = [];
146
+
147
+ for (const absFile of files) {
148
+ const fromRel = relToSrc(absFile, srcRoot);
149
+ const fromComp = componentIdForRel(fromRel);
150
+
151
+ if (!componentFiles.has(fromComp)) componentFiles.set(fromComp, new Set());
152
+ componentFiles.get(fromComp).add(fromRel);
153
+
154
+ if (!dependencies.has(fromComp)) dependencies.set(fromComp, new Set());
155
+
156
+ const text = fs.readFileSync(absFile, "utf8");
157
+ const specs = parseImports(text);
158
+ for (const spec of specs) {
159
+ const resolved = resolveImport(absFile, spec, srcRoot);
160
+ if (!resolved) continue;
161
+ const toRel = relToSrc(resolved, srcRoot);
162
+ const toComp = componentIdForRel(toRel);
163
+ fileDeps.push({ from: fromRel, to: toRel });
164
+ if (toComp !== fromComp) dependencies.get(fromComp).add(toComp);
165
+ }
166
+ }
167
+
168
+ const components = [...componentFiles.keys()]
169
+ .sort()
170
+ .map((id) => ({
171
+ id,
172
+ name: id === "app" ? "app-entry" : id,
173
+ kind: "generated",
174
+ files: [...(componentFiles.get(id) || [])].sort(),
175
+ depends_on: [...(dependencies.get(id) || [])].sort(),
176
+ }));
177
+
178
+ return {
179
+ generator_version: "1",
180
+ source_root: path.relative(process.cwd(), srcRoot).split(path.sep).join("/") || ".",
181
+ components,
182
+ fileDeps,
183
+ };
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Mermaid generation
188
+ // ---------------------------------------------------------------------------
189
+
190
+ export function toC3Mermaid(model) {
191
+ const lines = ["flowchart LR"];
192
+ for (const component of model.components) {
193
+ lines.push(` ${component.id}["${component.name}"]`);
194
+ }
195
+ for (const component of model.components) {
196
+ for (const to of component.depends_on) {
197
+ lines.push(` ${component.id} --> ${to}`);
198
+ }
199
+ }
200
+ lines.push("");
201
+ return `${lines.join("\n")}\n`;
202
+ }
203
+
204
+ function fileNodeId(relPath) {
205
+ return relPath.replace(/[/.\\-]/g, "_").replace(/\.ts$/, "");
206
+ }
207
+
208
+ function fileLabel(relPath) {
209
+ return path.basename(relPath, ".ts");
210
+ }
211
+
212
+ export function toC4Mermaid(model) {
213
+ const sections = [];
214
+
215
+ for (const component of model.components) {
216
+ const lines = ["flowchart LR"];
217
+ const fileSet = new Set(component.files);
218
+
219
+ for (const file of component.files) {
220
+ lines.push(` ${fileNodeId(file)}["${fileLabel(file)}"]`);
221
+ }
222
+
223
+ const externalNodes = new Set();
224
+ const edges = new Set();
225
+
226
+ for (const dep of model.fileDeps) {
227
+ if (!fileSet.has(dep.from)) continue;
228
+ const edgeKey = `${dep.from}|${dep.to}`;
229
+ if (edges.has(edgeKey)) continue;
230
+ edges.add(edgeKey);
231
+
232
+ if (fileSet.has(dep.to)) {
233
+ lines.push(` ${fileNodeId(dep.from)} --> ${fileNodeId(dep.to)}`);
234
+ } else {
235
+ const toCompId = componentIdForRel(dep.to);
236
+ const toComp = toCompId === "app" ? "app-entry" : toCompId;
237
+ const extId = fileNodeId(dep.to);
238
+ if (!externalNodes.has(dep.to)) {
239
+ externalNodes.add(dep.to);
240
+ lines.push(` ${extId}["${toComp}/${fileLabel(dep.to)}"]:::ext`);
241
+ }
242
+ lines.push(` ${fileNodeId(dep.from)} -.-> ${extId}`);
243
+ }
244
+ }
245
+
246
+ if (externalNodes.size > 0) {
247
+ lines.push(` classDef ext fill:#f0f0f0,stroke:#999,stroke-dasharray:5 5`);
248
+ }
249
+
250
+ lines.push("");
251
+ sections.push(`### ${component.name}\n\n\`\`\`mermaid\n${lines.join("\n")}\n\`\`\``);
252
+ }
253
+
254
+ return sections.join("\n\n");
255
+ }
256
+
257
+ export function toJson(model) {
258
+ return `${JSON.stringify(model, null, 2)}\n`;
259
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: wr:c4-check
3
+ description: Check whether C4 architecture diagrams are up to date with source code
4
+ allowed-tools: Bash(node *)
5
+ ---
6
+
7
+ Run the C4 check script to verify architecture diagrams are current:
8
+
9
+ ```
10
+ node ${CLAUDE_SKILL_DIR}/scripts/c4-check.mjs
11
+ ```
12
+
13
+ Report PASS or FAIL with details.
14
+
15
+ $ARGUMENTS
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * c4-check.mjs — Check whether C4 architecture diagrams are up to date.
5
+ * Portable, self-contained (no npm deps). Run via: node c4-check.mjs
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { detectSourceRoot, buildModel, toJson } from "../../c4/scripts/c4-lib.mjs";
11
+
12
+ const ROOT = process.cwd();
13
+ const COMPONENTS_FILE = path.join(ROOT, "docs", "architecture", "generated", "components.json");
14
+ const POLICY_FILE = path.join(ROOT, "governance", "architecture-conformance-policy.json");
15
+
16
+ function main() {
17
+ const srcRoot = detectSourceRoot(ROOT);
18
+ const model = buildModel(srcRoot);
19
+ const freshJson = toJson(model);
20
+ const failures = [];
21
+
22
+ // 1. Compare JSON against existing components.json
23
+ if (!fs.existsSync(COMPONENTS_FILE)) {
24
+ failures.push("missing generated architecture model: docs/architecture/generated/components.json");
25
+ } else {
26
+ const existingJson = fs.readFileSync(COMPONENTS_FILE, "utf8");
27
+ if (existingJson !== freshJson) {
28
+ failures.push("C4 model is stale — run /c4 to regenerate");
29
+ }
30
+ }
31
+
32
+ // 2. Conformance policy check (if policy file exists)
33
+ if (fs.existsSync(POLICY_FILE)) {
34
+ const policy = JSON.parse(fs.readFileSync(POLICY_FILE, "utf8"));
35
+ const components = new Map();
36
+ for (const component of model.components) {
37
+ components.set(component.id, new Set(component.depends_on || []));
38
+ }
39
+
40
+ for (const id of policy.required_components || []) {
41
+ if (!components.has(id)) {
42
+ failures.push(`missing required component: ${id}`);
43
+ }
44
+ }
45
+
46
+ for (const rule of policy.forbidden_dependencies || []) {
47
+ const deps = components.get(rule.from);
48
+ if (!deps) {
49
+ failures.push(`forbidden dependency rule references unknown component: ${rule.from}`);
50
+ continue;
51
+ }
52
+ if (deps.has(rule.to)) {
53
+ failures.push(
54
+ `forbidden dependency: ${rule.from} -> ${rule.to}${rule.reason ? ` (${rule.reason})` : ""}`
55
+ );
56
+ }
57
+ }
58
+
59
+ for (const rule of policy.required_dependencies || []) {
60
+ const deps = components.get(rule.from);
61
+ if (!deps) {
62
+ failures.push(`required dependency rule references unknown component: ${rule.from}`);
63
+ continue;
64
+ }
65
+ if (!deps.has(rule.to)) {
66
+ failures.push(
67
+ `missing required dependency: ${rule.from} -> ${rule.to}${rule.reason ? ` (${rule.reason})` : ""}`
68
+ );
69
+ }
70
+ }
71
+ }
72
+
73
+ if (failures.length > 0) {
74
+ console.error("FAIL: C4 architecture check:");
75
+ for (const failure of failures) {
76
+ console.error(`- ${failure}`);
77
+ }
78
+ process.exit(1);
79
+ }
80
+
81
+ console.log("PASS: C4 architecture diagrams are up to date.");
82
+ }
83
+
84
+ main();