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.
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/check/commands/config.js +140 -0
- package/src/check/commands/freshness.js +205 -0
- package/src/check/commands/scan.js +273 -0
- package/src/check.js +41 -0
- package/src/docs/commands/agents.js +2 -2
- package/src/docs/commands/enrich.js +2 -2
- package/src/docs/commands/forge.js +2 -2
- package/src/docs/commands/init.js +2 -2
- package/src/docs/commands/text.js +3 -3
- package/src/docs/commands/translate.js +2 -2
- package/src/docs/lib/command-context.js +2 -2
- package/src/docs/lib/template-merger.js +2 -2
- package/src/flow/commands/merge.js +5 -5
- package/src/flow/commands/report.js +18 -39
- package/src/flow/commands/review.js +42 -59
- package/src/flow/lib/get-context.js +2 -2
- package/src/flow/lib/get-issue.js +2 -2
- package/src/flow/lib/run-finalize.js +5 -7
- package/src/flow/lib/run-gate.js +38 -19
- package/src/flow/lib/run-prepare-spec.js +2 -2
- package/src/flow/lib/run-retro.js +3 -2
- package/src/flow/lib/run-review.js +3 -4
- package/src/flow/lib/run-sync.js +2 -2
- package/src/flow/registry.js +4 -6
- package/src/lib/NOTICE +15 -0
- package/src/lib/agent.js +437 -71
- package/src/lib/cli.js +7 -7
- package/src/lib/config.js +3 -1
- package/src/lib/flow-state.js +12 -0
- package/src/lib/formatter.js +21 -0
- package/src/lib/git-helpers.js +2 -2
- package/src/lib/guardrail.js +14 -21
- package/src/lib/lint.js +2 -2
- package/src/lib/log.js +418 -0
- package/src/lib/process.js +40 -4
- package/src/lib/skills.js +49 -10
- package/src/lib/types.js +50 -0
- package/src/presets/base/{templates/en/guardrail.json → guardrail.json} +96 -0
- package/src/presets/nextjs/NOTICE +19 -0
- package/src/presets/nextjs/guardrail.json +128 -0
- package/src/sdd-forge.js +20 -2
- package/src/setup.js +5 -37
- package/src/templates/config.example.json +1 -0
- package/src/templates/skills/sdd-forge.flow-impl/SKILL.md +41 -13
- package/src/templates/skills/sdd-forge.flow-plan/SKILL.md +13 -43
- package/src/upgrade.js +16 -1
- package/src/presets/api/templates/ja/guardrail.json +0 -25
- package/src/presets/architecture/templates/ja/guardrail.json +0 -15
- package/src/presets/base/templates/ja/guardrail.json +0 -145
- package/src/presets/cakephp2/templates/ja/guardrail.json +0 -46
- package/src/presets/ci/templates/ja/guardrail.json +0 -25
- package/src/presets/cli/templates/ja/guardrail.json +0 -26
- package/src/presets/coding-rule/templates/ja/guardrail.json +0 -51
- package/src/presets/database/templates/ja/guardrail.json +0 -58
- package/src/presets/document/templates/ja/guardrail.json +0 -24
- package/src/presets/edge/templates/ja/guardrail.json +0 -35
- package/src/presets/github-actions/templates/ja/guardrail.json +0 -34
- package/src/presets/graphql/templates/ja/guardrail.json +0 -36
- package/src/presets/greenfield/templates/ja/guardrail.json +0 -65
- package/src/presets/infrastructure/templates/ja/guardrail.json +0 -15
- package/src/presets/js-webapp/templates/ja/guardrail.json +0 -14
- package/src/presets/laravel/templates/ja/guardrail.json +0 -46
- package/src/presets/library/templates/ja/guardrail.json +0 -36
- package/src/presets/maintenance/templates/ja/guardrail.json +0 -45
- package/src/presets/monorepo/templates/ja/guardrail.json +0 -26
- package/src/presets/nextjs/templates/en/guardrail.json +0 -36
- package/src/presets/nextjs/templates/ja/guardrail.json +0 -36
- package/src/presets/node-cli/templates/ja/guardrail.json +0 -24
- package/src/presets/oss-contribute/templates/ja/guardrail.json +0 -35
- package/src/presets/rest/templates/ja/guardrail.json +0 -15
- package/src/presets/storage/templates/ja/guardrail.json +0 -26
- package/src/presets/symfony/templates/ja/guardrail.json +0 -308
- package/src/presets/web-design/templates/ja/guardrail.json +0 -75
- package/src/presets/webapp/templates/ja/guardrail.json +0 -90
- package/src/presets/workers/templates/ja/guardrail.json +0 -15
- /package/src/presets/api/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/architecture/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/cakephp2/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/ci/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/cli/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/coding-rule/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/database/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/document/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/edge/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/github-actions/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/graphql/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/greenfield/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/infrastructure/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/js-webapp/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/laravel/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/library/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/maintenance/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/monorepo/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/node-cli/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/oss-contribute/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/rest/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/storage/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/symfony/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/web-design/{templates/en/guardrail.json → guardrail.json} +0 -0
- /package/src/presets/webapp/{templates/en/guardrail.json → guardrail.json} +0 -0
- /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
|
|
133
|
-
| [Technology Stack and Operations](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/stack_and_ops.md) |
|
|
134
|
-
| [Project Structure](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/project_structure.md) | This chapter describes the source
|
|
135
|
-
| [CLI Command Reference](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/cli_commands.md) |
|
|
136
|
-
| [Configuration and Customization](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/configuration.md) | sdd-forge reads a single JSON configuration file
|
|
137
|
-
| [Internal Design](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/internal_design.md) |
|
|
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
|
@@ -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);
|