arcshape 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +91 -2
  3. package/bin/arcshape.mjs +93 -0
  4. package/bin/confluence-pages.mjs +569 -0
  5. package/bin/confluence.mjs +263 -0
  6. package/bin/init.mjs +160 -0
  7. package/bin/product-citations.mjs +104 -0
  8. package/bin/session.mjs +753 -0
  9. package/bin/site.mjs +59 -0
  10. package/bin/sync.mjs +352 -0
  11. package/bin/validate-links.mjs +98 -0
  12. package/bin/validate.mjs +126 -0
  13. package/package.json +62 -4
  14. package/templates/architecture/01-introduction-and-goals.md +13 -0
  15. package/templates/architecture/02-constraints.md +13 -0
  16. package/templates/architecture/03-context-and-scope.md +11 -0
  17. package/templates/architecture/04-solution-strategy.md +13 -0
  18. package/templates/architecture/05-building-block-view.md +11 -0
  19. package/templates/architecture/06-runtime-view.md +9 -0
  20. package/templates/architecture/07-deployment-view.md +11 -0
  21. package/templates/architecture/08-crosscutting-concepts.md +13 -0
  22. package/templates/architecture/09-architecture-decisions.md +9 -0
  23. package/templates/architecture/10-quality-requirements.md +11 -0
  24. package/templates/architecture/11-risks-and-technical-debt.md +11 -0
  25. package/templates/architecture/12-glossary.md +10 -0
  26. package/templates/architecture/README.md +20 -0
  27. package/templates/assistants/.agents/skills/arcshape-adr/SKILL.md +16 -0
  28. package/templates/assistants/.agents/skills/arcshape-architecture/SKILL.md +35 -0
  29. package/templates/assistants/.agents/skills/arcshape-c4/SKILL.md +13 -0
  30. package/templates/assistants/.agents/skills/arcshape-docs-sync/SKILL.md +12 -0
  31. package/templates/assistants/.claude/commands/arcshape/adr.md +5 -0
  32. package/templates/assistants/.claude/commands/arcshape/c4.md +5 -0
  33. package/templates/assistants/.claude/commands/arcshape/document.md +5 -0
  34. package/templates/assistants/.claude/commands/arcshape/reverse-engineer.md +5 -0
  35. package/templates/assistants/.claude/commands/arcshape/sync.md +5 -0
  36. package/templates/assistants/.claude/skills/arcshape-adr/SKILL.md +16 -0
  37. package/templates/assistants/.claude/skills/arcshape-architecture/SKILL.md +35 -0
  38. package/templates/assistants/.claude/skills/arcshape-c4/SKILL.md +13 -0
  39. package/templates/assistants/.claude/skills/arcshape-docs-sync/SKILL.md +12 -0
  40. package/tools/arc42-site/LICENSE +21 -0
  41. package/tools/arc42-site/NOTICE.md +3 -0
  42. package/tools/arc42-site/README.md +3 -0
  43. package/tools/arc42-site/browser-options.mjs +14 -0
  44. package/tools/arc42-site/capture-evidence.mjs +84 -0
  45. package/tools/arc42-site/generate.mjs +227 -0
  46. package/tools/arc42-site/loadDocs.mjs +196 -0
  47. package/tools/arc42-site/render.mjs +411 -0
  48. package/tools/arc42-site/shell.mjs +761 -0
  49. package/tools/arc42-site/validate-diagrams.mjs +27 -0
package/bin/site.mjs ADDED
@@ -0,0 +1,59 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { resolve, join } from "node:path";
3
+
4
+ export async function runSite(args, { packageRoot }) {
5
+ const options = parseOptions(args);
6
+ if (options.help) {
7
+ process.stdout.write(
8
+ "Usage: arcshape site build [--docs <directory>] [--out <html-file>] [--title <name>] [--source-ref <ref>]\n\n",
9
+ );
10
+ process.stdout.write(
11
+ "Build a self-contained reader site from the canonical architecture Markdown.\n",
12
+ );
13
+ return { exitCode: 0 };
14
+ }
15
+ if (options.command !== "build") {
16
+ throw new Error("expected `arcshape site build`; use --help for options");
17
+ }
18
+
19
+ const docsDir = resolve(process.cwd(), options.docs ?? "docs/architecture");
20
+ const outFile = resolve(
21
+ process.cwd(),
22
+ options.out ?? "docs/architecture-site/index.html",
23
+ );
24
+ const generator = join(packageRoot, "tools", "arc42-site", "generate.mjs");
25
+ const argv = [generator, docsDir, "--out", outFile];
26
+ if (options.title) argv.push("--title", options.title);
27
+ if (options.sourceRef) argv.push("--source-ref", options.sourceRef);
28
+ const result = spawnSync(process.execPath, argv, { stdio: "inherit" });
29
+ if (result.error) throw result.error;
30
+ return { exitCode: result.status ?? 1 };
31
+ }
32
+
33
+ function parseOptions(args) {
34
+ const options = { help: false };
35
+ let index = 0;
36
+ if (args[0] === "build") {
37
+ options.command = "build";
38
+ index = 1;
39
+ } else if (args[0] === "--help" || args[0] === "-h") options.help = true;
40
+ else if (args[0] !== undefined) {
41
+ options.command = args[0];
42
+ index = 1;
43
+ }
44
+ for (; index < args.length; index += 1) {
45
+ const arg = args[index];
46
+ if (arg === "--help" || arg === "-h") options.help = true;
47
+ else if (["--docs", "--out", "--title", "--source-ref"].includes(arg)) {
48
+ const value = args[index + 1];
49
+ if (!value || value.startsWith("--"))
50
+ throw new Error(`${arg} requires a value`);
51
+ if (arg === "--docs") options.docs = value;
52
+ if (arg === "--out") options.out = value;
53
+ if (arg === "--title") options.title = value;
54
+ if (arg === "--source-ref") options.sourceRef = value;
55
+ index += 1;
56
+ } else throw new Error(`unknown site option: ${arg}`);
57
+ }
58
+ return options;
59
+ }
package/bin/sync.mjs ADDED
@@ -0,0 +1,352 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import {
4
+ dirname,
5
+ isAbsolute,
6
+ join,
7
+ parse,
8
+ relative,
9
+ resolve,
10
+ sep,
11
+ } from "node:path";
12
+ import { runConfluenceSync } from "./confluence.mjs";
13
+ import { syncConfluencePages } from "./confluence-pages.mjs";
14
+
15
+ const MANIFEST_NAME = ".arcshape-sync.json";
16
+
17
+ export async function runSync(args) {
18
+ const [provider, ...providerArgs] = args;
19
+ if (provider === "--help" || provider === "-h" || provider === undefined) {
20
+ printHelp();
21
+ return { exitCode: 0 };
22
+ }
23
+ if (provider === "confluence") {
24
+ const options = parseConfluenceOptions(providerArgs);
25
+ if (options.help) {
26
+ printHelp();
27
+ return { exitCode: 0 };
28
+ }
29
+ if (options.spaceId || options.parentId || options.titlePrefix) {
30
+ if (options.pageId)
31
+ throw new Error(
32
+ "use either --page-id or the multi-page --space-id/--parent-id destination",
33
+ );
34
+ return syncConfluencePages(options);
35
+ }
36
+ return runConfluenceSync(options);
37
+ }
38
+ if (provider !== "obsidian")
39
+ throw new Error("supported sync providers: obsidian, confluence");
40
+
41
+ const options = parseOptions(providerArgs);
42
+ if (options.help) {
43
+ printHelp();
44
+ return { exitCode: 0 };
45
+ }
46
+ if (!options.vault) throw new Error("sync obsidian requires --vault <path>");
47
+
48
+ const repository = resolve(process.cwd());
49
+ const docsRoot = resolve(repository, options.docs ?? "docs/architecture");
50
+ const vaultRoot = resolve(repository, options.vault);
51
+ const relativeTarget = options.path ?? `Architecture/${basename(repository)}`;
52
+ const targetRoot = resolve(vaultRoot, relativeTarget);
53
+ if (!isInside(vaultRoot, targetRoot) || targetRoot === vaultRoot)
54
+ throw new Error("--path must select a child directory inside the vault");
55
+ if (!(await isDirectory(vaultRoot)))
56
+ throw new Error(`Obsidian vault directory does not exist: ${vaultRoot}`);
57
+ if (await hasSymlink(vaultRoot, targetRoot))
58
+ throw new Error("Obsidian target path contains a symbolic link");
59
+
60
+ const includeRoots = await Promise.all(
61
+ (options.include ?? []).map(async (include) => {
62
+ const path = resolve(repository, include);
63
+ if (!isInside(repository, path))
64
+ throw new Error(
65
+ `--include must stay inside the repository: ${include}`,
66
+ );
67
+ if (await isSymlink(path))
68
+ throw new Error(`--include path cannot be a symbolic link: ${include}`);
69
+ if (!(await isDirectory(path)))
70
+ throw new Error(
71
+ `--include must select a Markdown directory: ${include}`,
72
+ );
73
+ return path;
74
+ }),
75
+ );
76
+ const sourceRoots = [docsRoot, ...includeRoots];
77
+ const contentByPath = new Map();
78
+ const commonRoot = commonAncestor(sourceRoots);
79
+ for (const sourceRoot of sourceRoots) {
80
+ for (const file of await collectMarkdown(sourceRoot)) {
81
+ const absolutePath = join(sourceRoot, file.path);
82
+ const path = relative(commonRoot, absolutePath).split(sep).join("/");
83
+ contentByPath.set(path, file.content);
84
+ }
85
+ }
86
+ const sourceFiles = [...contentByPath].map(([path, content]) => ({
87
+ path,
88
+ content,
89
+ }));
90
+ if (sourceFiles.length === 0)
91
+ throw new Error(`no Markdown files found under ${docsRoot}`);
92
+ const manifestPath = join(targetRoot, MANIFEST_NAME);
93
+ if (await isSymlink(manifestPath))
94
+ throw new Error(
95
+ `refusing to read a symbolic-link manifest: ${manifestPath}`,
96
+ );
97
+ const manifest = await readManifest(manifestPath);
98
+ const nextManifest = { version: 1, files: { ...manifest.files } };
99
+ const created = [];
100
+ const updated = [];
101
+ const unchanged = [];
102
+ const conflicts = [];
103
+ const seenSourcePaths = new Set();
104
+
105
+ for (const source of sourceFiles) {
106
+ seenSourcePaths.add(source.path);
107
+ const destination = resolve(targetRoot, source.path);
108
+ if (!isInside(targetRoot, destination)) {
109
+ conflicts.push({ path: source.path, reason: "resolved outside target" });
110
+ continue;
111
+ }
112
+ if (await hasSymlink(targetRoot, destination)) {
113
+ conflicts.push({
114
+ path: source.path,
115
+ reason: "target path is a symbolic link",
116
+ });
117
+ continue;
118
+ }
119
+
120
+ const sourceDigest = digest(source.content);
121
+ const previousDigest = manifest.files[source.path];
122
+ let destinationContent;
123
+ try {
124
+ destinationContent = await readFile(destination);
125
+ } catch (error) {
126
+ if (error.code !== "ENOENT") throw error;
127
+ }
128
+
129
+ if (destinationContent === undefined) {
130
+ if (!options.dryRun) {
131
+ await mkdir(dirname(destination), { recursive: true });
132
+ try {
133
+ await writeFile(destination, source.content, { flag: "wx" });
134
+ } catch (error) {
135
+ if (error.code !== "EEXIST") throw error;
136
+ conflicts.push({
137
+ path: source.path,
138
+ reason: "a vault file appeared during synchronization",
139
+ });
140
+ continue;
141
+ }
142
+ }
143
+ created.push(source.path);
144
+ nextManifest.files[source.path] = sourceDigest;
145
+ } else {
146
+ const destinationDigest = digest(destinationContent);
147
+ if (destinationDigest === sourceDigest) {
148
+ unchanged.push(source.path);
149
+ nextManifest.files[source.path] = sourceDigest;
150
+ } else if (
151
+ previousDigest !== undefined &&
152
+ destinationDigest === previousDigest
153
+ ) {
154
+ if (!options.dryRun) await writeFile(destination, source.content);
155
+ updated.push(source.path);
156
+ nextManifest.files[source.path] = sourceDigest;
157
+ } else {
158
+ conflicts.push({
159
+ path: source.path,
160
+ reason: "vault copy has local edits; review or reconcile it manually",
161
+ });
162
+ }
163
+ }
164
+ }
165
+
166
+ const orphaned = Object.keys(manifest.files).filter(
167
+ (path) => !seenSourcePaths.has(path),
168
+ );
169
+ if (!options.dryRun && !conflicts.length) {
170
+ await mkdir(targetRoot, { recursive: true });
171
+ await writeFile(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`);
172
+ }
173
+
174
+ const result = {
175
+ action: options.dryRun ? "dry-run" : "sync",
176
+ provider: "obsidian",
177
+ source: docsRoot,
178
+ target: targetRoot,
179
+ created,
180
+ updated,
181
+ unchanged,
182
+ orphaned,
183
+ conflicts,
184
+ };
185
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
186
+ return { exitCode: conflicts.length ? 1 : 0, result };
187
+ }
188
+
189
+ function printHelp() {
190
+ process.stdout.write(
191
+ "Usage: arcshape sync obsidian --vault <directory> [--docs <directory>] [--include <Markdown-directory>] [--path <vault-subdirectory>] [--dry-run]\n\n",
192
+ );
193
+ process.stdout.write(
194
+ " arcshape sync confluence --base-url <url> --page-id <id> [--docs <directory>] [--dry-run]\n",
195
+ );
196
+ process.stdout.write(
197
+ " arcshape sync confluence --base-url <url> --space-id <id> --parent-id <id> [--docs <directory>] [--include <file-or-directory>] [--title-prefix <name>] [--dry-run]\n",
198
+ );
199
+ process.stdout.write(
200
+ "Sync canonical Markdown to Obsidian or Confluence. Multi-page sync creates one page per arc42 section under a parent page.\n",
201
+ );
202
+ process.stdout.write(
203
+ "Vault and page edits are preserved and reported as conflicts. Confluence credentials use CONFLUENCE_EMAIL and CONFLUENCE_API_TOKEN by default.\n",
204
+ );
205
+ }
206
+
207
+ function parseConfluenceOptions(args) {
208
+ const options = { dryRun: false, help: false };
209
+ for (let index = 0; index < args.length; index += 1) {
210
+ const arg = args[index];
211
+ if (arg === "--help" || arg === "-h") options.help = true;
212
+ else if (arg === "--dry-run") options.dryRun = true;
213
+ else if (
214
+ [
215
+ "--base-url",
216
+ "--page-id",
217
+ "--space-id",
218
+ "--parent-id",
219
+ "--title-prefix",
220
+ "--include",
221
+ "--docs",
222
+ "--email-env",
223
+ "--token-env",
224
+ ].includes(arg)
225
+ ) {
226
+ const value = args[index + 1];
227
+ if (!value || value.startsWith("--"))
228
+ throw new Error(`${arg} requires a value`);
229
+ if (arg === "--base-url") options.baseUrl = value;
230
+ else if (arg === "--page-id") options.pageId = value;
231
+ else if (arg === "--space-id") options.spaceId = value;
232
+ else if (arg === "--parent-id") options.parentId = value;
233
+ else if (arg === "--title-prefix") options.titlePrefix = value;
234
+ else if (arg === "--include") (options.include ??= []).push(value);
235
+ else if (arg === "--docs") options.docs = value;
236
+ else if (arg === "--email-env") options.emailEnv = value;
237
+ else options.tokenEnv = value;
238
+ index += 1;
239
+ } else throw new Error(`Unknown Confluence sync option: ${arg}`);
240
+ }
241
+ return options;
242
+ }
243
+
244
+ function parseOptions(args) {
245
+ const options = { dryRun: false, help: false };
246
+ for (let index = 0; index < args.length; index += 1) {
247
+ const arg = args[index];
248
+ if (arg === "--help" || arg === "-h") options.help = true;
249
+ else if (arg === "--dry-run") options.dryRun = true;
250
+ else if (["--vault", "--docs", "--include", "--path"].includes(arg)) {
251
+ const value = args[index + 1];
252
+ if (!value || value.startsWith("--"))
253
+ throw new Error(`${arg} requires a value`);
254
+ if (arg === "--vault") options.vault = value;
255
+ else if (arg === "--docs") options.docs = value;
256
+ else if (arg === "--include") (options.include ??= []).push(value);
257
+ else options.path = value;
258
+ index += 1;
259
+ } else throw new Error(`Unknown sync option: ${arg}`);
260
+ }
261
+ return options;
262
+ }
263
+
264
+ async function collectMarkdown(root, parent = "") {
265
+ const entries = await readdir(join(root, parent), { withFileTypes: true });
266
+ const files = [];
267
+ for (const entry of entries.sort((left, right) =>
268
+ left.name.localeCompare(right.name),
269
+ )) {
270
+ const path = join(parent, entry.name);
271
+ if (entry.isDirectory()) files.push(...(await collectMarkdown(root, path)));
272
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
273
+ files.push({ path, content: await readFile(join(root, path)) });
274
+ }
275
+ return files;
276
+ }
277
+
278
+ async function readManifest(path) {
279
+ try {
280
+ const manifest = JSON.parse(await readFile(path, "utf8"));
281
+ if (
282
+ manifest.version !== 1 ||
283
+ manifest.files === null ||
284
+ typeof manifest.files !== "object" ||
285
+ Array.isArray(manifest.files) ||
286
+ Object.values(manifest.files).some(
287
+ (value) =>
288
+ typeof value !== "string" || !/^sha256:[a-f\d]{64}$/.test(value),
289
+ )
290
+ )
291
+ throw new Error("manifest has an unsupported or invalid format");
292
+ return manifest;
293
+ } catch (error) {
294
+ if (error.code === "ENOENT") return { version: 1, files: {} };
295
+ throw new Error(`cannot read sync manifest: ${error.message}`);
296
+ }
297
+ }
298
+
299
+ function digest(content) {
300
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
301
+ }
302
+
303
+ function isInside(root, path) {
304
+ const rel = relative(root, path);
305
+ return (
306
+ rel === "" ||
307
+ (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`))
308
+ );
309
+ }
310
+
311
+ async function isDirectory(path) {
312
+ try {
313
+ return (await lstat(path)).isDirectory();
314
+ } catch (error) {
315
+ if (error.code === "ENOENT") return false;
316
+ throw error;
317
+ }
318
+ }
319
+
320
+ async function isSymlink(path) {
321
+ try {
322
+ return (await lstat(path)).isSymbolicLink();
323
+ } catch (error) {
324
+ if (error.code === "ENOENT") return false;
325
+ throw error;
326
+ }
327
+ }
328
+
329
+ async function hasSymlink(root, path) {
330
+ const parts = relative(root, path).split(sep);
331
+ let current = root;
332
+ for (const part of parts) {
333
+ current = join(current, part);
334
+ if (await isSymlink(current)) return true;
335
+ }
336
+ return false;
337
+ }
338
+
339
+ function basename(path) {
340
+ return path.split(/[\\/]/).filter(Boolean).at(-1) ?? "architecture";
341
+ }
342
+
343
+ function commonAncestor(paths) {
344
+ const [first, ...rest] = paths.map((path) => resolve(path));
345
+ let candidate = first;
346
+ while (rest.some((path) => !isInside(candidate, path))) {
347
+ const parent = dirname(candidate);
348
+ if (parent === candidate) return parse(candidate).root;
349
+ candidate = parent;
350
+ }
351
+ return candidate;
352
+ }
@@ -0,0 +1,98 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { isAbsolute, resolve, sep } from "node:path";
3
+ import { marked } from "marked";
4
+ import GithubSlugger from "github-slugger";
5
+
6
+ export async function validateMarkdownLinks(
7
+ documents,
8
+ repositoryRoot,
9
+ docsDir,
10
+ ) {
11
+ const problems = [];
12
+ for (const document of documents) {
13
+ const source = resolve(docsDir, document.file);
14
+ const tokens = marked.lexer(document.body);
15
+ const links = [];
16
+ marked.walkTokens(tokens, (token) => {
17
+ if (token.type === "link" || token.type === "image")
18
+ links.push(token.href);
19
+ });
20
+ for (const href of links) {
21
+ if (!href || href.startsWith("#")) continue;
22
+ let target;
23
+ try {
24
+ target = new URL(href, "https://arcshape.invalid");
25
+ } catch {
26
+ problems.push(`${document.file}: invalid link destination ${href}`);
27
+ continue;
28
+ }
29
+ if (target.origin !== "https://arcshape.invalid") continue;
30
+ let pathname;
31
+ try {
32
+ pathname = decodeURIComponent(href.split(/[?#]/, 1)[0]);
33
+ } catch {
34
+ problems.push(`${document.file}: invalid URL encoding in ${href}`);
35
+ continue;
36
+ }
37
+ const resolvedTarget = pathname.startsWith("/")
38
+ ? resolve(repositoryRoot, pathname.slice(1))
39
+ : resolve(source, "..", pathname);
40
+ const relativePath = relativeFromRoot(repositoryRoot, resolvedTarget);
41
+ if (
42
+ isAbsolute(relativePath) ||
43
+ relativePath === ".." ||
44
+ relativePath.startsWith(`..${sep}`)
45
+ ) {
46
+ problems.push(`${document.file}: link escapes the repository: ${href}`);
47
+ continue;
48
+ }
49
+ try {
50
+ await access(resolvedTarget);
51
+ } catch {
52
+ problems.push(`${document.file}: missing local target ${href}`);
53
+ continue;
54
+ }
55
+ if (!target.hash || !pathname.toLowerCase().endsWith(".md")) continue;
56
+ let fragment;
57
+ try {
58
+ fragment = decodeURIComponent(target.hash.slice(1));
59
+ } catch {
60
+ problems.push(
61
+ `${document.file}: invalid heading URL encoding in ${href}`,
62
+ );
63
+ continue;
64
+ }
65
+ try {
66
+ const targetContents = await readFile(resolvedTarget, "utf8");
67
+ const slugger = new GithubSlugger();
68
+ let found = false;
69
+ marked.walkTokens(marked.lexer(targetContents), (candidate) => {
70
+ if (
71
+ candidate.type === "heading" &&
72
+ slugger.slug(candidate.text) === fragment
73
+ )
74
+ found = true;
75
+ });
76
+ if (!found)
77
+ problems.push(
78
+ `${document.file}: missing heading #${fragment} in ${href}`,
79
+ );
80
+ } catch {
81
+ problems.push(
82
+ `${document.file}: cannot read local Markdown target ${href}`,
83
+ );
84
+ }
85
+ }
86
+ }
87
+ return problems;
88
+ }
89
+
90
+ function relativeFromRoot(root, path) {
91
+ const normalizedRoot = resolve(root);
92
+ const normalizedPath = resolve(path);
93
+ return normalizedPath.startsWith(`${normalizedRoot}${sep}`)
94
+ ? normalizedPath.slice(normalizedRoot.length + 1)
95
+ : normalizedPath === normalizedRoot
96
+ ? ""
97
+ : normalizedPath;
98
+ }
@@ -0,0 +1,126 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { loadDocs } from "../tools/arc42-site/loadDocs.mjs";
3
+ import { verifyProductCitations } from "./product-citations.mjs";
4
+ import { validateMarkdownLinks } from "./validate-links.mjs";
5
+
6
+ export async function runValidate(args, runtime = {}) {
7
+ const options = parseOptions(args);
8
+ if (options.help) {
9
+ process.stdout.write(
10
+ "Usage: arcshape validate [--docs <directory>] [--links] [--citations] [--format text|json]\n\n",
11
+ );
12
+ process.stdout.write(
13
+ "Validate the README and all twelve Markdown arc42 sections. Use --links to check local links and --citations to verify ProductShape citations when a model is present.\n",
14
+ );
15
+ return { exitCode: 0 };
16
+ }
17
+
18
+ const workingDirectory = resolve(runtime.cwd ?? process.cwd());
19
+ const docsDir = resolve(
20
+ workingDirectory,
21
+ options.docs ?? "docs/architecture",
22
+ );
23
+ const docsRelativeToWorkingDirectory = relative(workingDirectory, docsDir);
24
+ const repositoryRoot =
25
+ isAbsolute(docsRelativeToWorkingDirectory) ||
26
+ docsRelativeToWorkingDirectory === ".." ||
27
+ docsRelativeToWorkingDirectory.startsWith(`..${sep}`)
28
+ ? docsDir
29
+ : workingDirectory;
30
+ try {
31
+ const docs = await loadDocs(docsDir);
32
+ const diagnostics = options.links
33
+ ? await validateMarkdownLinks(
34
+ [docs.readme, ...docs.sections],
35
+ repositoryRoot,
36
+ docsDir,
37
+ )
38
+ : [];
39
+ const productCitations = options.citations
40
+ ? await verifyProductCitations({
41
+ repositoryRoot: workingDirectory,
42
+ docsDir,
43
+ productShapeCliPath: runtime.productShapeCliPath,
44
+ })
45
+ : undefined;
46
+ const result = {
47
+ valid:
48
+ diagnostics.length === 0 &&
49
+ productCitations?.status !== "invalid" &&
50
+ productCitations?.status !== "unavailable",
51
+ docs: docsDir,
52
+ sections: docs.sections.length,
53
+ title: docs.readme.title,
54
+ diagnostics,
55
+ ...(productCitations ? { productCitations } : {}),
56
+ };
57
+ printResult(result, options.format);
58
+ return { exitCode: result.valid ? 0 : 1, result };
59
+ } catch (error) {
60
+ const problems = error.problems ?? [error.message];
61
+ const result = {
62
+ valid: false,
63
+ docs: docsDir,
64
+ sections: 0,
65
+ diagnostics: problems,
66
+ };
67
+ printResult(result, options.format);
68
+ return { exitCode: 1, result };
69
+ }
70
+ }
71
+
72
+ function printResult(result, format) {
73
+ if (format === "json") {
74
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
75
+ return;
76
+ }
77
+ if (result.valid) {
78
+ process.stdout.write(
79
+ `Valid arc42 documentation: ${result.sections} sections (${result.docs})\n`,
80
+ );
81
+ } else {
82
+ process.stderr.write(`Invalid arc42 documentation (${result.docs}):\n`);
83
+ for (const diagnostic of result.diagnostics)
84
+ process.stderr.write(` - ${diagnostic}\n`);
85
+ }
86
+ if (result.productCitations?.status === "skipped")
87
+ process.stdout.write(
88
+ `ProductShape citations skipped: ${result.productCitations.reason}\n`,
89
+ );
90
+ else if (result.productCitations?.status === "verified")
91
+ process.stdout.write(
92
+ `ProductShape citations verified: ${result.productCitations.summary.total} citation(s)\n`,
93
+ );
94
+ else if (result.productCitations) {
95
+ process.stderr.write(
96
+ `ProductShape citation verification ${result.productCitations.status}: ${result.productCitations.reason ?? "see diagnostics"}\n`,
97
+ );
98
+ }
99
+ for (const diagnostic of result.productCitations?.diagnostics ?? []) {
100
+ const write =
101
+ diagnostic.severity === "warning" ? process.stdout : process.stderr;
102
+ write.write(
103
+ ` - ${diagnostic.severity ?? "error"} ${diagnostic.code ?? ""} ${diagnostic.file ?? ""}: ${diagnostic.message ?? JSON.stringify(diagnostic)}\n`,
104
+ );
105
+ }
106
+ }
107
+
108
+ function parseOptions(args) {
109
+ const options = { format: "text", help: false };
110
+ for (let index = 0; index < args.length; index += 1) {
111
+ const arg = args[index];
112
+ if (arg === "--help" || arg === "-h") options.help = true;
113
+ else if (arg === "--links") options.links = true;
114
+ else if (arg === "--citations") options.citations = true;
115
+ else if (arg === "--docs" || arg === "--format") {
116
+ const value = args[index + 1];
117
+ if (!value || value.startsWith("--"))
118
+ throw new Error(`${arg} requires a value`);
119
+ if (arg === "--docs") options.docs = value;
120
+ else if (value === "text" || value === "json") options.format = value;
121
+ else throw new Error("--format must be text or json");
122
+ index += 1;
123
+ } else throw new Error(`Unknown validate option: ${arg}`);
124
+ }
125
+ return options;
126
+ }