stego-cli 0.4.0 → 0.4.2
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/.vscode/extensions.json +7 -0
- package/README.md +41 -0
- package/dist/shared/src/contracts/cli/envelopes.js +19 -0
- package/dist/shared/src/contracts/cli/errors.js +14 -0
- package/dist/shared/src/contracts/cli/exit-codes.js +15 -0
- package/dist/shared/src/contracts/cli/index.js +6 -0
- package/dist/shared/src/contracts/cli/metadata.js +1 -0
- package/dist/shared/src/contracts/cli/operations.js +1 -0
- package/dist/shared/src/domain/comments/anchors.js +1 -0
- package/dist/shared/src/domain/comments/index.js +4 -0
- package/dist/shared/src/domain/comments/serializer.js +1 -0
- package/dist/shared/src/domain/comments/thread-key.js +21 -0
- package/dist/shared/src/domain/frontmatter/index.js +3 -0
- package/dist/shared/src/domain/frontmatter/parser.js +34 -0
- package/dist/shared/src/domain/frontmatter/serializer.js +32 -0
- package/dist/shared/src/domain/frontmatter/validators.js +47 -0
- package/dist/shared/src/domain/project/index.js +4 -0
- package/dist/shared/src/domain/stages/index.js +20 -0
- package/dist/shared/src/index.js +6 -0
- package/dist/shared/src/utils/guards.js +6 -0
- package/dist/shared/src/utils/index.js +3 -0
- package/dist/shared/src/utils/invariant.js +5 -0
- package/dist/shared/src/utils/result.js +6 -0
- package/dist/stego-cli/src/app/cli-version.js +32 -0
- package/dist/stego-cli/src/app/command-context.js +1 -0
- package/dist/stego-cli/src/app/command-registry.js +121 -0
- package/dist/stego-cli/src/app/create-cli-app.js +42 -0
- package/dist/stego-cli/src/app/error-boundary.js +42 -0
- package/dist/stego-cli/src/app/index.js +6 -0
- package/dist/stego-cli/src/app/output-renderer.js +6 -0
- package/dist/stego-cli/src/main.js +14 -0
- package/dist/stego-cli/src/modules/comments/application/comment-operations.js +457 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-add.js +40 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-clear-resolved.js +32 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-delete.js +33 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-read.js +32 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-reply.js +36 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-set-status.js +35 -0
- package/dist/stego-cli/src/modules/comments/commands/comments-sync-anchors.js +33 -0
- package/dist/stego-cli/src/modules/comments/domain/comment-policy.js +14 -0
- package/dist/stego-cli/src/modules/comments/index.js +20 -0
- package/dist/stego-cli/src/modules/comments/infra/comments-repo.js +68 -0
- package/dist/stego-cli/src/modules/comments/types.js +1 -0
- package/dist/stego-cli/src/modules/compile/application/compile-manuscript.js +16 -0
- package/dist/stego-cli/src/modules/compile/commands/build.js +51 -0
- package/dist/stego-cli/src/modules/compile/domain/compile-structure.js +105 -0
- package/dist/stego-cli/src/modules/compile/index.js +8 -0
- package/dist/stego-cli/src/modules/compile/infra/dist-writer.js +8 -0
- package/dist/stego-cli/src/modules/compile/types.js +1 -0
- package/dist/stego-cli/src/modules/export/application/run-export.js +29 -0
- package/dist/stego-cli/src/modules/export/commands/export.js +61 -0
- package/dist/stego-cli/src/modules/export/domain/exporter.js +6 -0
- package/dist/stego-cli/src/modules/export/index.js +8 -0
- package/dist/stego-cli/src/modules/export/types.js +1 -0
- package/dist/stego-cli/src/modules/index.js +22 -0
- package/dist/stego-cli/src/modules/manuscript/application/create-manuscript.js +107 -0
- package/dist/stego-cli/src/modules/manuscript/application/order-inference.js +56 -0
- package/dist/stego-cli/src/modules/manuscript/commands/new-manuscript.js +54 -0
- package/dist/stego-cli/src/modules/manuscript/domain/manuscript.js +1 -0
- package/dist/stego-cli/src/modules/manuscript/index.js +9 -0
- package/dist/stego-cli/src/modules/manuscript/infra/manuscript-repo.js +39 -0
- package/dist/stego-cli/src/modules/manuscript/types.js +1 -0
- package/dist/stego-cli/src/modules/metadata/application/apply-metadata.js +45 -0
- package/dist/stego-cli/src/modules/metadata/application/read-metadata.js +18 -0
- package/dist/stego-cli/src/modules/metadata/commands/metadata-apply.js +38 -0
- package/dist/stego-cli/src/modules/metadata/commands/metadata-read.js +33 -0
- package/dist/stego-cli/src/modules/metadata/domain/metadata.js +1 -0
- package/dist/stego-cli/src/modules/metadata/index.js +11 -0
- package/dist/stego-cli/src/modules/metadata/infra/metadata-repo.js +68 -0
- package/dist/stego-cli/src/modules/metadata/types.js +1 -0
- package/dist/stego-cli/src/modules/project/application/create-project.js +203 -0
- package/dist/stego-cli/src/modules/project/application/infer-project.js +72 -0
- package/dist/stego-cli/src/modules/project/commands/new-project.js +86 -0
- package/dist/stego-cli/src/modules/project/domain/project.js +1 -0
- package/dist/stego-cli/src/modules/project/index.js +9 -0
- package/dist/stego-cli/src/modules/project/infra/project-repo.js +27 -0
- package/dist/stego-cli/src/modules/project/types.js +1 -0
- package/dist/stego-cli/src/modules/quality/application/inspect-project.js +603 -0
- package/dist/stego-cli/src/modules/quality/application/lint-runner.js +313 -0
- package/dist/stego-cli/src/modules/quality/application/stage-check.js +87 -0
- package/dist/stego-cli/src/modules/quality/commands/check-stage.js +54 -0
- package/dist/stego-cli/src/modules/quality/commands/lint.js +51 -0
- package/dist/stego-cli/src/modules/quality/commands/validate.js +50 -0
- package/dist/stego-cli/src/modules/quality/domain/issues.js +1 -0
- package/dist/stego-cli/src/modules/quality/domain/policies.js +1 -0
- package/dist/stego-cli/src/modules/quality/index.js +14 -0
- package/dist/stego-cli/src/modules/quality/infra/cspell-adapter.js +1 -0
- package/dist/stego-cli/src/modules/quality/infra/markdownlint-adapter.js +1 -0
- package/dist/stego-cli/src/modules/quality/types.js +1 -0
- package/dist/stego-cli/src/modules/scaffold/application/scaffold-workspace.js +307 -0
- package/dist/stego-cli/src/modules/scaffold/commands/init.js +34 -0
- package/dist/stego-cli/src/modules/scaffold/domain/templates.js +182 -0
- package/dist/stego-cli/src/modules/scaffold/index.js +8 -0
- package/dist/stego-cli/src/modules/scaffold/infra/template-repo.js +33 -0
- package/dist/stego-cli/src/modules/scaffold/types.js +1 -0
- package/dist/stego-cli/src/modules/spine/application/create-category.js +14 -0
- package/dist/stego-cli/src/modules/spine/application/create-entry.js +9 -0
- package/dist/stego-cli/src/modules/spine/application/read-catalog.js +13 -0
- package/dist/stego-cli/src/modules/spine/commands/spine-deprecated-aliases.js +33 -0
- package/dist/stego-cli/src/modules/spine/commands/spine-new-category.js +64 -0
- package/dist/stego-cli/src/modules/spine/commands/spine-new-entry.js +65 -0
- package/dist/stego-cli/src/modules/spine/commands/spine-read.js +49 -0
- package/dist/{spine/spine-domain.js → stego-cli/src/modules/spine/domain/spine.js} +13 -7
- package/dist/stego-cli/src/modules/spine/index.js +16 -0
- package/dist/stego-cli/src/modules/spine/infra/spine-repo.js +46 -0
- package/dist/stego-cli/src/modules/spine/types.js +1 -0
- package/dist/stego-cli/src/modules/workspace/application/discover-projects.js +18 -0
- package/dist/stego-cli/src/modules/workspace/application/resolve-workspace.js +73 -0
- package/dist/stego-cli/src/modules/workspace/commands/list-projects.js +40 -0
- package/dist/stego-cli/src/modules/workspace/index.js +9 -0
- package/dist/stego-cli/src/modules/workspace/infra/workspace-repo.js +37 -0
- package/dist/stego-cli/src/modules/workspace/types.js +1 -0
- package/dist/stego-cli/src/platform/clock.js +3 -0
- package/dist/stego-cli/src/platform/fs.js +13 -0
- package/dist/stego-cli/src/platform/index.js +3 -0
- package/dist/stego-cli/src/platform/temp-files.js +6 -0
- package/package.json +20 -11
- package/dist/comments/comments-command.js +0 -499
- package/dist/comments/errors.js +0 -20
- package/dist/metadata/metadata-command.js +0 -127
- package/dist/metadata/metadata-domain.js +0 -209
- package/dist/spine/spine-command.js +0 -129
- package/dist/stego-cli.js +0 -2107
- /package/dist/{exporters/exporter-types.js → shared/src/contracts/cli/comments.js} +0 -0
- /package/dist/{comments/comment-domain.js → shared/src/domain/comments/parser.js} +0 -0
- /package/dist/{exporters → stego-cli/src/modules/export/infra}/markdown-exporter.js +0 -0
- /package/dist/{exporters → stego-cli/src/modules/export/infra}/pandoc-exporter.js +0 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { parseCommentAppendix } from "../../../../../shared/src/domain/comments/index.js";
|
|
6
|
+
export function resolveLintSelection(options) {
|
|
7
|
+
const manuscript = readBooleanOption(options, "manuscript");
|
|
8
|
+
const spine = readBooleanOption(options, "spine");
|
|
9
|
+
if (!manuscript && !spine) {
|
|
10
|
+
return { manuscript: true, spine: true };
|
|
11
|
+
}
|
|
12
|
+
return { manuscript, spine };
|
|
13
|
+
}
|
|
14
|
+
export function formatLintSelection(selection) {
|
|
15
|
+
if (selection.manuscript && selection.spine) {
|
|
16
|
+
return "manuscript + spine";
|
|
17
|
+
}
|
|
18
|
+
if (selection.manuscript) {
|
|
19
|
+
return "manuscript";
|
|
20
|
+
}
|
|
21
|
+
if (selection.spine) {
|
|
22
|
+
return "spine";
|
|
23
|
+
}
|
|
24
|
+
return "none";
|
|
25
|
+
}
|
|
26
|
+
export function runProjectLint(project, selection) {
|
|
27
|
+
const issues = [];
|
|
28
|
+
let fileCount = 0;
|
|
29
|
+
if (selection.manuscript) {
|
|
30
|
+
const manuscriptFiles = collectTopLevelMarkdownFiles(project.manuscriptDir);
|
|
31
|
+
if (manuscriptFiles.length === 0) {
|
|
32
|
+
issues.push(makeIssue("error", "lint", `No manuscript markdown files found in ${project.manuscriptDir}`));
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
fileCount += manuscriptFiles.length;
|
|
36
|
+
issues.push(...runMarkdownlint(project, manuscriptFiles, true, "manuscript"));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (selection.spine) {
|
|
40
|
+
const spineLintState = collectSpineLintMarkdownFiles(project);
|
|
41
|
+
issues.push(...spineLintState.issues);
|
|
42
|
+
if (spineLintState.files.length > 0) {
|
|
43
|
+
fileCount += spineLintState.files.length;
|
|
44
|
+
issues.push(...runMarkdownlint(project, spineLintState.files, true, "default"));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (fileCount === 0 && issues.length === 0) {
|
|
48
|
+
issues.push(makeIssue("error", "lint", `No markdown files found for lint scope '${formatLintSelection(selection)}' in project '${project.id}'.`));
|
|
49
|
+
}
|
|
50
|
+
return { issues, fileCount };
|
|
51
|
+
}
|
|
52
|
+
export function runMarkdownlint(project, files, required, profile = "default") {
|
|
53
|
+
if (files.length === 0) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const markdownlintCommand = resolveCommand(project.workspace.repoRoot, "markdownlint");
|
|
57
|
+
if (!markdownlintCommand) {
|
|
58
|
+
if (required) {
|
|
59
|
+
return [
|
|
60
|
+
makeIssue("error", "tooling", "markdownlint is required for this command but not installed. Run 'npm i' in the repo root.")
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const repoRoot = project.workspace.repoRoot;
|
|
66
|
+
const manuscriptProjectConfigPath = path.join(project.root, ".markdownlint.manuscript.json");
|
|
67
|
+
const manuscriptRepoConfigPath = path.join(repoRoot, ".markdownlint.manuscript.json");
|
|
68
|
+
const defaultProjectConfigPath = path.join(project.root, ".markdownlint.json");
|
|
69
|
+
const defaultRepoConfigPath = path.join(repoRoot, ".markdownlint.json");
|
|
70
|
+
const markdownlintConfigPath = profile === "manuscript"
|
|
71
|
+
? (fs.existsSync(manuscriptProjectConfigPath)
|
|
72
|
+
? manuscriptProjectConfigPath
|
|
73
|
+
: fs.existsSync(manuscriptRepoConfigPath)
|
|
74
|
+
? manuscriptRepoConfigPath
|
|
75
|
+
: fs.existsSync(defaultProjectConfigPath)
|
|
76
|
+
? defaultProjectConfigPath
|
|
77
|
+
: defaultRepoConfigPath)
|
|
78
|
+
: (fs.existsSync(defaultProjectConfigPath)
|
|
79
|
+
? defaultProjectConfigPath
|
|
80
|
+
: defaultRepoConfigPath);
|
|
81
|
+
const prepared = prepareFilesWithoutComments(project.workspace.repoRoot, files);
|
|
82
|
+
try {
|
|
83
|
+
const result = spawnSync(markdownlintCommand, ["--config", markdownlintConfigPath, ...prepared.files], {
|
|
84
|
+
cwd: repoRoot,
|
|
85
|
+
encoding: "utf8"
|
|
86
|
+
});
|
|
87
|
+
if (result.status === 0) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const details = remapToolOutputPaths(compactToolOutput(result.stdout, result.stderr), prepared.pathMap, project.workspace.repoRoot);
|
|
91
|
+
return [makeIssue(required ? "error" : "warning", "lint", `markdownlint reported issues. ${details}`)];
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
prepared.cleanup();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function runCSpell(project, files, required, extraWords = []) {
|
|
98
|
+
const cspellCommand = resolveCommand(project.workspace.repoRoot, "cspell");
|
|
99
|
+
if (!cspellCommand) {
|
|
100
|
+
if (required) {
|
|
101
|
+
return [
|
|
102
|
+
makeIssue("error", "tooling", "cspell is required for this stage but not installed. Run 'npm i' in the repo root.")
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
const repoRoot = project.workspace.repoRoot;
|
|
108
|
+
let tempConfigDir = null;
|
|
109
|
+
let cspellConfigPath = path.join(repoRoot, ".cspell.json");
|
|
110
|
+
if (extraWords.length > 0) {
|
|
111
|
+
const baseConfig = readJson(cspellConfigPath);
|
|
112
|
+
const existingWords = Array.isArray(baseConfig.words)
|
|
113
|
+
? baseConfig.words.filter((word) => typeof word === "string")
|
|
114
|
+
: [];
|
|
115
|
+
const mergedWords = new Set([...existingWords, ...extraWords]);
|
|
116
|
+
tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "stego-cspell-"));
|
|
117
|
+
cspellConfigPath = path.join(tempConfigDir, "cspell.generated.json");
|
|
118
|
+
fs.writeFileSync(cspellConfigPath, `${JSON.stringify({ ...baseConfig, words: Array.from(mergedWords).sort() }, null, 2)}\n`, "utf8");
|
|
119
|
+
}
|
|
120
|
+
const prepared = prepareFilesWithoutComments(repoRoot, files);
|
|
121
|
+
try {
|
|
122
|
+
const result = spawnSync(cspellCommand, ["--no-progress", "--no-summary", "--config", cspellConfigPath, ...prepared.files], {
|
|
123
|
+
cwd: repoRoot,
|
|
124
|
+
encoding: "utf8"
|
|
125
|
+
});
|
|
126
|
+
if (result.status === 0) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
const details = remapToolOutputPaths(compactToolOutput(result.stdout, result.stderr), prepared.pathMap, repoRoot);
|
|
130
|
+
return [
|
|
131
|
+
makeIssue(required ? "error" : "warning", "spell", `cspell reported issues. ${details} Words from spine identifiers are auto-whitelisted. For additional terms, add them to '.cspell.json' under the 'words' array.`)
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
prepared.cleanup();
|
|
136
|
+
if (tempConfigDir) {
|
|
137
|
+
fs.rmSync(tempConfigDir, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function collectSpineWordsForSpellcheck(entriesByCategory) {
|
|
142
|
+
const words = new Set();
|
|
143
|
+
for (const [category, entries] of entriesByCategory) {
|
|
144
|
+
const categoryParts = category
|
|
145
|
+
.split(/[-_/]+/)
|
|
146
|
+
.map((part) => part.trim())
|
|
147
|
+
.filter(Boolean);
|
|
148
|
+
for (const part of categoryParts) {
|
|
149
|
+
if (/[A-Za-z]/.test(part)) {
|
|
150
|
+
words.add(part.toLowerCase());
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
const entryParts = entry
|
|
155
|
+
.split(/[-_/]+/)
|
|
156
|
+
.map((part) => part.trim())
|
|
157
|
+
.filter(Boolean);
|
|
158
|
+
for (const part of entryParts) {
|
|
159
|
+
if (!/[A-Za-z]/.test(part)) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
words.add(part.toLowerCase());
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return Array.from(words).sort();
|
|
167
|
+
}
|
|
168
|
+
function collectSpineLintMarkdownFiles(project) {
|
|
169
|
+
const issues = [];
|
|
170
|
+
const files = new Set();
|
|
171
|
+
addMarkdownFilesFromDirectory(files, project.spineDir, true);
|
|
172
|
+
if (!fs.existsSync(project.spineDir)) {
|
|
173
|
+
issues.push(makeIssue("warning", "lint", `Missing spine directory: ${project.spineDir}`));
|
|
174
|
+
}
|
|
175
|
+
addMarkdownFilesFromDirectory(files, project.notesDir, true);
|
|
176
|
+
if (!fs.existsSync(project.notesDir)) {
|
|
177
|
+
issues.push(makeIssue("warning", "lint", `Missing notes directory: ${project.notesDir}`));
|
|
178
|
+
}
|
|
179
|
+
for (const file of collectTopLevelMarkdownFiles(project.root)) {
|
|
180
|
+
files.add(file);
|
|
181
|
+
}
|
|
182
|
+
const sortedFiles = Array.from(files).sort();
|
|
183
|
+
if (sortedFiles.length === 0) {
|
|
184
|
+
issues.push(makeIssue("error", "lint", `No spine/notes markdown files found in ${project.spineDir}, ${project.notesDir}, or project root.`));
|
|
185
|
+
}
|
|
186
|
+
return { files: sortedFiles, issues };
|
|
187
|
+
}
|
|
188
|
+
function collectTopLevelMarkdownFiles(directory) {
|
|
189
|
+
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
return fs
|
|
193
|
+
.readdirSync(directory, { withFileTypes: true })
|
|
194
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
|
195
|
+
.map((entry) => path.join(directory, entry.name))
|
|
196
|
+
.sort();
|
|
197
|
+
}
|
|
198
|
+
function addMarkdownFilesFromDirectory(target, directory, recursive) {
|
|
199
|
+
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const stack = [directory];
|
|
203
|
+
while (stack.length > 0) {
|
|
204
|
+
const current = stack.pop();
|
|
205
|
+
if (!current) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
const fullPath = path.join(current, entry.name);
|
|
211
|
+
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
212
|
+
target.add(fullPath);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (recursive && entry.isDirectory()) {
|
|
216
|
+
stack.push(fullPath);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function prepareFilesWithoutComments(repoRoot, files) {
|
|
222
|
+
if (files.length === 0) {
|
|
223
|
+
return {
|
|
224
|
+
files,
|
|
225
|
+
pathMap: new Map(),
|
|
226
|
+
cleanup: () => undefined
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const tempDir = fs.mkdtempSync(path.join(repoRoot, ".stego-tooling-"));
|
|
230
|
+
const pathMap = new Map();
|
|
231
|
+
const preparedFiles = [];
|
|
232
|
+
for (let index = 0; index < files.length; index += 1) {
|
|
233
|
+
const filePath = files[index];
|
|
234
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
235
|
+
const relativePath = path.relative(repoRoot, filePath);
|
|
236
|
+
const parsed = parseCommentAppendix(raw);
|
|
237
|
+
const sanitized = parsed.contentWithoutComments.endsWith("\n")
|
|
238
|
+
? parsed.contentWithoutComments
|
|
239
|
+
: `${parsed.contentWithoutComments}\n`;
|
|
240
|
+
const relativeTarget = relativePath.startsWith("..")
|
|
241
|
+
? `external/file-${index + 1}-${path.basename(filePath)}`
|
|
242
|
+
: relativePath;
|
|
243
|
+
const targetPath = path.join(tempDir, relativeTarget);
|
|
244
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
245
|
+
fs.writeFileSync(targetPath, sanitized, "utf8");
|
|
246
|
+
preparedFiles.push(targetPath);
|
|
247
|
+
pathMap.set(targetPath, filePath);
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
files: preparedFiles,
|
|
251
|
+
pathMap,
|
|
252
|
+
cleanup: () => {
|
|
253
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function remapToolOutputPaths(output, pathMap, repoRoot) {
|
|
258
|
+
if (!output || pathMap.size === 0) {
|
|
259
|
+
return output;
|
|
260
|
+
}
|
|
261
|
+
const root = repoRoot || process.cwd();
|
|
262
|
+
let mapped = output;
|
|
263
|
+
for (const [preparedPath, originalPath] of pathMap.entries()) {
|
|
264
|
+
if (preparedPath === originalPath) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
mapped = mapped.split(preparedPath).join(originalPath);
|
|
268
|
+
const preparedRelative = path.relative(root, preparedPath);
|
|
269
|
+
const originalRelative = path.relative(root, originalPath);
|
|
270
|
+
const preparedRelativeNormalized = preparedRelative.split(path.sep).join("/");
|
|
271
|
+
const originalRelativeNormalized = originalRelative.split(path.sep).join("/");
|
|
272
|
+
mapped = mapped.split(preparedRelative).join(originalRelative);
|
|
273
|
+
mapped = mapped.split(preparedRelativeNormalized).join(originalRelativeNormalized);
|
|
274
|
+
}
|
|
275
|
+
return mapped;
|
|
276
|
+
}
|
|
277
|
+
function resolveCommand(repoRoot, command) {
|
|
278
|
+
const localCommandPath = path.join(repoRoot, "node_modules", ".bin", process.platform === "win32" ? `${command}.cmd` : command);
|
|
279
|
+
if (fs.existsSync(localCommandPath)) {
|
|
280
|
+
return localCommandPath;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
function compactToolOutput(stdout, stderr) {
|
|
285
|
+
const text = `${stdout || ""}\n${stderr || ""}`.trim();
|
|
286
|
+
if (!text) {
|
|
287
|
+
return "No details provided by tool.";
|
|
288
|
+
}
|
|
289
|
+
return text
|
|
290
|
+
.split(/\r?\n/)
|
|
291
|
+
.filter(Boolean)
|
|
292
|
+
.slice(0, 4)
|
|
293
|
+
.join(" | ");
|
|
294
|
+
}
|
|
295
|
+
function readJson(filePath) {
|
|
296
|
+
if (!fs.existsSync(filePath)) {
|
|
297
|
+
throw new Error(`Missing JSON file: ${filePath}`);
|
|
298
|
+
}
|
|
299
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
300
|
+
try {
|
|
301
|
+
return JSON.parse(raw);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
305
|
+
throw new Error(`Invalid JSON at ${filePath}: ${message}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function readBooleanOption(options, key) {
|
|
309
|
+
return options[key] === true;
|
|
310
|
+
}
|
|
311
|
+
function makeIssue(level, category, message, file = null, line = null) {
|
|
312
|
+
return { level, category, message, file, line };
|
|
313
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { getStageRank, isStageName } from "../../../../../shared/src/domain/stages/index.js";
|
|
2
|
+
import { inspectProject } from "./inspect-project.js";
|
|
3
|
+
import { collectSpineWordsForSpellcheck, runCSpell, runMarkdownlint } from "./lint-runner.js";
|
|
4
|
+
export function runStageCheck(project, stage, onlyFile) {
|
|
5
|
+
const policy = resolveStagePolicy(project, stage);
|
|
6
|
+
const report = inspectProject(project, { onlyFile });
|
|
7
|
+
const issues = [...report.issues];
|
|
8
|
+
const minimumRank = getStageRank(policy.minimumChapterStatus);
|
|
9
|
+
for (const chapter of report.chapters) {
|
|
10
|
+
if (!isStageName(chapter.status)) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const chapterRank = getStageRank(chapter.status);
|
|
14
|
+
if (chapterRank < minimumRank) {
|
|
15
|
+
issues.push(makeIssue("error", "stage", `File status '${chapter.status}' is below required stage '${policy.minimumChapterStatus}'.`, chapter.relativePath));
|
|
16
|
+
}
|
|
17
|
+
if (stage === "final" && chapter.status !== "final") {
|
|
18
|
+
issues.push(makeIssue("error", "stage", "Final stage requires all chapters to be status 'final'.", chapter.relativePath));
|
|
19
|
+
}
|
|
20
|
+
if (policy.requireResolvedComments) {
|
|
21
|
+
const unresolvedComments = chapter.comments.filter((comment) => !comment.resolved);
|
|
22
|
+
if (unresolvedComments.length > 0) {
|
|
23
|
+
const unresolvedLabel = unresolvedComments.slice(0, 5).map((comment) => comment.id).join(", ");
|
|
24
|
+
const remainder = unresolvedComments.length > 5 ? ` (+${unresolvedComments.length - 5} more)` : "";
|
|
25
|
+
issues.push(makeIssue("error", "comments", `Unresolved comments (${unresolvedComments.length}): ${unresolvedLabel}${remainder}. Resolve or clear comments before stage '${stage}'.`, chapter.relativePath));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (policy.requireSpine) {
|
|
30
|
+
if (report.spineState.categories.length === 0) {
|
|
31
|
+
issues.push(makeIssue("error", "continuity", "No spine categories found. Add at least one category under spine/<category>/ before this stage."));
|
|
32
|
+
}
|
|
33
|
+
for (const spineIssue of report.issues.filter((issue) => issue.category === "continuity")) {
|
|
34
|
+
if (spineIssue.message.startsWith("Missing spine directory")) {
|
|
35
|
+
issues.push({ ...spineIssue, level: "error" });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (policy.enforceLocalLinks) {
|
|
40
|
+
for (const linkIssue of issues.filter((issue) => issue.category === "links" && issue.level !== "error")) {
|
|
41
|
+
linkIssue.level = "error";
|
|
42
|
+
linkIssue.message = `${linkIssue.message} (strict in stage '${stage}')`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const chapterPaths = report.chapters.map((chapter) => chapter.path);
|
|
46
|
+
const spineWords = collectSpineWordsForSpellcheck(report.spineState.entriesByCategory);
|
|
47
|
+
if (policy.enforceMarkdownlint) {
|
|
48
|
+
issues.push(...runMarkdownlint(project, chapterPaths, true, "manuscript"));
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
issues.push(...runMarkdownlint(project, chapterPaths, false, "manuscript"));
|
|
52
|
+
}
|
|
53
|
+
if (policy.enforceCSpell) {
|
|
54
|
+
issues.push(...runCSpell(project, chapterPaths, true, spineWords));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
issues.push(...runCSpell(project, chapterPaths, false, spineWords));
|
|
58
|
+
}
|
|
59
|
+
return { chapters: report.chapters, issues };
|
|
60
|
+
}
|
|
61
|
+
function resolveStagePolicy(project, stage) {
|
|
62
|
+
if (!isStageName(stage)) {
|
|
63
|
+
throw new Error(`Unknown stage '${stage}'. Allowed: ${Object.keys(project.workspace.config.stagePolicies).join(", ")}.`);
|
|
64
|
+
}
|
|
65
|
+
const rawPolicy = project.workspace.config.stagePolicies[stage];
|
|
66
|
+
if (!isStagePolicy(rawPolicy)) {
|
|
67
|
+
throw new Error(`Invalid stage policy for '${stage}' in stego.config.json.`);
|
|
68
|
+
}
|
|
69
|
+
return rawPolicy;
|
|
70
|
+
}
|
|
71
|
+
function isStagePolicy(value) {
|
|
72
|
+
if (!isPlainObject(value)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return isStageName(String(value.minimumChapterStatus))
|
|
76
|
+
&& typeof value.requireSpine === "boolean"
|
|
77
|
+
&& typeof value.enforceMarkdownlint === "boolean"
|
|
78
|
+
&& typeof value.enforceCSpell === "boolean"
|
|
79
|
+
&& typeof value.enforceLocalLinks === "boolean"
|
|
80
|
+
&& (value.requireResolvedComments == null || typeof value.requireResolvedComments === "boolean");
|
|
81
|
+
}
|
|
82
|
+
function isPlainObject(value) {
|
|
83
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
84
|
+
}
|
|
85
|
+
function makeIssue(level, category, message, file = null, line = null) {
|
|
86
|
+
return { level, category, message, file, line };
|
|
87
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { writeText } from "../../../app/output-renderer.js";
|
|
2
|
+
import { resolveProjectContext } from "../../project/index.js";
|
|
3
|
+
import { resolveWorkspaceContext } from "../../workspace/index.js";
|
|
4
|
+
import { formatIssues, issueHasErrors } from "../application/inspect-project.js";
|
|
5
|
+
import { runStageCheck } from "../application/stage-check.js";
|
|
6
|
+
export function registerCheckStageCommand(registry) {
|
|
7
|
+
registry.register({
|
|
8
|
+
name: "check-stage",
|
|
9
|
+
description: "Run stage-specific quality gates",
|
|
10
|
+
allowUnknownOptions: true,
|
|
11
|
+
options: [
|
|
12
|
+
{ flags: "--project <project-id>", description: "Project id" },
|
|
13
|
+
{ flags: "--stage <stage>", description: "draft|revise|line-edit|proof|final" },
|
|
14
|
+
{ flags: "--file <path>", description: "Project-relative manuscript path" },
|
|
15
|
+
{ flags: "--root <path>", description: "Workspace root path" }
|
|
16
|
+
],
|
|
17
|
+
action: (context) => {
|
|
18
|
+
const project = resolveProjectContext({
|
|
19
|
+
workspace: resolveWorkspaceContext({
|
|
20
|
+
cwd: context.cwd,
|
|
21
|
+
rootOption: readStringOption(context.options, "root")
|
|
22
|
+
}),
|
|
23
|
+
cwd: context.cwd,
|
|
24
|
+
env: context.env,
|
|
25
|
+
explicitProjectId: readStringOption(context.options, "project")
|
|
26
|
+
});
|
|
27
|
+
const stage = readStringOption(context.options, "stage") || "draft";
|
|
28
|
+
const requestedFile = readStringOption(context.options, "file");
|
|
29
|
+
const report = runStageCheck(project, stage, requestedFile);
|
|
30
|
+
for (const line of formatIssues(report.issues)) {
|
|
31
|
+
writeText(line);
|
|
32
|
+
}
|
|
33
|
+
if (issueHasErrors(report.issues)) {
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (requestedFile && report.chapters.length === 1) {
|
|
38
|
+
writeText(`Stage check passed for '${report.chapters[0].relativePath}' at stage '${stage}'.`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
writeText(`Stage check passed for '${project.id}' at stage '${stage}'.`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function readStringOption(options, key) {
|
|
46
|
+
const value = options[key];
|
|
47
|
+
if (typeof value === "string") {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === "number") {
|
|
51
|
+
return String(value);
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { writeText } from "../../../app/output-renderer.js";
|
|
2
|
+
import { resolveProjectContext } from "../../project/index.js";
|
|
3
|
+
import { resolveWorkspaceContext } from "../../workspace/index.js";
|
|
4
|
+
import { formatIssues, issueHasErrors } from "../application/inspect-project.js";
|
|
5
|
+
import { formatLintSelection, resolveLintSelection, runProjectLint } from "../application/lint-runner.js";
|
|
6
|
+
export function registerLintCommand(registry) {
|
|
7
|
+
registry.register({
|
|
8
|
+
name: "lint",
|
|
9
|
+
description: "Run markdown and spelling checks",
|
|
10
|
+
allowUnknownOptions: true,
|
|
11
|
+
options: [
|
|
12
|
+
{ flags: "--project <project-id>", description: "Project id" },
|
|
13
|
+
{ flags: "--manuscript", description: "Lint manuscript files only" },
|
|
14
|
+
{ flags: "--spine", description: "Lint spine/notes files only" },
|
|
15
|
+
{ flags: "--root <path>", description: "Workspace root path" }
|
|
16
|
+
],
|
|
17
|
+
action: (context) => {
|
|
18
|
+
const project = resolveProjectContext({
|
|
19
|
+
workspace: resolveWorkspaceContext({
|
|
20
|
+
cwd: context.cwd,
|
|
21
|
+
rootOption: readStringOption(context.options, "root")
|
|
22
|
+
}),
|
|
23
|
+
cwd: context.cwd,
|
|
24
|
+
env: context.env,
|
|
25
|
+
explicitProjectId: readStringOption(context.options, "project")
|
|
26
|
+
});
|
|
27
|
+
const selection = resolveLintSelection(context.options);
|
|
28
|
+
const result = runProjectLint(project, selection);
|
|
29
|
+
for (const line of formatIssues(result.issues)) {
|
|
30
|
+
writeText(line);
|
|
31
|
+
}
|
|
32
|
+
if (issueHasErrors(result.issues)) {
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const scopeLabel = formatLintSelection(selection);
|
|
37
|
+
const fileLabel = result.fileCount === 1 ? "file" : "files";
|
|
38
|
+
writeText(`Lint passed for '${project.id}' (${scopeLabel}, ${result.fileCount} ${fileLabel}).`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function readStringOption(options, key) {
|
|
43
|
+
const value = options[key];
|
|
44
|
+
if (typeof value === "string") {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
if (typeof value === "number") {
|
|
48
|
+
return String(value);
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { writeText } from "../../../app/output-renderer.js";
|
|
2
|
+
import { resolveProjectContext } from "../../project/index.js";
|
|
3
|
+
import { resolveWorkspaceContext } from "../../workspace/index.js";
|
|
4
|
+
import { formatIssues, inspectProject, issueHasErrors } from "../application/inspect-project.js";
|
|
5
|
+
export function registerValidateCommand(registry) {
|
|
6
|
+
registry.register({
|
|
7
|
+
name: "validate",
|
|
8
|
+
description: "Validate manuscript and project state",
|
|
9
|
+
allowUnknownOptions: true,
|
|
10
|
+
options: [
|
|
11
|
+
{ flags: "--project <project-id>", description: "Project id" },
|
|
12
|
+
{ flags: "--file <path>", description: "Project-relative manuscript path" },
|
|
13
|
+
{ flags: "--root <path>", description: "Workspace root path" }
|
|
14
|
+
],
|
|
15
|
+
action: (context) => {
|
|
16
|
+
const project = resolveProjectContext({
|
|
17
|
+
workspace: resolveWorkspaceContext({
|
|
18
|
+
cwd: context.cwd,
|
|
19
|
+
rootOption: readStringOption(context.options, "root")
|
|
20
|
+
}),
|
|
21
|
+
cwd: context.cwd,
|
|
22
|
+
env: context.env,
|
|
23
|
+
explicitProjectId: readStringOption(context.options, "project")
|
|
24
|
+
});
|
|
25
|
+
const report = inspectProject(project, { onlyFile: readStringOption(context.options, "file") });
|
|
26
|
+
for (const line of formatIssues(report.issues)) {
|
|
27
|
+
writeText(line);
|
|
28
|
+
}
|
|
29
|
+
if (issueHasErrors(report.issues)) {
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (report.chapters.length === 1) {
|
|
34
|
+
writeText(`Validation passed for '${report.chapters[0].relativePath}'.`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
writeText(`Validation passed for '${project.id}'.`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function readStringOption(options, key) {
|
|
42
|
+
const value = options[key];
|
|
43
|
+
if (typeof value === "string") {
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
if (typeof value === "number") {
|
|
47
|
+
return String(value);
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { registerValidateCommand } from "./commands/validate.js";
|
|
2
|
+
import { registerLintCommand } from "./commands/lint.js";
|
|
3
|
+
import { registerCheckStageCommand } from "./commands/check-stage.js";
|
|
4
|
+
export const qualityModule = {
|
|
5
|
+
registerCommands(registry) {
|
|
6
|
+
registerValidateCommand(registry);
|
|
7
|
+
registerLintCommand(registry);
|
|
8
|
+
registerCheckStageCommand(registry);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
export * from "./types.js";
|
|
12
|
+
export * from "./application/inspect-project.js";
|
|
13
|
+
export * from "./application/stage-check.js";
|
|
14
|
+
export * from "./application/lint-runner.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|