stego-cli 0.4.1 → 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.
Files changed (126) hide show
  1. package/README.md +6 -0
  2. package/dist/shared/src/contracts/cli/envelopes.js +19 -0
  3. package/dist/shared/src/contracts/cli/errors.js +14 -0
  4. package/dist/shared/src/contracts/cli/exit-codes.js +15 -0
  5. package/dist/shared/src/contracts/cli/index.js +6 -0
  6. package/dist/shared/src/contracts/cli/metadata.js +1 -0
  7. package/dist/shared/src/contracts/cli/operations.js +1 -0
  8. package/dist/shared/src/domain/comments/anchors.js +1 -0
  9. package/dist/shared/src/domain/comments/index.js +4 -0
  10. package/dist/shared/src/domain/comments/serializer.js +1 -0
  11. package/dist/shared/src/domain/comments/thread-key.js +21 -0
  12. package/dist/shared/src/domain/frontmatter/index.js +3 -0
  13. package/dist/shared/src/domain/frontmatter/parser.js +34 -0
  14. package/dist/shared/src/domain/frontmatter/serializer.js +32 -0
  15. package/dist/shared/src/domain/frontmatter/validators.js +47 -0
  16. package/dist/shared/src/domain/project/index.js +4 -0
  17. package/dist/shared/src/domain/stages/index.js +20 -0
  18. package/dist/shared/src/index.js +6 -0
  19. package/dist/shared/src/utils/guards.js +6 -0
  20. package/dist/shared/src/utils/index.js +3 -0
  21. package/dist/shared/src/utils/invariant.js +5 -0
  22. package/dist/shared/src/utils/result.js +6 -0
  23. package/dist/stego-cli/src/app/cli-version.js +32 -0
  24. package/dist/stego-cli/src/app/command-context.js +1 -0
  25. package/dist/stego-cli/src/app/command-registry.js +121 -0
  26. package/dist/stego-cli/src/app/create-cli-app.js +42 -0
  27. package/dist/stego-cli/src/app/error-boundary.js +42 -0
  28. package/dist/stego-cli/src/app/index.js +6 -0
  29. package/dist/stego-cli/src/app/output-renderer.js +6 -0
  30. package/dist/stego-cli/src/main.js +14 -0
  31. package/dist/stego-cli/src/modules/comments/application/comment-operations.js +457 -0
  32. package/dist/stego-cli/src/modules/comments/commands/comments-add.js +40 -0
  33. package/dist/stego-cli/src/modules/comments/commands/comments-clear-resolved.js +32 -0
  34. package/dist/stego-cli/src/modules/comments/commands/comments-delete.js +33 -0
  35. package/dist/stego-cli/src/modules/comments/commands/comments-read.js +32 -0
  36. package/dist/stego-cli/src/modules/comments/commands/comments-reply.js +36 -0
  37. package/dist/stego-cli/src/modules/comments/commands/comments-set-status.js +35 -0
  38. package/dist/stego-cli/src/modules/comments/commands/comments-sync-anchors.js +33 -0
  39. package/dist/stego-cli/src/modules/comments/domain/comment-policy.js +14 -0
  40. package/dist/stego-cli/src/modules/comments/index.js +20 -0
  41. package/dist/stego-cli/src/modules/comments/infra/comments-repo.js +68 -0
  42. package/dist/stego-cli/src/modules/comments/types.js +1 -0
  43. package/dist/stego-cli/src/modules/compile/application/compile-manuscript.js +16 -0
  44. package/dist/stego-cli/src/modules/compile/commands/build.js +51 -0
  45. package/dist/stego-cli/src/modules/compile/domain/compile-structure.js +105 -0
  46. package/dist/stego-cli/src/modules/compile/index.js +8 -0
  47. package/dist/stego-cli/src/modules/compile/infra/dist-writer.js +8 -0
  48. package/dist/stego-cli/src/modules/compile/types.js +1 -0
  49. package/dist/stego-cli/src/modules/export/application/run-export.js +29 -0
  50. package/dist/stego-cli/src/modules/export/commands/export.js +61 -0
  51. package/dist/stego-cli/src/modules/export/domain/exporter.js +6 -0
  52. package/dist/stego-cli/src/modules/export/index.js +8 -0
  53. package/dist/stego-cli/src/modules/export/types.js +1 -0
  54. package/dist/stego-cli/src/modules/index.js +22 -0
  55. package/dist/stego-cli/src/modules/manuscript/application/create-manuscript.js +107 -0
  56. package/dist/stego-cli/src/modules/manuscript/application/order-inference.js +56 -0
  57. package/dist/stego-cli/src/modules/manuscript/commands/new-manuscript.js +54 -0
  58. package/dist/stego-cli/src/modules/manuscript/domain/manuscript.js +1 -0
  59. package/dist/stego-cli/src/modules/manuscript/index.js +9 -0
  60. package/dist/stego-cli/src/modules/manuscript/infra/manuscript-repo.js +39 -0
  61. package/dist/stego-cli/src/modules/manuscript/types.js +1 -0
  62. package/dist/stego-cli/src/modules/metadata/application/apply-metadata.js +45 -0
  63. package/dist/stego-cli/src/modules/metadata/application/read-metadata.js +18 -0
  64. package/dist/stego-cli/src/modules/metadata/commands/metadata-apply.js +38 -0
  65. package/dist/stego-cli/src/modules/metadata/commands/metadata-read.js +33 -0
  66. package/dist/stego-cli/src/modules/metadata/domain/metadata.js +1 -0
  67. package/dist/stego-cli/src/modules/metadata/index.js +11 -0
  68. package/dist/stego-cli/src/modules/metadata/infra/metadata-repo.js +68 -0
  69. package/dist/stego-cli/src/modules/metadata/types.js +1 -0
  70. package/dist/stego-cli/src/modules/project/application/create-project.js +203 -0
  71. package/dist/stego-cli/src/modules/project/application/infer-project.js +72 -0
  72. package/dist/stego-cli/src/modules/project/commands/new-project.js +86 -0
  73. package/dist/stego-cli/src/modules/project/domain/project.js +1 -0
  74. package/dist/stego-cli/src/modules/project/index.js +9 -0
  75. package/dist/stego-cli/src/modules/project/infra/project-repo.js +27 -0
  76. package/dist/stego-cli/src/modules/project/types.js +1 -0
  77. package/dist/stego-cli/src/modules/quality/application/inspect-project.js +603 -0
  78. package/dist/stego-cli/src/modules/quality/application/lint-runner.js +313 -0
  79. package/dist/stego-cli/src/modules/quality/application/stage-check.js +87 -0
  80. package/dist/stego-cli/src/modules/quality/commands/check-stage.js +54 -0
  81. package/dist/stego-cli/src/modules/quality/commands/lint.js +51 -0
  82. package/dist/stego-cli/src/modules/quality/commands/validate.js +50 -0
  83. package/dist/stego-cli/src/modules/quality/domain/issues.js +1 -0
  84. package/dist/stego-cli/src/modules/quality/domain/policies.js +1 -0
  85. package/dist/stego-cli/src/modules/quality/index.js +14 -0
  86. package/dist/stego-cli/src/modules/quality/infra/cspell-adapter.js +1 -0
  87. package/dist/stego-cli/src/modules/quality/infra/markdownlint-adapter.js +1 -0
  88. package/dist/stego-cli/src/modules/quality/types.js +1 -0
  89. package/dist/stego-cli/src/modules/scaffold/application/scaffold-workspace.js +307 -0
  90. package/dist/stego-cli/src/modules/scaffold/commands/init.js +34 -0
  91. package/dist/stego-cli/src/modules/scaffold/domain/templates.js +182 -0
  92. package/dist/stego-cli/src/modules/scaffold/index.js +8 -0
  93. package/dist/stego-cli/src/modules/scaffold/infra/template-repo.js +33 -0
  94. package/dist/stego-cli/src/modules/scaffold/types.js +1 -0
  95. package/dist/stego-cli/src/modules/spine/application/create-category.js +14 -0
  96. package/dist/stego-cli/src/modules/spine/application/create-entry.js +9 -0
  97. package/dist/stego-cli/src/modules/spine/application/read-catalog.js +13 -0
  98. package/dist/stego-cli/src/modules/spine/commands/spine-deprecated-aliases.js +33 -0
  99. package/dist/stego-cli/src/modules/spine/commands/spine-new-category.js +64 -0
  100. package/dist/stego-cli/src/modules/spine/commands/spine-new-entry.js +65 -0
  101. package/dist/stego-cli/src/modules/spine/commands/spine-read.js +49 -0
  102. package/dist/{spine/spine-domain.js → stego-cli/src/modules/spine/domain/spine.js} +13 -7
  103. package/dist/stego-cli/src/modules/spine/index.js +16 -0
  104. package/dist/stego-cli/src/modules/spine/infra/spine-repo.js +46 -0
  105. package/dist/stego-cli/src/modules/spine/types.js +1 -0
  106. package/dist/stego-cli/src/modules/workspace/application/discover-projects.js +18 -0
  107. package/dist/stego-cli/src/modules/workspace/application/resolve-workspace.js +73 -0
  108. package/dist/stego-cli/src/modules/workspace/commands/list-projects.js +40 -0
  109. package/dist/stego-cli/src/modules/workspace/index.js +9 -0
  110. package/dist/stego-cli/src/modules/workspace/infra/workspace-repo.js +37 -0
  111. package/dist/stego-cli/src/modules/workspace/types.js +1 -0
  112. package/dist/stego-cli/src/platform/clock.js +3 -0
  113. package/dist/stego-cli/src/platform/fs.js +13 -0
  114. package/dist/stego-cli/src/platform/index.js +3 -0
  115. package/dist/stego-cli/src/platform/temp-files.js +6 -0
  116. package/package.json +20 -11
  117. package/dist/comments/comments-command.js +0 -499
  118. package/dist/comments/errors.js +0 -20
  119. package/dist/metadata/metadata-command.js +0 -127
  120. package/dist/metadata/metadata-domain.js +0 -209
  121. package/dist/spine/spine-command.js +0 -129
  122. package/dist/stego-cli.js +0 -2264
  123. /package/dist/{exporters/exporter-types.js → shared/src/contracts/cli/comments.js} +0 -0
  124. /package/dist/{comments/comment-domain.js → shared/src/domain/comments/parser.js} +0 -0
  125. /package/dist/{exporters → stego-cli/src/modules/export/infra}/markdown-exporter.js +0 -0
  126. /package/dist/{exporters → stego-cli/src/modules/export/infra}/pandoc-exporter.js +0 -0
@@ -0,0 +1,603 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parseCommentAppendix } from "../../../../../shared/src/domain/comments/index.js";
4
+ import { isStageName } from "../../../../../shared/src/domain/stages/index.js";
5
+ import { readSpineCatalogForProject } from "../../spine/index.js";
6
+ export function inspectProject(project, options = {}) {
7
+ const repoRoot = project.workspace.repoRoot;
8
+ const runtimeConfig = project.workspace.config;
9
+ const issues = [];
10
+ const emptySpineState = { categories: [], entriesByCategory: new Map(), issues: [] };
11
+ const requiredMetadataState = resolveRequiredMetadata(project);
12
+ const compileStructureState = resolveCompileStructure(project);
13
+ issues.push(...requiredMetadataState.issues);
14
+ issues.push(...compileStructureState.issues);
15
+ if (project.meta.spineCategories !== undefined) {
16
+ issues.push(makeIssue("error", "metadata", "Legacy 'spineCategories' in stego-project.json is no longer supported. Use spine/ category directories and files.", path.relative(repoRoot, path.join(project.root, "stego-project.json"))));
17
+ }
18
+ const spineState = readSpine(project);
19
+ issues.push(...spineState.issues);
20
+ let chapterFiles = [];
21
+ const onlyFile = options.onlyFile?.trim();
22
+ if (onlyFile) {
23
+ const resolvedPath = path.resolve(project.root, onlyFile);
24
+ const relativeToProject = path.relative(project.root, resolvedPath);
25
+ if (!relativeToProject || relativeToProject.startsWith("..") || path.isAbsolute(relativeToProject)) {
26
+ issues.push(makeIssue("error", "structure", `Requested file is outside the project: ${onlyFile}`, null));
27
+ return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
28
+ }
29
+ if (!fs.existsSync(resolvedPath)) {
30
+ issues.push(makeIssue("error", "structure", `Requested file does not exist: ${onlyFile}`, null));
31
+ return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
32
+ }
33
+ if (!fs.statSync(resolvedPath).isFile() || !resolvedPath.endsWith(".md")) {
34
+ issues.push(makeIssue("error", "structure", `Requested file must be a markdown file: ${onlyFile}`, null));
35
+ return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
36
+ }
37
+ const relativeToManuscript = path.relative(project.manuscriptDir, resolvedPath);
38
+ if (relativeToManuscript.startsWith("..") || path.isAbsolute(relativeToManuscript)) {
39
+ issues.push(makeIssue("error", "structure", `Requested file must be inside manuscript directory: ${project.manuscriptDir}`, null));
40
+ return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
41
+ }
42
+ chapterFiles = [resolvedPath];
43
+ }
44
+ else {
45
+ if (!fs.existsSync(project.manuscriptDir)) {
46
+ issues.push(makeIssue("error", "structure", `Missing manuscript directory: ${project.manuscriptDir}`));
47
+ return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
48
+ }
49
+ chapterFiles = fs
50
+ .readdirSync(project.manuscriptDir, { withFileTypes: true })
51
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
52
+ .map((entry) => path.join(project.manuscriptDir, entry.name))
53
+ .sort();
54
+ if (chapterFiles.length === 0) {
55
+ issues.push(makeIssue("error", "structure", `No manuscript files found in ${project.manuscriptDir}`));
56
+ return { chapters: [], issues, spineState: emptySpineState, compileStructureLevels: compileStructureState.levels };
57
+ }
58
+ }
59
+ const chapters = chapterFiles.map((chapterPath) => parseChapter(chapterPath, project, requiredMetadataState.requiredMetadata, spineState.categories, compileStructureState.levels));
60
+ for (const chapter of chapters) {
61
+ issues.push(...chapter.issues);
62
+ }
63
+ const orderMap = new Map();
64
+ for (const chapter of chapters) {
65
+ if (chapter.order == null) {
66
+ continue;
67
+ }
68
+ if (orderMap.has(chapter.order)) {
69
+ issues.push(makeIssue("error", "ordering", `Duplicate filename order prefix '${chapter.order}' in ${chapter.relativePath} and ${orderMap.get(chapter.order)}`, chapter.relativePath));
70
+ continue;
71
+ }
72
+ orderMap.set(chapter.order, chapter.relativePath);
73
+ }
74
+ chapters.sort((a, b) => {
75
+ if (a.order == null && b.order == null) {
76
+ return a.relativePath.localeCompare(b.relativePath);
77
+ }
78
+ if (a.order == null) {
79
+ return 1;
80
+ }
81
+ if (b.order == null) {
82
+ return -1;
83
+ }
84
+ return a.order - b.order;
85
+ });
86
+ for (const chapter of chapters) {
87
+ issues.push(...findUnknownSpineReferences(chapter.referenceKeysByCategory, spineState.entriesByCategory, chapter.relativePath));
88
+ }
89
+ return {
90
+ chapters,
91
+ issues,
92
+ spineState,
93
+ compileStructureLevels: compileStructureState.levels
94
+ };
95
+ }
96
+ export function resolveRequiredMetadata(project) {
97
+ const repoRoot = project.workspace.repoRoot;
98
+ const runtimeConfig = project.workspace.config;
99
+ const issues = [];
100
+ const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
101
+ const raw = project.meta.requiredMetadata;
102
+ if (raw == null) {
103
+ return { requiredMetadata: runtimeConfig.requiredMetadata, issues };
104
+ }
105
+ if (!Array.isArray(raw)) {
106
+ issues.push(makeIssue("error", "metadata", "Project 'requiredMetadata' must be an array of metadata keys.", projectFile));
107
+ return { requiredMetadata: runtimeConfig.requiredMetadata, issues };
108
+ }
109
+ const requiredMetadata = [];
110
+ const seen = new Set();
111
+ for (const [index, entry] of raw.entries()) {
112
+ if (typeof entry !== "string") {
113
+ issues.push(makeIssue("error", "metadata", `Project 'requiredMetadata' entry at index ${index} must be a string.`, projectFile));
114
+ continue;
115
+ }
116
+ const key = entry.trim();
117
+ if (!key) {
118
+ issues.push(makeIssue("error", "metadata", `Project 'requiredMetadata' entry at index ${index} cannot be empty.`, projectFile));
119
+ continue;
120
+ }
121
+ if (seen.has(key)) {
122
+ continue;
123
+ }
124
+ seen.add(key);
125
+ requiredMetadata.push(key);
126
+ }
127
+ return { requiredMetadata, issues };
128
+ }
129
+ export function resolveCompileStructure(project) {
130
+ const repoRoot = project.workspace.repoRoot;
131
+ const issues = [];
132
+ const projectFile = path.relative(repoRoot, path.join(project.root, "stego-project.json"));
133
+ const raw = project.meta.compileStructure;
134
+ if (raw == null) {
135
+ return { levels: [], issues };
136
+ }
137
+ if (!isPlainObject(raw)) {
138
+ issues.push(makeIssue("error", "metadata", "Project 'compileStructure' must be an object.", projectFile));
139
+ return { levels: [], issues };
140
+ }
141
+ const rawLevels = raw.levels;
142
+ if (!Array.isArray(rawLevels)) {
143
+ issues.push(makeIssue("error", "metadata", "Project 'compileStructure.levels' must be an array.", projectFile));
144
+ return { levels: [], issues };
145
+ }
146
+ const levels = [];
147
+ const seenKeys = new Set();
148
+ for (const [index, entry] of rawLevels.entries()) {
149
+ if (!isPlainObject(entry)) {
150
+ issues.push(makeIssue("error", "metadata", `Invalid compileStructure level at index ${index}. Expected object.`, projectFile));
151
+ continue;
152
+ }
153
+ const key = typeof entry.key === "string" ? entry.key.trim() : "";
154
+ const label = typeof entry.label === "string" ? entry.label.trim() : "";
155
+ const titleKeyRaw = typeof entry.titleKey === "string" ? entry.titleKey.trim() : "";
156
+ const headingTemplateRaw = typeof entry.headingTemplate === "string" ? entry.headingTemplate.trim() : "";
157
+ if (!key || !/^[a-z][a-z0-9_-]*$/.test(key)) {
158
+ issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].key must match /^[a-z][a-z0-9_-]*$/.`, projectFile));
159
+ continue;
160
+ }
161
+ if (!label) {
162
+ issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].label is required.`, projectFile));
163
+ continue;
164
+ }
165
+ if (seenKeys.has(key)) {
166
+ issues.push(makeIssue("error", "metadata", `Duplicate compileStructure level key '${key}'.`, projectFile));
167
+ continue;
168
+ }
169
+ if (titleKeyRaw && !/^[a-z][a-z0-9_-]*$/.test(titleKeyRaw)) {
170
+ issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].titleKey must match /^[a-z][a-z0-9_-]*$/.`, projectFile));
171
+ continue;
172
+ }
173
+ const pageBreakRaw = typeof entry.pageBreak === "string" ? entry.pageBreak.trim() : "between-groups";
174
+ if (pageBreakRaw !== "none" && pageBreakRaw !== "between-groups") {
175
+ issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].pageBreak must be 'none' or 'between-groups'.`, projectFile));
176
+ continue;
177
+ }
178
+ const injectHeading = typeof entry.injectHeading === "boolean" ? entry.injectHeading : true;
179
+ const headingTemplate = headingTemplateRaw || "{label} {value}: {title}";
180
+ seenKeys.add(key);
181
+ levels.push({
182
+ key,
183
+ label,
184
+ titleKey: titleKeyRaw || undefined,
185
+ injectHeading,
186
+ headingTemplate,
187
+ pageBreak: pageBreakRaw
188
+ });
189
+ }
190
+ return { levels, issues };
191
+ }
192
+ export function issueHasErrors(issues) {
193
+ return issues.some((issue) => issue.level === "error");
194
+ }
195
+ export function formatIssues(issues) {
196
+ return issues.map((issue) => {
197
+ const filePart = issue.file ? ` ${issue.file}` : "";
198
+ const linePart = issue.line ? `:${issue.line}` : "";
199
+ return `[${issue.level.toUpperCase()}][${issue.category}]${filePart}${linePart} ${issue.message}`;
200
+ });
201
+ }
202
+ function parseChapter(chapterPath, project, requiredMetadata, spineCategories, compileStructureLevels) {
203
+ const repoRoot = project.workspace.repoRoot;
204
+ const runtimeConfig = project.workspace.config;
205
+ const relativePath = path.relative(repoRoot, chapterPath);
206
+ const raw = fs.readFileSync(chapterPath, "utf8");
207
+ const { metadata, body, comments, issues } = parseMetadata(raw, chapterPath, repoRoot, false);
208
+ const chapterIssues = [...issues];
209
+ for (const requiredKey of requiredMetadata) {
210
+ if (metadata[requiredKey] == null || metadata[requiredKey] === "") {
211
+ chapterIssues.push(makeIssue("warning", "metadata", `Missing required metadata key '${requiredKey}'. Validation and stage checks that depend on '${requiredKey}' are skipped for this file.`, relativePath));
212
+ }
213
+ }
214
+ const title = deriveEntryTitle(metadata.title, chapterPath);
215
+ if (metadata.order != null && metadata.order !== "") {
216
+ chapterIssues.push(makeIssue("warning", "metadata", "Metadata 'order' is ignored. Ordering is derived from filename prefix.", relativePath));
217
+ }
218
+ const order = parseOrderFromFilename(chapterPath, relativePath, chapterIssues);
219
+ const status = String(metadata.status || "").trim();
220
+ if (status && !isStageName(status)) {
221
+ chapterIssues.push(makeIssue("error", "metadata", `Invalid file status '${status}'. Allowed: ${runtimeConfig.allowedStatuses.join(", ")}.`, relativePath));
222
+ }
223
+ const groupValues = {};
224
+ for (const level of compileStructureLevels) {
225
+ const groupValue = normalizeGroupingValue(metadata[level.key], relativePath, chapterIssues, level.key);
226
+ if (groupValue) {
227
+ groupValues[level.key] = groupValue;
228
+ }
229
+ if (level.titleKey) {
230
+ void normalizeGroupingValue(metadata[level.titleKey], relativePath, chapterIssues, level.titleKey);
231
+ }
232
+ }
233
+ const referenceValidation = extractReferenceKeysByCategory(metadata, relativePath, spineCategories);
234
+ chapterIssues.push(...referenceValidation.issues);
235
+ chapterIssues.push(...validateMarkdownBody(body, chapterPath, repoRoot));
236
+ return {
237
+ path: chapterPath,
238
+ relativePath,
239
+ title,
240
+ order,
241
+ status,
242
+ referenceKeysByCategory: referenceValidation.referencesByCategory,
243
+ groupValues,
244
+ metadata,
245
+ body,
246
+ comments,
247
+ issues: chapterIssues
248
+ };
249
+ }
250
+ function normalizeGroupingValue(rawValue, relativePath, issues, key) {
251
+ if (rawValue == null || rawValue === "") {
252
+ return undefined;
253
+ }
254
+ if (Array.isArray(rawValue)) {
255
+ issues.push(makeIssue("error", "metadata", `Metadata '${key}' must be a scalar value.`, relativePath));
256
+ return undefined;
257
+ }
258
+ const normalized = String(rawValue).trim();
259
+ return normalized.length > 0 ? normalized : undefined;
260
+ }
261
+ function deriveEntryTitle(rawTitle, chapterPath) {
262
+ if (typeof rawTitle === "string" && rawTitle.trim()) {
263
+ return rawTitle.trim();
264
+ }
265
+ const basename = path.basename(chapterPath, ".md");
266
+ const withoutPrefix = basename.replace(/^\d+[-_]?/, "");
267
+ const normalized = withoutPrefix.replace(/[-_]+/g, " ").trim();
268
+ if (!normalized) {
269
+ return basename;
270
+ }
271
+ return normalized.replace(/\b\w/g, (letter) => letter.toUpperCase());
272
+ }
273
+ function parseOrderFromFilename(chapterPath, relativePath, issues) {
274
+ const basename = path.basename(chapterPath, ".md");
275
+ const match = basename.match(/^(\d+)[-_]/);
276
+ if (!match) {
277
+ issues.push(makeIssue("error", "ordering", "Filename must start with a numeric prefix followed by '-' or '_' (for example '100-scene.md').", relativePath));
278
+ return null;
279
+ }
280
+ if (match[1].length !== 3) {
281
+ issues.push(makeIssue("warning", "ordering", `Filename prefix '${match[1]}' is valid but non-standard. Use three digits like 100, 200, 300.`, relativePath));
282
+ }
283
+ return Number(match[1]);
284
+ }
285
+ function extractReferenceKeysByCategory(metadata, relativePath, spineCategories) {
286
+ const issues = [];
287
+ const referencesByCategory = {};
288
+ for (const category of spineCategories) {
289
+ const rawValue = metadata[category.key];
290
+ if (rawValue == null || rawValue === "") {
291
+ continue;
292
+ }
293
+ if (!Array.isArray(rawValue)) {
294
+ issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' must be an array of spine entry keys (for example: [\"matthaeus\"]).`, relativePath));
295
+ continue;
296
+ }
297
+ const seen = new Set();
298
+ const values = [];
299
+ for (const entry of rawValue) {
300
+ if (typeof entry !== "string") {
301
+ issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' entries must be strings.`, relativePath));
302
+ continue;
303
+ }
304
+ const normalized = entry.trim();
305
+ if (!normalized) {
306
+ issues.push(makeIssue("error", "metadata", `Metadata '${category.key}' contains an empty entry key.`, relativePath));
307
+ continue;
308
+ }
309
+ if (seen.has(normalized)) {
310
+ continue;
311
+ }
312
+ seen.add(normalized);
313
+ values.push(normalized);
314
+ }
315
+ referencesByCategory[category.key] = values;
316
+ }
317
+ return { referencesByCategory, issues };
318
+ }
319
+ function parseMetadata(raw, chapterPath, repoRoot, required) {
320
+ const relativePath = path.relative(repoRoot, chapterPath);
321
+ const issues = [];
322
+ if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) {
323
+ const commentsResult = parseStegoCommentsAppendix(raw, relativePath, 1);
324
+ if (!required) {
325
+ return {
326
+ metadata: {},
327
+ body: commentsResult.bodyWithoutComments,
328
+ comments: commentsResult.comments,
329
+ issues: commentsResult.issues
330
+ };
331
+ }
332
+ return {
333
+ metadata: {},
334
+ body: commentsResult.bodyWithoutComments,
335
+ comments: commentsResult.comments,
336
+ issues: [makeIssue("error", "metadata", "Missing metadata block at top of file.", relativePath), ...commentsResult.issues]
337
+ };
338
+ }
339
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
340
+ if (!match) {
341
+ return {
342
+ metadata: {},
343
+ body: raw,
344
+ comments: [],
345
+ issues: [makeIssue("error", "metadata", "Metadata opening delimiter found, but closing delimiter is missing.", relativePath)]
346
+ };
347
+ }
348
+ const metadataText = match[1];
349
+ const body = raw.slice(match[0].length);
350
+ const metadata = {};
351
+ const lines = metadataText.split(/\r?\n/);
352
+ for (let i = 0; i < lines.length; i += 1) {
353
+ const line = lines[i].trim();
354
+ if (!line || line.startsWith("#")) {
355
+ continue;
356
+ }
357
+ const separatorIndex = line.indexOf(":");
358
+ if (separatorIndex === -1) {
359
+ issues.push(makeIssue("error", "metadata", `Invalid metadata line '${line}'. Expected 'key: value' format.`, relativePath, i + 1));
360
+ continue;
361
+ }
362
+ const key = line.slice(0, separatorIndex).trim();
363
+ const value = line.slice(separatorIndex + 1).trim();
364
+ if (!value) {
365
+ let lookahead = i + 1;
366
+ while (lookahead < lines.length) {
367
+ const nextTrimmed = lines[lookahead].trim();
368
+ if (!nextTrimmed || nextTrimmed.startsWith("#")) {
369
+ lookahead += 1;
370
+ continue;
371
+ }
372
+ break;
373
+ }
374
+ if (lookahead < lines.length) {
375
+ const firstValueLine = lines[lookahead];
376
+ const firstValueTrimmed = firstValueLine.trim();
377
+ const firstValueIndent = firstValueLine.length - firstValueLine.trimStart().length;
378
+ if (firstValueIndent > 0 && firstValueTrimmed.startsWith("- ")) {
379
+ const items = [];
380
+ let j = lookahead;
381
+ while (j < lines.length) {
382
+ const candidateRaw = lines[j];
383
+ const candidateTrimmed = candidateRaw.trim();
384
+ if (!candidateTrimmed || candidateTrimmed.startsWith("#")) {
385
+ j += 1;
386
+ continue;
387
+ }
388
+ const indent = candidateRaw.length - candidateRaw.trimStart().length;
389
+ if (indent === 0) {
390
+ break;
391
+ }
392
+ if (!candidateTrimmed.startsWith("- ")) {
393
+ issues.push(makeIssue("error", "metadata", `Unsupported metadata list line '${candidateTrimmed}'. Expected '- value'.`, relativePath, j + 1));
394
+ j += 1;
395
+ continue;
396
+ }
397
+ const itemValue = candidateTrimmed.slice(2).trim().replace(/^['"]|['"]$/g, "");
398
+ items.push(itemValue);
399
+ j += 1;
400
+ }
401
+ metadata[key] = items;
402
+ i = j - 1;
403
+ continue;
404
+ }
405
+ }
406
+ }
407
+ metadata[key] = coerceMetadataValue(value);
408
+ }
409
+ const bodyStartLine = match[0].split(/\r?\n/).length;
410
+ const commentsResult = parseStegoCommentsAppendix(body, relativePath, bodyStartLine);
411
+ issues.push(...commentsResult.issues);
412
+ return {
413
+ metadata,
414
+ body: commentsResult.bodyWithoutComments,
415
+ comments: commentsResult.comments,
416
+ issues
417
+ };
418
+ }
419
+ function parseStegoCommentsAppendix(body, relativePath, bodyStartLine) {
420
+ const parsed = parseCommentAppendix(body);
421
+ const issues = parsed.errors.map((error) => parseCommentIssueFromParserError(error, relativePath, bodyStartLine));
422
+ const comments = parsed.comments.map((comment) => ({
423
+ id: comment.id,
424
+ resolved: comment.status === "resolved",
425
+ thread: comment.thread
426
+ }));
427
+ return {
428
+ bodyWithoutComments: parsed.contentWithoutComments,
429
+ comments,
430
+ issues
431
+ };
432
+ }
433
+ function parseCommentIssueFromParserError(error, relativePath, bodyStartLine) {
434
+ const lineMatch = error.match(/^Line\s+(\d+):\s+([\s\S]+)$/);
435
+ if (!lineMatch) {
436
+ return makeIssue("error", "comments", error, relativePath);
437
+ }
438
+ const relativeLine = Number.parseInt(lineMatch[1], 10);
439
+ const absoluteLine = Number.isFinite(relativeLine) ? bodyStartLine + relativeLine - 1 : undefined;
440
+ return makeIssue("error", "comments", lineMatch[2], relativePath, absoluteLine);
441
+ }
442
+ function coerceMetadataValue(value) {
443
+ if (!value) {
444
+ return "";
445
+ }
446
+ if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
447
+ return value.slice(1, -1);
448
+ }
449
+ if (value.startsWith("[") && value.endsWith("]")) {
450
+ const inner = value.slice(1, -1).trim();
451
+ if (!inner) {
452
+ return [];
453
+ }
454
+ return inner.split(",").map((entry) => entry.trim().replace(/^['\"]|['\"]$/g, ""));
455
+ }
456
+ if (/^-?\d+$/.test(value)) {
457
+ return Number(value);
458
+ }
459
+ if (value === "true") {
460
+ return true;
461
+ }
462
+ if (value === "false") {
463
+ return false;
464
+ }
465
+ return value;
466
+ }
467
+ function validateMarkdownBody(body, chapterPath, repoRoot) {
468
+ const relativePath = path.relative(repoRoot, chapterPath);
469
+ const issues = [];
470
+ const lines = body.split(/\r?\n/);
471
+ let openFence = null;
472
+ let previousHeadingLevel = 0;
473
+ for (let i = 0; i < lines.length; i += 1) {
474
+ const line = lines[i];
475
+ const fenceMatch = line.match(/^(```+|~~~+)/);
476
+ if (fenceMatch) {
477
+ const marker = fenceMatch[1][0];
478
+ const length = fenceMatch[1].length;
479
+ if (!openFence) {
480
+ openFence = { marker, length, line: i + 1 };
481
+ }
482
+ else if (openFence.marker === marker && length >= openFence.length) {
483
+ openFence = null;
484
+ }
485
+ continue;
486
+ }
487
+ const headingMatch = line.match(/^(#{1,6})\s+/);
488
+ if (headingMatch) {
489
+ const level = headingMatch[1].length;
490
+ if (previousHeadingLevel > 0 && level > previousHeadingLevel + 1) {
491
+ issues.push(makeIssue("warning", "style", `Heading level jumps from H${previousHeadingLevel} to H${level}.`, relativePath, i + 1));
492
+ }
493
+ previousHeadingLevel = level;
494
+ }
495
+ if (/\[[^\]]+\]\([^\)]*$/.test(line.trim())) {
496
+ issues.push(makeIssue("error", "structure", "Malformed markdown link, missing closing ')'.", relativePath, i + 1));
497
+ }
498
+ }
499
+ if (openFence) {
500
+ issues.push(makeIssue("error", "structure", `Unclosed code fence opened at line ${openFence.line}.`, relativePath, openFence.line));
501
+ }
502
+ issues.push(...checkLocalMarkdownLinks(body, chapterPath, repoRoot));
503
+ issues.push(...runStyleHeuristics(body, relativePath));
504
+ return issues;
505
+ }
506
+ function checkLocalMarkdownLinks(body, chapterPath, repoRoot) {
507
+ const relativePath = path.relative(repoRoot, chapterPath);
508
+ const issues = [];
509
+ const linkRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
510
+ let match = null;
511
+ while ((match = linkRegex.exec(body)) !== null) {
512
+ let target = match[1].trim();
513
+ if (!target) {
514
+ continue;
515
+ }
516
+ if (target.startsWith("<") && target.endsWith(">")) {
517
+ target = target.slice(1, -1).trim();
518
+ }
519
+ target = target.split(/\s+"/)[0].split(/\s+'/)[0].trim();
520
+ if (isExternalTarget(target) || target.startsWith("#")) {
521
+ continue;
522
+ }
523
+ const cleanTarget = target.split("#")[0];
524
+ if (!cleanTarget) {
525
+ continue;
526
+ }
527
+ const resolved = path.resolve(path.dirname(chapterPath), cleanTarget);
528
+ if (!fs.existsSync(resolved)) {
529
+ issues.push(makeIssue("warning", "links", `Broken local link/image target '${cleanTarget}'.`, relativePath));
530
+ }
531
+ }
532
+ return issues;
533
+ }
534
+ function isExternalTarget(target) {
535
+ return (target.startsWith("http://")
536
+ || target.startsWith("https://")
537
+ || target.startsWith("mailto:")
538
+ || target.startsWith("tel:"));
539
+ }
540
+ function runStyleHeuristics(body, relativePath) {
541
+ const issues = [];
542
+ const prose = body
543
+ .replace(/```[\s\S]*?```/g, "")
544
+ .replace(/~~~[\s\S]*?~~~/g, "");
545
+ const paragraphs = prose
546
+ .split(/\n\s*\n/)
547
+ .map((paragraph) => paragraph.trim())
548
+ .filter((paragraph) => paragraph.length > 0)
549
+ .filter((paragraph) => !paragraph.startsWith("#"))
550
+ .filter((paragraph) => !paragraph.startsWith("- "));
551
+ for (const paragraph of paragraphs) {
552
+ const words = countWords(paragraph);
553
+ if (words > 180) {
554
+ issues.push(makeIssue("warning", "style", `Long paragraph detected (${words} words).`, relativePath));
555
+ }
556
+ const sentences = paragraph.split(/[.!?]+\s+/).map((sentence) => sentence.trim()).filter(Boolean);
557
+ for (const sentence of sentences) {
558
+ const sentenceWords = countWords(sentence);
559
+ if (sentenceWords > 45) {
560
+ issues.push(makeIssue("warning", "style", `Long sentence detected (${sentenceWords} words).`, relativePath));
561
+ }
562
+ }
563
+ }
564
+ return issues;
565
+ }
566
+ function countWords(text) {
567
+ return text.trim().split(/\s+/).filter(Boolean).length;
568
+ }
569
+ function readSpine(project) {
570
+ const catalogEnvelope = readSpineCatalogForProject(project);
571
+ const categories = [];
572
+ const entriesByCategory = new Map();
573
+ for (const category of catalogEnvelope.state.categories) {
574
+ const entries = new Set(category.entries.map((entry) => entry.key));
575
+ categories.push({ key: category.key, entries });
576
+ entriesByCategory.set(category.key, entries);
577
+ }
578
+ const issues = catalogEnvelope.state.issues.map((message) => makeIssue("warning", "continuity", message));
579
+ return { categories, entriesByCategory, issues };
580
+ }
581
+ function findUnknownSpineReferences(referencesByCategory, entriesByCategory, relativePath) {
582
+ const issues = [];
583
+ for (const [categoryKey, values] of Object.entries(referencesByCategory)) {
584
+ const known = entriesByCategory.get(categoryKey);
585
+ if (!known) {
586
+ issues.push(makeIssue("warning", "continuity", `Metadata category '${categoryKey}' has references but no matching spine category directory was found in spine/.`, relativePath));
587
+ continue;
588
+ }
589
+ for (const value of values) {
590
+ if (known.has(value)) {
591
+ continue;
592
+ }
593
+ issues.push(makeIssue("warning", "continuity", `Metadata reference '${categoryKey}: ${value}' does not exist in spine/${categoryKey}/.`, relativePath));
594
+ }
595
+ }
596
+ return issues;
597
+ }
598
+ function makeIssue(level, category, message, file = null, line = null) {
599
+ return { level, category, message, file, line };
600
+ }
601
+ function isPlainObject(value) {
602
+ return typeof value === "object" && value !== null && !Array.isArray(value);
603
+ }