sdd-forge 0.1.0-alpha.622 → 0.1.0-alpha.692

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 (103) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/check/commands/config.js +140 -0
  4. package/src/check/commands/freshness.js +205 -0
  5. package/src/check/commands/scan.js +273 -0
  6. package/src/check.js +41 -0
  7. package/src/docs/commands/agents.js +2 -2
  8. package/src/docs/commands/enrich.js +2 -2
  9. package/src/docs/commands/forge.js +2 -2
  10. package/src/docs/commands/init.js +2 -2
  11. package/src/docs/commands/text.js +3 -3
  12. package/src/docs/commands/translate.js +2 -2
  13. package/src/docs/lib/command-context.js +2 -2
  14. package/src/docs/lib/template-merger.js +2 -2
  15. package/src/flow/commands/merge.js +5 -5
  16. package/src/flow/commands/report.js +18 -39
  17. package/src/flow/commands/review.js +42 -59
  18. package/src/flow/lib/get-context.js +2 -2
  19. package/src/flow/lib/get-issue.js +2 -2
  20. package/src/flow/lib/run-finalize.js +5 -7
  21. package/src/flow/lib/run-gate.js +38 -19
  22. package/src/flow/lib/run-prepare-spec.js +2 -2
  23. package/src/flow/lib/run-retro.js +3 -2
  24. package/src/flow/lib/run-review.js +3 -4
  25. package/src/flow/lib/run-sync.js +2 -2
  26. package/src/flow/registry.js +4 -6
  27. package/src/lib/NOTICE +15 -0
  28. package/src/lib/agent.js +437 -71
  29. package/src/lib/cli.js +7 -7
  30. package/src/lib/config.js +3 -1
  31. package/src/lib/flow-state.js +12 -0
  32. package/src/lib/formatter.js +21 -0
  33. package/src/lib/git-helpers.js +2 -2
  34. package/src/lib/guardrail.js +14 -21
  35. package/src/lib/lint.js +2 -2
  36. package/src/lib/log.js +418 -0
  37. package/src/lib/process.js +40 -4
  38. package/src/lib/skills.js +49 -10
  39. package/src/lib/types.js +50 -0
  40. package/src/presets/base/{templates/en/guardrail.json → guardrail.json} +96 -0
  41. package/src/presets/nextjs/NOTICE +19 -0
  42. package/src/presets/nextjs/guardrail.json +128 -0
  43. package/src/sdd-forge.js +20 -2
  44. package/src/setup.js +5 -37
  45. package/src/templates/config.example.json +1 -0
  46. package/src/templates/skills/sdd-forge.flow-impl/SKILL.md +41 -13
  47. package/src/templates/skills/sdd-forge.flow-plan/SKILL.md +13 -43
  48. package/src/upgrade.js +16 -1
  49. package/src/presets/api/templates/ja/guardrail.json +0 -25
  50. package/src/presets/architecture/templates/ja/guardrail.json +0 -15
  51. package/src/presets/base/templates/ja/guardrail.json +0 -145
  52. package/src/presets/cakephp2/templates/ja/guardrail.json +0 -46
  53. package/src/presets/ci/templates/ja/guardrail.json +0 -25
  54. package/src/presets/cli/templates/ja/guardrail.json +0 -26
  55. package/src/presets/coding-rule/templates/ja/guardrail.json +0 -51
  56. package/src/presets/database/templates/ja/guardrail.json +0 -58
  57. package/src/presets/document/templates/ja/guardrail.json +0 -24
  58. package/src/presets/edge/templates/ja/guardrail.json +0 -35
  59. package/src/presets/github-actions/templates/ja/guardrail.json +0 -34
  60. package/src/presets/graphql/templates/ja/guardrail.json +0 -36
  61. package/src/presets/greenfield/templates/ja/guardrail.json +0 -65
  62. package/src/presets/infrastructure/templates/ja/guardrail.json +0 -15
  63. package/src/presets/js-webapp/templates/ja/guardrail.json +0 -14
  64. package/src/presets/laravel/templates/ja/guardrail.json +0 -46
  65. package/src/presets/library/templates/ja/guardrail.json +0 -36
  66. package/src/presets/maintenance/templates/ja/guardrail.json +0 -45
  67. package/src/presets/monorepo/templates/ja/guardrail.json +0 -26
  68. package/src/presets/nextjs/templates/en/guardrail.json +0 -36
  69. package/src/presets/nextjs/templates/ja/guardrail.json +0 -36
  70. package/src/presets/node-cli/templates/ja/guardrail.json +0 -24
  71. package/src/presets/oss-contribute/templates/ja/guardrail.json +0 -35
  72. package/src/presets/rest/templates/ja/guardrail.json +0 -15
  73. package/src/presets/storage/templates/ja/guardrail.json +0 -26
  74. package/src/presets/symfony/templates/ja/guardrail.json +0 -308
  75. package/src/presets/web-design/templates/ja/guardrail.json +0 -75
  76. package/src/presets/webapp/templates/ja/guardrail.json +0 -90
  77. package/src/presets/workers/templates/ja/guardrail.json +0 -15
  78. /package/src/presets/api/{templates/en/guardrail.json → guardrail.json} +0 -0
  79. /package/src/presets/architecture/{templates/en/guardrail.json → guardrail.json} +0 -0
  80. /package/src/presets/cakephp2/{templates/en/guardrail.json → guardrail.json} +0 -0
  81. /package/src/presets/ci/{templates/en/guardrail.json → guardrail.json} +0 -0
  82. /package/src/presets/cli/{templates/en/guardrail.json → guardrail.json} +0 -0
  83. /package/src/presets/coding-rule/{templates/en/guardrail.json → guardrail.json} +0 -0
  84. /package/src/presets/database/{templates/en/guardrail.json → guardrail.json} +0 -0
  85. /package/src/presets/document/{templates/en/guardrail.json → guardrail.json} +0 -0
  86. /package/src/presets/edge/{templates/en/guardrail.json → guardrail.json} +0 -0
  87. /package/src/presets/github-actions/{templates/en/guardrail.json → guardrail.json} +0 -0
  88. /package/src/presets/graphql/{templates/en/guardrail.json → guardrail.json} +0 -0
  89. /package/src/presets/greenfield/{templates/en/guardrail.json → guardrail.json} +0 -0
  90. /package/src/presets/infrastructure/{templates/en/guardrail.json → guardrail.json} +0 -0
  91. /package/src/presets/js-webapp/{templates/en/guardrail.json → guardrail.json} +0 -0
  92. /package/src/presets/laravel/{templates/en/guardrail.json → guardrail.json} +0 -0
  93. /package/src/presets/library/{templates/en/guardrail.json → guardrail.json} +0 -0
  94. /package/src/presets/maintenance/{templates/en/guardrail.json → guardrail.json} +0 -0
  95. /package/src/presets/monorepo/{templates/en/guardrail.json → guardrail.json} +0 -0
  96. /package/src/presets/node-cli/{templates/en/guardrail.json → guardrail.json} +0 -0
  97. /package/src/presets/oss-contribute/{templates/en/guardrail.json → guardrail.json} +0 -0
  98. /package/src/presets/rest/{templates/en/guardrail.json → guardrail.json} +0 -0
  99. /package/src/presets/storage/{templates/en/guardrail.json → guardrail.json} +0 -0
  100. /package/src/presets/symfony/{templates/en/guardrail.json → guardrail.json} +0 -0
  101. /package/src/presets/web-design/{templates/en/guardrail.json → guardrail.json} +0 -0
  102. /package/src/presets/webapp/{templates/en/guardrail.json → guardrail.json} +0 -0
  103. /package/src/presets/workers/{templates/en/guardrail.json → guardrail.json} +0 -0
package/README.md CHANGED
@@ -129,12 +129,12 @@ See the [configuration reference](docs/configuration.md) for details.
129
129
  <!-- {{data("cli.docs.chapters", {header: "", labels: "Chapter|Summary", ignoreError: true})}} -->
130
130
  | Chapter | Summary |
131
131
  | --- | --- |
132
- | [Tool Overview and Architecture](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/overview.md) | This chapter introduces sdd-forge — a CLI tool for automated documentation generation and Spec-Driven Development (SD… |
133
- | [Technology Stack and Operations](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/stack_and_ops.md) | sdd-forge is implemented in JavaScript using Node.js (>=18.0.0) with the ES Modules system, and is distributed as a z… |
134
- | [Project Structure](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/project_structure.md) | This chapter describes the source layout of sdd-forge across three major directory groups: src/docs (documentation ge… |
135
- | [CLI Command Reference](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/cli_commands.md) | The CLI provides over 30 commands organized in a three-level dispatch hierarchy: the top-level sdd-forge entry point … |
136
- | [Configuration and Customization](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/configuration.md) | sdd-forge reads a single JSON configuration file placed inside your project's .sdd-forge/ directory and exposes a bro… |
137
- | [Internal Design](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/internal_design.md) | This chapter describes the internal architecture of sdd-forge, a layered CLI tool in which top-level dispatchers rout… |
132
+ | [Tool Overview and Architecture](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/overview.md) | This chapter provides a concise introduction to sdd-forge — a CLI tool that generates structured technical documentat… |
133
+ | [Technology Stack and Operations](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/stack_and_ops.md) | This chapter covers the technology stack and operational procedures for sdd-forge, a Node.js CLI tool built with ES M… |
134
+ | [Project Structure](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/project_structure.md) | This chapter describes the source tree of sdd-forge, which is organized into five major directories: src/docs (docume… |
135
+ | [CLI Command Reference](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/cli_commands.md) | sdd-forge exposes over 35 commands organized across three namespace groups docs, flow, and check — plus four standa… |
136
+ | [Configuration and Customization](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/configuration.md) | sdd-forge reads a single JSON configuration file per project, through which users can control documentation output la… |
137
+ | [Internal Design](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/internal_design.md) | sdd-forge is a Node.js CLI tool organized into three primary source layers src/docs/ for documentation pipeline com… |
138
138
  <!-- {{/data}} -->
139
139
 
140
140
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdd-forge",
3
- "version": "0.1.0-alpha.622",
3
+ "version": "0.1.0-alpha.692",
4
4
  "description": "Spec-Driven Development tooling for automated documentation generation",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * src/check/commands/config.js
4
+ *
5
+ * sdd-forge check config — config.json validation report.
6
+ *
7
+ * Runs three checks in order:
8
+ * 1. File existence and JSON parse
9
+ * 2. Schema validation (required fields, type constraints)
10
+ * 3. Preset existence (type values must match known presets)
11
+ */
12
+
13
+ import fs from "fs";
14
+ import { runIfDirect } from "../../lib/entrypoint.js";
15
+ import { repoRoot, parseArgs } from "../../lib/cli.js";
16
+ import { sddConfigPath } from "../../lib/config.js";
17
+ import { validateConfig } from "../../lib/types.js";
18
+ import { PRESETS } from "../../lib/presets.js";
19
+ import { EXIT_ERROR } from "../../lib/exit-codes.js";
20
+
21
+ const MAX_SCHEMA_ERRORS = 50;
22
+
23
+ function printHelp() {
24
+ console.log(
25
+ [
26
+ "Usage: sdd-forge check config [options]",
27
+ "",
28
+ "Validate .sdd-forge/config.json for required fields, preset existence,",
29
+ "and schema consistency.",
30
+ "",
31
+ "Options:",
32
+ " --format <text|json> Output format (default: text)",
33
+ " -h, --help Show this help",
34
+ ].join("\n")
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Run all config checks and return check results.
40
+ * Stops early if file or schema check fails.
41
+ *
42
+ * @param {string} root - repo root
43
+ * @returns {{ name: string, result: "pass"|"fail", errors: string[] }[]}
44
+ */
45
+ function runChecks(root) {
46
+ const configPath = sddConfigPath(root);
47
+ const checks = [];
48
+
49
+ // Check 1: file existence + JSON parse
50
+ if (!fs.existsSync(configPath)) {
51
+ checks.push({ name: "file", result: "fail", errors: [`config.json not found: ${configPath}`] });
52
+ return checks;
53
+ }
54
+
55
+ let raw;
56
+ try {
57
+ raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
58
+ } catch (err) {
59
+ checks.push({ name: "file", result: "fail", errors: [`Failed to parse config.json: ${err.message}`] });
60
+ return checks;
61
+ }
62
+ checks.push({ name: "file", result: "pass", errors: [] });
63
+
64
+ // Check 2: schema validation
65
+ try {
66
+ validateConfig(raw);
67
+ checks.push({ name: "schema", result: "pass", errors: [] });
68
+ } catch (err) {
69
+ const errors = err.message
70
+ .replace(/^Config validation failed:\n/, "")
71
+ .split(/\n\s*-\s*/)
72
+ .map((e) => e.trim())
73
+ .filter(Boolean)
74
+ .slice(0, MAX_SCHEMA_ERRORS);
75
+ checks.push({ name: "schema", result: "fail", errors });
76
+ return checks;
77
+ }
78
+
79
+ // Check 3: preset existence
80
+ const types = Array.isArray(raw.type) ? raw.type : [raw.type];
81
+ const validKeys = new Set(PRESETS.map((p) => p.key));
82
+ const unknownPresets = types.filter((t) => !validKeys.has(t));
83
+
84
+ if (unknownPresets.length > 0) {
85
+ checks.push({
86
+ name: "presets",
87
+ result: "fail",
88
+ errors: unknownPresets.map((t) => `Preset not found: ${t}`),
89
+ });
90
+ } else {
91
+ checks.push({ name: "presets", result: "pass", errors: [] });
92
+ }
93
+
94
+ return checks;
95
+ }
96
+
97
+ async function main() {
98
+ const cli = parseArgs(process.argv.slice(2), {
99
+ options: ["--format"],
100
+ defaults: { format: "text" },
101
+ });
102
+
103
+ if (cli.help) {
104
+ printHelp();
105
+ return;
106
+ }
107
+
108
+ const format = cli.format || "text";
109
+ if (!["text", "json"].includes(format)) {
110
+ process.stderr.write(`sdd-forge check config: unknown format '${format}'. Use text or json.\n`);
111
+ process.exit(EXIT_ERROR);
112
+ }
113
+
114
+ const root = repoRoot();
115
+ const checks = runChecks(root);
116
+ const ok = checks.every((c) => c.result === "pass");
117
+
118
+ if (format === "json") {
119
+ process.stdout.write(JSON.stringify({ ok, checks }, null, 2) + "\n");
120
+ if (!ok) process.exit(EXIT_ERROR);
121
+ return;
122
+ }
123
+
124
+ // text format
125
+ if (ok) {
126
+ process.stdout.write("config is valid\n");
127
+ } else {
128
+ for (const check of checks) {
129
+ if (check.result === "fail") {
130
+ for (const err of check.errors) {
131
+ process.stderr.write(` - ${err}\n`);
132
+ }
133
+ }
134
+ }
135
+ process.exit(EXIT_ERROR);
136
+ }
137
+ }
138
+
139
+ export { main };
140
+ runIfDirect(import.meta.url, main);
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * src/check/commands/freshness.js
4
+ *
5
+ * sdd-forge check freshness — compare docs/ and source modification timestamps.
6
+ *
7
+ * Determines whether `sdd-forge build` is needed by comparing the newest mtime
8
+ * of files under SDD_SOURCE_ROOT with the newest mtime of files under docs/.
9
+ *
10
+ * Results:
11
+ * fresh — docs/ is up to date (exit 0)
12
+ * stale — source is newer than docs/ (exit 1)
13
+ * never-built — docs/ does not exist (exit 1)
14
+ */
15
+
16
+ import fs from "fs";
17
+ import path from "path";
18
+ import { runIfDirect } from "../../lib/entrypoint.js";
19
+ import { repoRoot, sourceRoot, parseArgs } from "../../lib/cli.js";
20
+ import { EXIT_ERROR } from "../../lib/exit-codes.js";
21
+
22
+ const FILE_LIMIT = 10_000;
23
+
24
+ function printHelp() {
25
+ console.log(
26
+ [
27
+ "Usage: sdd-forge check freshness [options]",
28
+ "",
29
+ "Compare docs/ and source modification timestamps to determine if",
30
+ "sdd-forge build is needed.",
31
+ "",
32
+ "Results:",
33
+ " fresh docs/ is up to date",
34
+ " stale source is newer than docs/ — run sdd-forge build",
35
+ " never-built docs/ does not exist — run sdd-forge build",
36
+ "",
37
+ "Exit codes:",
38
+ " 0 fresh",
39
+ " 1 stale or never-built",
40
+ "",
41
+ "Options:",
42
+ " --format <text|json> Output format (default: text)",
43
+ " -h, --help Show this help",
44
+ ].join("\n")
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Recursively walk a directory and collect file paths, up to `limit` entries.
50
+ *
51
+ * @param {string} dir
52
+ * @param {number} limit
53
+ * @returns {Promise<{ files: string[], truncated: boolean }>}
54
+ */
55
+ async function walkFiles(dir, limit) {
56
+ const files = [];
57
+ let truncated = false;
58
+
59
+ async function walk(current) {
60
+ if (truncated) return;
61
+ let entries;
62
+ try {
63
+ entries = await fs.promises.readdir(current, { withFileTypes: true });
64
+ } catch {
65
+ return;
66
+ }
67
+ for (const entry of entries) {
68
+ if (truncated) return;
69
+ const full = path.join(current, entry.name);
70
+ if (entry.isDirectory()) {
71
+ await walk(full);
72
+ } else if (entry.isFile()) {
73
+ files.push(full);
74
+ if (files.length >= limit) {
75
+ truncated = true;
76
+ return;
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ await walk(dir);
83
+ return { files, truncated };
84
+ }
85
+
86
+ /**
87
+ * Find the newest mtime (ms) among files in a directory.
88
+ *
89
+ * @param {string} dir
90
+ * @param {number} limit
91
+ * @returns {Promise<{ newestMs: number|null, truncated: boolean }>}
92
+ */
93
+ async function newestMtime(dir, limit) {
94
+ const { files, truncated } = await walkFiles(dir, limit);
95
+ let newestMs = null;
96
+ for (const f of files) {
97
+ try {
98
+ const ms = (await fs.promises.stat(f)).mtimeMs;
99
+ if (newestMs === null || ms > newestMs) newestMs = ms;
100
+ } catch {
101
+ // skip unreadable files
102
+ }
103
+ }
104
+ return { newestMs, truncated };
105
+ }
106
+
107
+ /**
108
+ * Run the freshness check.
109
+ *
110
+ * @param {string} workRoot - repo root (docs/ lives here)
111
+ * @param {string} srcRoot - source root
112
+ * @returns {Promise<{ result: "fresh"|"stale"|"never-built", srcNewest: string|null, docsNewest: string|null }>}
113
+ */
114
+ async function checkFreshness(workRoot, srcRoot) {
115
+ const docsDir = path.join(workRoot, "docs");
116
+
117
+ try {
118
+ await fs.promises.access(docsDir);
119
+ } catch {
120
+ return { result: "never-built", srcNewest: null, docsNewest: null };
121
+ }
122
+
123
+ const [srcResult, docsResult] = await Promise.all([
124
+ newestMtime(srcRoot, FILE_LIMIT),
125
+ newestMtime(docsDir, FILE_LIMIT),
126
+ ]);
127
+
128
+ const { newestMs: srcMs, truncated: srcTruncated } = srcResult;
129
+ const { newestMs: docsMs, truncated: docsTruncated } = docsResult;
130
+
131
+ if (srcTruncated) {
132
+ process.stderr.write(
133
+ `sdd-forge check freshness: warning — source file limit (${FILE_LIMIT}) reached, result may be approximate\n`
134
+ );
135
+ }
136
+ if (docsTruncated) {
137
+ process.stderr.write(
138
+ `sdd-forge check freshness: warning — docs file limit (${FILE_LIMIT}) reached, result may be approximate\n`
139
+ );
140
+ }
141
+
142
+ const srcNewest = srcMs !== null ? new Date(srcMs).toISOString() : null;
143
+ const docsNewest = docsMs !== null ? new Date(docsMs).toISOString() : null;
144
+
145
+ // If source has no files, treat as fresh (nothing to build from)
146
+ if (srcMs === null) {
147
+ return { result: "fresh", srcNewest, docsNewest };
148
+ }
149
+
150
+ // If docs has no files but dir exists, treat as stale
151
+ if (docsMs === null) {
152
+ return { result: "stale", srcNewest, docsNewest };
153
+ }
154
+
155
+ const result = srcMs > docsMs ? "stale" : "fresh";
156
+ return { result, srcNewest, docsNewest };
157
+ }
158
+
159
+ async function main() {
160
+ const cli = parseArgs(process.argv.slice(2), {
161
+ options: ["--format"],
162
+ defaults: { format: "text" },
163
+ });
164
+
165
+ if (cli.help) {
166
+ printHelp();
167
+ return;
168
+ }
169
+
170
+ const format = cli.format;
171
+ if (!["text", "json"].includes(format)) {
172
+ process.stderr.write(`sdd-forge check freshness: unknown format '${format}'. Use text or json.\n`);
173
+ process.exit(EXIT_ERROR);
174
+ }
175
+
176
+ const workRoot = repoRoot();
177
+ const srcRoot = sourceRoot();
178
+ const { result, srcNewest, docsNewest } = await checkFreshness(workRoot, srcRoot);
179
+
180
+ const ok = result === "fresh";
181
+
182
+ if (format === "json") {
183
+ process.stdout.write(JSON.stringify({ ok, result, srcNewest, docsNewest }, null, 2) + "\n");
184
+ if (!ok) process.exit(EXIT_ERROR);
185
+ return;
186
+ }
187
+
188
+ // text format
189
+ switch (result) {
190
+ case "fresh":
191
+ process.stdout.write("fresh — docs/ is up to date\n");
192
+ break;
193
+ case "stale":
194
+ process.stdout.write("stale — source is newer than docs/, run: sdd-forge build\n");
195
+ process.exit(EXIT_ERROR);
196
+ break;
197
+ case "never-built":
198
+ process.stdout.write("never-built — docs/ does not exist, run: sdd-forge build\n");
199
+ process.exit(EXIT_ERROR);
200
+ break;
201
+ }
202
+ }
203
+
204
+ export { main, checkFreshness };
205
+ runIfDirect(import.meta.url, main);
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * src/check/commands/scan.js
4
+ *
5
+ * sdd-forge check scan — scan coverage report.
6
+ *
7
+ * Shows DataSource coverage: scan.include matched files vs DataSource-analyzed files.
8
+ * Reports uncovered files grouped by extension (actionable summary) followed by the file list.
9
+ */
10
+
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import { runIfDirect } from "../../lib/entrypoint.js";
14
+ import { repoRoot, sourceRoot, parseArgs } from "../../lib/cli.js";
15
+ import { loadConfig, sddOutputDir } from "../../lib/config.js";
16
+ import { globToRegex } from "../../docs/lib/scanner.js";
17
+ import { pushSection, DIVIDER } from "../../lib/formatter.js";
18
+ import { EXIT_ERROR } from "../../lib/exit-codes.js";
19
+
20
+ const DEFAULT_MAX_FILES = 10;
21
+
22
+ // Meta keys to skip when reading analysis categories
23
+ const META_KEYS = new Set(["analyzedAt", "enrichedAt"]);
24
+
25
+ function printHelp() {
26
+ console.log([
27
+ "Usage: sdd-forge check scan [options]",
28
+ "",
29
+ "Show scan coverage report for the current project.",
30
+ "",
31
+ "Options:",
32
+ " --format <text|json|md> Output format (default: text)",
33
+ " --list Show all uncovered files (default: up to 10)",
34
+ " -h, --help Show this help",
35
+ ].join("\n"));
36
+ }
37
+
38
+ /**
39
+ * Group files by extension, sorted by count descending then extension alphabetically.
40
+ *
41
+ * @param {string[]} files - relative file paths
42
+ * @returns {{ ext: string, count: number }[]}
43
+ */
44
+ function groupByExtension(files) {
45
+ const counts = new Map();
46
+ for (const f of files) {
47
+ const ext = path.extname(f);
48
+ counts.set(ext, (counts.get(ext) ?? 0) + 1);
49
+ }
50
+ return [...counts.entries()]
51
+ .sort(([extA, countA], [extB, countB]) => countB - countA || extA.localeCompare(extB))
52
+ .map(([ext, count]) => ({ ext, count }));
53
+ }
54
+
55
+ /**
56
+ * Walk baseDir recursively, collecting files matched by includeMatchers.
57
+ * Skips .git, node_modules, vendor, .sdd-forge directories.
58
+ * Applies excludeMatchers to relative paths.
59
+ *
60
+ * @param {string} baseDir
61
+ * @param {RegExp[]} includeMatchers
62
+ * @param {RegExp[]} excludeMatchers
63
+ * @returns {string[]} relative paths
64
+ */
65
+ function walkIncludedFiles(baseDir, includeMatchers, excludeMatchers) {
66
+ const results = [];
67
+
68
+ function walk(dir, relPrefix) {
69
+ let entries;
70
+ try {
71
+ entries = fs.readdirSync(dir, { withFileTypes: true });
72
+ } catch (_) {
73
+ return;
74
+ }
75
+ for (const entry of entries) {
76
+ if (entry.isDirectory()) {
77
+ if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "vendor" || entry.name === ".sdd-forge") continue;
78
+ const nextRel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
79
+ walk(path.join(dir, entry.name), nextRel);
80
+ } else if (entry.isFile()) {
81
+ const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
82
+ if (excludeMatchers.some((m) => m.test(relPath))) continue;
83
+ if (includeMatchers.some((m) => m.test(relPath))) results.push(relPath);
84
+ }
85
+ }
86
+ }
87
+
88
+ walk(baseDir, "");
89
+ return results.sort();
90
+ }
91
+
92
+ /**
93
+ * Compute DataSource coverage from config and analysis.json.
94
+ *
95
+ * @param {string} root - work root
96
+ * @param {string} src - source root
97
+ * @param {Object} cfg - sdd config
98
+ * @returns {{ dataSourceCoverage: { total, analyzed, uncovered } }}
99
+ */
100
+ function computeCoverage(root, src, cfg) {
101
+ const outputPath = path.join(sddOutputDir(root), "analysis.json");
102
+ if (!fs.existsSync(outputPath)) {
103
+ throw new Error(`analysis.json not found: ${outputPath}\nRun 'sdd-forge docs scan' first.`);
104
+ }
105
+
106
+ let analysis;
107
+ try {
108
+ analysis = JSON.parse(fs.readFileSync(outputPath, "utf8"));
109
+ } catch (err) {
110
+ throw new Error(`Failed to parse analysis.json: ${err.message}`);
111
+ }
112
+
113
+ // Resolve scan patterns
114
+ const include = cfg.scan?.include || [];
115
+ const exclude = cfg.scan?.exclude || [];
116
+ const excludeMatchers = exclude.map((p) => globToRegex(p));
117
+ const includeMatchers = include.map((p) => globToRegex(p));
118
+
119
+ // scan.include matched files
120
+ const includedFiles = walkIncludedFiles(src, includeMatchers, excludeMatchers);
121
+
122
+ // Files analyzed by any DataSource (from analysis.json entries)
123
+ const analyzedFiles = new Set();
124
+ for (const key of Object.keys(analysis)) {
125
+ if (META_KEYS.has(key)) continue;
126
+ const cat = analysis[key];
127
+ if (!cat || !Array.isArray(cat.entries)) continue;
128
+ for (const entry of cat.entries) {
129
+ if (entry?.file) analyzedFiles.add(entry.file);
130
+ }
131
+ }
132
+
133
+ const uncovered = includedFiles.filter((f) => !analyzedFiles.has(f));
134
+
135
+ return {
136
+ dataSourceCoverage: {
137
+ total: includedFiles.length,
138
+ analyzed: analyzedFiles.size,
139
+ uncovered,
140
+ },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Format as plain text.
146
+ */
147
+ function formatText(data, showAll) {
148
+ const { dataSourceCoverage: ds } = data;
149
+ const lines = [];
150
+ const dsPct = ds.total === 0 ? 0 : Math.round((ds.analyzed / ds.total) * 100);
151
+
152
+ lines.push(` DataSource: ${ds.analyzed} / ${ds.total} files (${dsPct}%)`);
153
+
154
+ if (ds.uncovered.length > 0) {
155
+ const extGroups = groupByExtension(ds.uncovered);
156
+
157
+ pushSection(lines, "Uncovered by extension");
158
+ for (const { ext, count } of extGroups) {
159
+ const label = ext || "(no extension)";
160
+ lines.push(` ${label.padEnd(12)} ${count} files`);
161
+ }
162
+
163
+ pushSection(lines, "Uncovered files");
164
+ const display = showAll ? ds.uncovered : ds.uncovered.slice(0, DEFAULT_MAX_FILES);
165
+ for (const f of display) lines.push(` - ${f}`);
166
+ if (!showAll && ds.uncovered.length > DEFAULT_MAX_FILES) {
167
+ lines.push(` ... and ${ds.uncovered.length - DEFAULT_MAX_FILES} more (use --list to show all)`);
168
+ }
169
+ }
170
+
171
+ return lines.join("\n");
172
+ }
173
+
174
+ /**
175
+ * Format as Markdown.
176
+ */
177
+ function formatMarkdown(data, showAll) {
178
+ const { dataSourceCoverage: ds } = data;
179
+ const dsPct = ds.total === 0 ? 0 : Math.round((ds.analyzed / ds.total) * 100);
180
+
181
+ const lines = [];
182
+ lines.push("# Scan Coverage Report");
183
+ lines.push("");
184
+ lines.push("## DataSource Coverage");
185
+ lines.push("");
186
+ lines.push(`**${ds.analyzed} / ${ds.total} files (${dsPct}%)**`);
187
+
188
+ if (ds.uncovered.length > 0) {
189
+ const extGroups = groupByExtension(ds.uncovered);
190
+
191
+ lines.push("");
192
+ lines.push("### Uncovered by extension");
193
+ lines.push("");
194
+ for (const { ext, count } of extGroups) {
195
+ const label = ext || "(no extension)";
196
+ lines.push(`- \`${label}\` ${count} files`);
197
+ }
198
+
199
+ lines.push("");
200
+ lines.push("### Uncovered files");
201
+ lines.push("");
202
+ const display = showAll ? ds.uncovered : ds.uncovered.slice(0, DEFAULT_MAX_FILES);
203
+ for (const f of display) lines.push(`- \`${f}\``);
204
+ if (!showAll && ds.uncovered.length > DEFAULT_MAX_FILES) {
205
+ lines.push(`- _...and ${ds.uncovered.length - DEFAULT_MAX_FILES} more (use --list to show all)_`);
206
+ }
207
+ }
208
+
209
+ return lines.join("\n");
210
+ }
211
+
212
+ async function main() {
213
+ const cli = parseArgs(process.argv.slice(2), {
214
+ flags: ["--list"],
215
+ options: ["--format"],
216
+ defaults: { list: false, format: "text" },
217
+ });
218
+
219
+ if (cli.help) {
220
+ printHelp();
221
+ return;
222
+ }
223
+
224
+ const format = cli.format || "text";
225
+ if (!["text", "json", "md"].includes(format)) {
226
+ process.stderr.write(`sdd-forge check scan: unknown format '${format}'. Use text, json, or md.\n`);
227
+ process.exit(EXIT_ERROR);
228
+ }
229
+
230
+ const root = repoRoot();
231
+ const src = sourceRoot();
232
+
233
+ let cfg;
234
+ try {
235
+ cfg = loadConfig(root);
236
+ } catch (err) {
237
+ process.stderr.write(`sdd-forge check scan: failed to load config: ${err.message}\n`);
238
+ process.exit(EXIT_ERROR);
239
+ }
240
+
241
+ let data;
242
+ try {
243
+ data = computeCoverage(root, src, cfg);
244
+ } catch (err) {
245
+ process.stderr.write(`sdd-forge check scan: ${err.message}\n`);
246
+ process.exit(EXIT_ERROR);
247
+ }
248
+
249
+ const showAll = cli.list;
250
+
251
+ if (format === "json") {
252
+ const { dataSourceCoverage: ds } = data;
253
+ const dsPct = ds.total === 0 ? 0 : Math.round((ds.analyzed / ds.total) * 100);
254
+ const out = {
255
+ dataSourceCoverage: {
256
+ total: ds.total,
257
+ analyzed: ds.analyzed,
258
+ percent: dsPct,
259
+ uncovered: showAll ? ds.uncovered : ds.uncovered.slice(0, DEFAULT_MAX_FILES),
260
+ uncoveredTotal: ds.uncovered.length,
261
+ uncoveredByExtension: groupByExtension(ds.uncovered),
262
+ },
263
+ };
264
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
265
+ } else if (format === "md") {
266
+ process.stdout.write(formatMarkdown(data, showAll) + "\n");
267
+ } else {
268
+ process.stdout.write(formatText(data, showAll) + "\n");
269
+ }
270
+ }
271
+
272
+ export { main, groupByExtension, computeCoverage, formatText };
273
+ runIfDirect(import.meta.url, main);
package/src/check.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * src/check.js
4
+ *
5
+ * Check dispatcher. Routes check subcommands to scripts under check/commands/.
6
+ */
7
+
8
+ import path from "path";
9
+ import { PKG_DIR } from "./lib/cli.js";
10
+ import { EXIT_ERROR } from "./lib/exit-codes.js";
11
+
12
+ /** Subcommand → script mapping */
13
+ const SCRIPTS = {
14
+ config: "check/commands/config.js",
15
+ freshness: "check/commands/freshness.js",
16
+ scan: "check/commands/scan.js",
17
+ };
18
+
19
+ const args = process.argv.slice(2);
20
+ const subCmd = args[0];
21
+ const rest = args.slice(1);
22
+
23
+ if (!subCmd || subCmd === "-h" || subCmd === "--help") {
24
+ console.error("Usage: sdd-forge check <command>\n");
25
+ console.error("Available commands:");
26
+ for (const c of Object.keys(SCRIPTS)) console.error(` ${c}`);
27
+ console.error("\nRun: sdd-forge check <command> --help");
28
+ process.exit(subCmd ? 0 : 1);
29
+ }
30
+
31
+ const scriptRelPath = SCRIPTS[subCmd];
32
+ if (!scriptRelPath) {
33
+ console.error(`sdd-forge check: unknown command '${subCmd}'`);
34
+ console.error("Run: sdd-forge check --help");
35
+ process.exit(EXIT_ERROR);
36
+ }
37
+
38
+ const scriptPath = path.join(PKG_DIR, scriptRelPath);
39
+ process.argv = [process.argv[0], scriptPath, ...rest];
40
+
41
+ await import(scriptPath);