@hanna84/mcp-writing 3.14.0 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +3 -2
- package/package.json +4 -1
- package/src/structure/chapter-indexing.js +58 -0
- package/src/structure/epigraph-indexing.js +110 -3
- package/src/structure/structure-diagnostics.js +361 -1
- package/src/structure/structure-export.js +17 -1
- package/src/structure/structure-restore.js +808 -0
- package/src/sync/sync.js +79 -10
- package/src/tools/editing.js +7 -3
- package/src/tools/metadata.js +100 -82
- package/src/tools/review-bundles.js +4 -4
- package/src/tools/search.js +7 -7
- package/src/tools/styleguide.js +2 -2
- package/src/tools/sync.js +40 -6
- package/src/workflows/workflow-catalogue.js +5 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
#### [v3.15.0](https://github.com/hannasdev/mcp-writing/compare/v3.14.1...v3.15.0)
|
|
8
|
+
|
|
9
|
+
- feat(sync): harden structural authority [`#210`](https://github.com/hannasdev/mcp-writing/pull/210)
|
|
10
|
+
|
|
11
|
+
#### [v3.14.1](https://github.com/hannasdev/mcp-writing/compare/v3.14.0...v3.14.1)
|
|
12
|
+
|
|
13
|
+
> 19 May 2026
|
|
14
|
+
|
|
15
|
+
- ci: optimize validation workflow [`#208`](https://github.com/hannasdev/mcp-writing/pull/208)
|
|
16
|
+
- Release 3.14.1 [`995bb83`](https://github.com/hannasdev/mcp-writing/commit/995bb83941d589061d63ecdae764cf81b1503b41)
|
|
17
|
+
|
|
7
18
|
#### [v3.14.0](https://github.com/hannasdev/mcp-writing/compare/v3.13.1...v3.14.0)
|
|
8
19
|
|
|
20
|
+
> 19 May 2026
|
|
21
|
+
|
|
9
22
|
- feat: add deterministic structure exports [`#207`](https://github.com/hannasdev/mcp-writing/pull/207)
|
|
23
|
+
- Release 3.14.0 [`94fc46b`](https://github.com/hannasdev/mcp-writing/commit/94fc46bc98996688479a2bee9fc73415f69c3a86)
|
|
10
24
|
|
|
11
25
|
#### [v3.13.1](https://github.com/hannasdev/mcp-writing/compare/v3.13.0...v3.13.1)
|
|
12
26
|
|
package/README.md
CHANGED
|
@@ -28,7 +28,8 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
|
|
|
28
28
|
|
|
29
29
|
**Current status:**
|
|
30
30
|
- **Core platform complete:** Metadata-first analysis, sidecar-backed metadata maintenance, AI-assisted prose editing with confirmation + git history, review bundles, and Scrivener Direct extraction are all implemented.
|
|
31
|
-
- **
|
|
31
|
+
- **Recently completed:** Structural Authority Hardening tightened remaining structure-authority paths so scene placement and ordering go through explicit structure workflows, ordinary sync reports drift instead of adopting it, and trusted structure exports can be diagnosed and restored explicitly.
|
|
32
|
+
- **Active development:** none selected.
|
|
32
33
|
- **Deferred backlog:** embeddings search.
|
|
33
34
|
- **Ideas and open questions:** tracked separately so future exploration does not distort the active roadmap.
|
|
34
35
|
|
|
@@ -149,7 +150,7 @@ Outcome: subplot structure stays visible and auditable, which reduces dropped th
|
|
|
149
150
|
Goal: keep indexes accurate without manually re-tagging everything.
|
|
150
151
|
|
|
151
152
|
1. After rewriting scenes, call `enrich_scene` to re-derive lightweight metadata from current prose.
|
|
152
|
-
2. Use `update_scene_metadata` for intentional editorial fields (for example, beat, POV,
|
|
153
|
+
2. Use `update_scene_metadata` for intentional editorial fields (for example, beat, POV, status, and tags); use `list_chapters` plus `assign_scene_to_chapter` or `move_scene` for chapter placement and ordering. Numeric chapter filters are compatibility aliases for read scopes, not mutation targets.
|
|
153
154
|
3. Use `search_metadata` and `find_scenes` to verify scenes are discoverable under the expected filters.
|
|
154
155
|
|
|
155
156
|
Outcome: your AI assistant can reliably find the right scenes without drifting from the manuscript.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.15.0",
|
|
4
4
|
"description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
|
|
5
5
|
"homepage": "https://hannasdev.github.io/mcp-writing/",
|
|
6
6
|
"type": "module",
|
|
@@ -74,6 +74,9 @@
|
|
|
74
74
|
"lint": "eslint *.js src/index.js src/core src/review-bundles src/runtime src/setup src/scripts src/structure src/styleguide src/sync src/tools src/workflows src/world",
|
|
75
75
|
"guard:legacy-root-imports": "node src/scripts/check-legacy-root-imports.mjs",
|
|
76
76
|
"docs": "node src/scripts/generate-tool-docs.mjs",
|
|
77
|
+
"check:docs": "npm run docs && git diff --exit-code -- docs/agents/tools.md",
|
|
78
|
+
"check:static": "npm run lint && npm run guard:legacy-root-imports && npm run check:docs",
|
|
79
|
+
"check:pr": "npm run check:static && npm test",
|
|
77
80
|
"lint:metadata": "node src/scripts/lint-metadata.mjs",
|
|
78
81
|
"sync:server-json-version": "node src/scripts/sync-server-json-version.mjs",
|
|
79
82
|
"test:unit": "node --experimental-sqlite --test src/test/unit/*.test.mjs",
|
|
@@ -202,7 +202,15 @@ export function resolveIndexedChapterForFile(db, {
|
|
|
202
202
|
relativePath,
|
|
203
203
|
meta = {},
|
|
204
204
|
chapterStructure,
|
|
205
|
+
managedStructure = false,
|
|
205
206
|
}) {
|
|
207
|
+
const existingScene = meta.scene_id
|
|
208
|
+
? db.prepare(`
|
|
209
|
+
SELECT chapter_id, chapter, chapter_title
|
|
210
|
+
FROM scenes
|
|
211
|
+
WHERE scene_id = ? AND project_id = ?
|
|
212
|
+
`).get(meta.scene_id, projectId)
|
|
213
|
+
: null;
|
|
206
214
|
let chapterId = meta.chapter_id ?? chapterStructure.chapter?.chapter_id ?? null;
|
|
207
215
|
let chapterSortIndex = chapterStructure.chapter?.sort_index ?? meta.chapter ?? null;
|
|
208
216
|
let chapterTitle = chapterStructure.chapter?.title ?? meta.chapter_title ?? (chapterSortIndex != null ? `Chapter ${chapterSortIndex}` : null);
|
|
@@ -213,6 +221,56 @@ export function resolveIndexedChapterForFile(db, {
|
|
|
213
221
|
const explicitSceneChapterId = !chapterStructure.isEpigraph ? meta.chapter_id ?? null : null;
|
|
214
222
|
let explicitSceneCanonicalChapter = null;
|
|
215
223
|
|
|
224
|
+
if (managedStructure && !chapterStructure.isEpigraph) {
|
|
225
|
+
let managedWarning = null;
|
|
226
|
+
if (existingScene?.chapter_id) {
|
|
227
|
+
const canonicalChapter = db.prepare(`
|
|
228
|
+
SELECT chapter_id, sort_index, title
|
|
229
|
+
FROM chapters
|
|
230
|
+
WHERE chapter_id = ? AND project_id = ?
|
|
231
|
+
`).get(existingScene.chapter_id, projectId);
|
|
232
|
+
if (canonicalChapter) {
|
|
233
|
+
chapterId = canonicalChapter.chapter_id;
|
|
234
|
+
chapterSortIndex = canonicalChapter.sort_index ?? null;
|
|
235
|
+
chapterTitle = canonicalChapter.title ?? null;
|
|
236
|
+
const observedChapterId = meta.chapter_id ?? chapterStructure.chapter?.chapter_id ?? null;
|
|
237
|
+
const observedSortIndex = chapterStructure.chapter?.sort_index ?? meta.chapter ?? null;
|
|
238
|
+
const observedTitle = chapterStructure.chapter?.title ?? meta.chapter_title ?? null;
|
|
239
|
+
if (
|
|
240
|
+
(observedChapterId && observedChapterId !== chapterId)
|
|
241
|
+
|| (observedSortIndex != null && observedSortIndex !== chapterSortIndex)
|
|
242
|
+
|| (observedTitle && observedTitle !== chapterTitle)
|
|
243
|
+
) {
|
|
244
|
+
managedWarning = `Managed structure sync ignored file-derived chapter linkage for scene '${meta.scene_id}': ${relativePath}`;
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
chapterId = null;
|
|
248
|
+
chapterSortIndex = null;
|
|
249
|
+
chapterTitle = null;
|
|
250
|
+
managedWarning = `Scene references unknown chapter_id '${existingScene.chapter_id}': ${relativePath}`;
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
const observedChapterId = meta.chapter_id ?? chapterStructure.chapter?.chapter_id ?? null;
|
|
254
|
+
const observedSortIndex = chapterStructure.chapter?.sort_index ?? meta.chapter ?? null;
|
|
255
|
+
const observedTitle = chapterStructure.chapter?.title ?? meta.chapter_title ?? null;
|
|
256
|
+
if (observedChapterId || observedSortIndex != null || observedTitle) {
|
|
257
|
+
managedWarning = `Managed structure sync ignored file-derived chapter linkage for scene '${meta.scene_id}': ${relativePath}`;
|
|
258
|
+
}
|
|
259
|
+
chapterId = null;
|
|
260
|
+
chapterSortIndex = null;
|
|
261
|
+
chapterTitle = null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
chapterId,
|
|
266
|
+
chapterSortIndex,
|
|
267
|
+
chapterTitle,
|
|
268
|
+
chapterSourcePath,
|
|
269
|
+
chapterWarning: managedWarning,
|
|
270
|
+
upsertChapter: null,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
216
274
|
if (explicitSceneChapterId && !chapterStructure.chapter) {
|
|
217
275
|
explicitSceneCanonicalChapter = db.prepare(`
|
|
218
276
|
SELECT chapter_id, sort_index, title
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
1
3
|
export function indexCanonicalEpigraph(db, {
|
|
2
4
|
projectId,
|
|
3
5
|
chapterId,
|
|
@@ -10,8 +12,116 @@ export function indexCanonicalEpigraph(db, {
|
|
|
10
12
|
chapterWarning = null,
|
|
11
13
|
buildProseChecksum,
|
|
12
14
|
buildDefaultEpigraphId,
|
|
15
|
+
managedStructure = false,
|
|
13
16
|
updatedAt = new Date().toISOString(),
|
|
14
17
|
}) {
|
|
18
|
+
const defaultEpigraphId = chapterId
|
|
19
|
+
? buildDefaultEpigraphId({ projectId, chapterId })
|
|
20
|
+
: null;
|
|
21
|
+
const requestedEpigraphId = meta.epigraph_id ?? defaultEpigraphId;
|
|
22
|
+
const epigraphChecksum = buildProseChecksum(prose);
|
|
23
|
+
|
|
24
|
+
if (managedStructure) {
|
|
25
|
+
const existingEpigraphByPath = db.prepare(`
|
|
26
|
+
SELECT epigraph_id, chapter_id, file_path, prose_checksum
|
|
27
|
+
FROM epigraphs
|
|
28
|
+
WHERE project_id = ? AND file_path = ?
|
|
29
|
+
LIMIT 1
|
|
30
|
+
`).get(projectId, file);
|
|
31
|
+
const existingEpigraphById = requestedEpigraphId
|
|
32
|
+
? db.prepare(`
|
|
33
|
+
SELECT e.epigraph_id, e.chapter_id, e.file_path, e.prose_checksum, c.sort_index AS chapter_sort_index
|
|
34
|
+
FROM epigraphs e
|
|
35
|
+
LEFT JOIN chapters c
|
|
36
|
+
ON c.project_id = e.project_id
|
|
37
|
+
AND c.chapter_id = e.chapter_id
|
|
38
|
+
WHERE e.project_id = ? AND e.epigraph_id = ?
|
|
39
|
+
LIMIT 1
|
|
40
|
+
`).get(projectId, requestedEpigraphId)
|
|
41
|
+
: null;
|
|
42
|
+
const existingEpigraphByCompatibleId = existingEpigraphById
|
|
43
|
+
&& (
|
|
44
|
+
chapterSortIndex == null
|
|
45
|
+
|| existingEpigraphById.chapter_sort_index == null
|
|
46
|
+
|| existingEpigraphById.chapter_sort_index === chapterSortIndex
|
|
47
|
+
)
|
|
48
|
+
? existingEpigraphById
|
|
49
|
+
: null;
|
|
50
|
+
const existingEpigraphByChapter = chapterId
|
|
51
|
+
? db.prepare(`
|
|
52
|
+
SELECT epigraph_id, chapter_id, file_path, prose_checksum
|
|
53
|
+
FROM epigraphs
|
|
54
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
55
|
+
ORDER BY epigraph_id
|
|
56
|
+
LIMIT 2
|
|
57
|
+
`).all(projectId, chapterId)
|
|
58
|
+
: [];
|
|
59
|
+
const existingEpigraphByChapterSort = chapterSortIndex != null
|
|
60
|
+
? db.prepare(`
|
|
61
|
+
SELECT e.epigraph_id, e.chapter_id, e.file_path, e.prose_checksum
|
|
62
|
+
FROM epigraphs e
|
|
63
|
+
JOIN chapters c
|
|
64
|
+
ON c.project_id = e.project_id
|
|
65
|
+
AND c.chapter_id = e.chapter_id
|
|
66
|
+
WHERE e.project_id = ? AND c.sort_index = ?
|
|
67
|
+
ORDER BY e.epigraph_id
|
|
68
|
+
LIMIT 2
|
|
69
|
+
`).all(projectId, chapterSortIndex)
|
|
70
|
+
: [];
|
|
71
|
+
const movedCandidate = (candidate) => candidate && (!candidate.file_path || !fs.existsSync(candidate.file_path))
|
|
72
|
+
? candidate
|
|
73
|
+
: null;
|
|
74
|
+
const existingEpigraph = existingEpigraphByPath
|
|
75
|
+
?? movedCandidate(existingEpigraphByCompatibleId)
|
|
76
|
+
?? (existingEpigraphByChapter.length === 1 ? movedCandidate(existingEpigraphByChapter[0]) : null)
|
|
77
|
+
?? (existingEpigraphByChapterSort.length === 1 ? movedCandidate(existingEpigraphByChapterSort[0]) : null);
|
|
78
|
+
|
|
79
|
+
if (!existingEpigraph) {
|
|
80
|
+
return {
|
|
81
|
+
isStale: 0,
|
|
82
|
+
skippedAsEpigraph: true,
|
|
83
|
+
warning: `Managed structure sync ignored file-derived epigraph linkage: ${relativePath}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const epigraphIsStale = existingEpigraph.prose_checksum !== null && existingEpigraph.prose_checksum !== epigraphChecksum ? 1 : 0;
|
|
88
|
+
db.prepare(`
|
|
89
|
+
UPDATE epigraphs
|
|
90
|
+
SET body = ?,
|
|
91
|
+
file_path = ?,
|
|
92
|
+
prose_checksum = ?,
|
|
93
|
+
metadata_stale = CASE
|
|
94
|
+
WHEN ? != prose_checksum THEN 1
|
|
95
|
+
ELSE metadata_stale
|
|
96
|
+
END,
|
|
97
|
+
updated_at = ?
|
|
98
|
+
WHERE epigraph_id = ? AND project_id = ?
|
|
99
|
+
`).run(
|
|
100
|
+
prose,
|
|
101
|
+
file,
|
|
102
|
+
epigraphChecksum,
|
|
103
|
+
epigraphChecksum,
|
|
104
|
+
updatedAt,
|
|
105
|
+
existingEpigraph.epigraph_id,
|
|
106
|
+
projectId
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
isStale: epigraphIsStale,
|
|
111
|
+
skippedAsEpigraph: true,
|
|
112
|
+
epigraphIndexed: true,
|
|
113
|
+
chapterId: existingEpigraph.chapter_id,
|
|
114
|
+
epigraphId: existingEpigraph.epigraph_id,
|
|
115
|
+
warning: existingEpigraphByPath
|
|
116
|
+
? (requestedEpigraphId && requestedEpigraphId !== existingEpigraph.epigraph_id
|
|
117
|
+
? `Managed structure sync ignored file-derived epigraph_id '${requestedEpigraphId}': ${relativePath}`
|
|
118
|
+
: chapterId && chapterId !== existingEpigraph.chapter_id
|
|
119
|
+
? `Managed structure sync ignored file-derived epigraph linkage: ${relativePath}`
|
|
120
|
+
: null)
|
|
121
|
+
: `Managed structure sync preserved canonical epigraph '${existingEpigraph.epigraph_id}' while refreshing moved file path: ${relativePath}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
15
125
|
const canonicalChapter = chapterId
|
|
16
126
|
? db.prepare(`SELECT chapter_id FROM chapters WHERE chapter_id = ? AND project_id = ?`).get(chapterId, projectId)
|
|
17
127
|
: null;
|
|
@@ -26,9 +136,6 @@ export function indexCanonicalEpigraph(db, {
|
|
|
26
136
|
return { isStale: 0, skippedAsEpigraph: true, warning: reason };
|
|
27
137
|
}
|
|
28
138
|
|
|
29
|
-
const defaultEpigraphId = buildDefaultEpigraphId({ projectId, chapterId });
|
|
30
|
-
const requestedEpigraphId = meta.epigraph_id ?? defaultEpigraphId;
|
|
31
|
-
const epigraphChecksum = buildProseChecksum(prose);
|
|
32
139
|
const epigraphById = db.prepare(`
|
|
33
140
|
SELECT epigraph_id, chapter_id, prose_checksum
|
|
34
141
|
FROM epigraphs
|
|
@@ -2,6 +2,12 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import matter from "gray-matter";
|
|
4
4
|
import yaml from "js-yaml";
|
|
5
|
+
import {
|
|
6
|
+
buildStructureExport,
|
|
7
|
+
computeStructureChecksum,
|
|
8
|
+
defaultStructureExportFileName,
|
|
9
|
+
STRUCTURE_EXPORT_SCHEMA_VERSION,
|
|
10
|
+
} from "./structure-export.js";
|
|
5
11
|
import {
|
|
6
12
|
inferChapterStructureFromPath,
|
|
7
13
|
normalizeSceneMetaForPath,
|
|
@@ -91,6 +97,16 @@ function readIndexedEpigraphRows(db, projectId) {
|
|
|
91
97
|
`).all(...scope.params);
|
|
92
98
|
}
|
|
93
99
|
|
|
100
|
+
function readProjectRows(db, projectId) {
|
|
101
|
+
const scope = projectClause(projectId);
|
|
102
|
+
return db.prepare(`
|
|
103
|
+
SELECT project_id
|
|
104
|
+
FROM projects
|
|
105
|
+
WHERE 1 = 1${scope.sql}
|
|
106
|
+
ORDER BY project_id
|
|
107
|
+
`).all(...scope.params);
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
function diagnoseUnknownChapterLinks(db, diagnostics, projectId) {
|
|
95
111
|
const sceneScope = projectClause(projectId, "s");
|
|
96
112
|
const scenes = db.prepare(`
|
|
@@ -280,7 +296,20 @@ function diagnoseObservedFiles(syncDir, diagnostics, { scenes, epigraphs }) {
|
|
|
280
296
|
}
|
|
281
297
|
|
|
282
298
|
for (const epigraph of epigraphs) {
|
|
283
|
-
if (!epigraph.file_path || !fs.existsSync(epigraph.file_path))
|
|
299
|
+
if (!epigraph.file_path || !fs.existsSync(epigraph.file_path)) {
|
|
300
|
+
addDiagnostic(
|
|
301
|
+
diagnostics,
|
|
302
|
+
"indexed_epigraph_file_missing",
|
|
303
|
+
`Epigraph "${epigraph.epigraph_id}" has an indexed file path that no longer exists.`,
|
|
304
|
+
{
|
|
305
|
+
project_id: epigraph.project_id,
|
|
306
|
+
epigraph_id: epigraph.epigraph_id,
|
|
307
|
+
file_path: epigraph.file_path,
|
|
308
|
+
},
|
|
309
|
+
{ nextStep: "Run sync to refresh moved epigraph file paths, then inspect remaining drift." }
|
|
310
|
+
);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
284
313
|
if (!isPathInsideSyncDir(syncDir, epigraph.file_path)) {
|
|
285
314
|
addDiagnostic(
|
|
286
315
|
diagnostics,
|
|
@@ -334,8 +363,332 @@ function diagnoseObservedFiles(syncDir, diagnostics, { scenes, epigraphs }) {
|
|
|
334
363
|
}
|
|
335
364
|
}
|
|
336
365
|
|
|
366
|
+
function resolveStructureExportPath(syncDir, exportDir, projectId) {
|
|
367
|
+
const resolvedSyncDir = path.resolve(syncDir);
|
|
368
|
+
const resolvedExportDir = exportDir
|
|
369
|
+
? (path.isAbsolute(exportDir) ? path.resolve(exportDir) : path.resolve(resolvedSyncDir, exportDir))
|
|
370
|
+
: path.resolve(resolvedSyncDir, "structure-exports");
|
|
371
|
+
const relativeDir = path.relative(resolvedSyncDir, resolvedExportDir);
|
|
372
|
+
if (relativeDir.startsWith("..") || path.isAbsolute(relativeDir)) {
|
|
373
|
+
throw new Error(`Structure export directory must be inside sync_dir: ${exportDir}`);
|
|
374
|
+
}
|
|
375
|
+
return path.join(resolvedExportDir, defaultStructureExportFileName(projectId));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function readStructureExportFile(filePath) {
|
|
379
|
+
try {
|
|
380
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
381
|
+
} catch (error) {
|
|
382
|
+
return {
|
|
383
|
+
error: error instanceof Error ? error : new Error("Could not read structure export."),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function diagnoseStructureExportFileKind(diagnostics, {
|
|
389
|
+
projectId,
|
|
390
|
+
exportPath,
|
|
391
|
+
}) {
|
|
392
|
+
let exportStat;
|
|
393
|
+
try {
|
|
394
|
+
exportStat = fs.lstatSync(exportPath);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error?.code !== "ENOENT") throw error;
|
|
397
|
+
addDiagnostic(
|
|
398
|
+
diagnostics,
|
|
399
|
+
"structure_export_missing",
|
|
400
|
+
`Project "${projectId}" does not have a generated structure export.`,
|
|
401
|
+
{
|
|
402
|
+
project_id: projectId,
|
|
403
|
+
export_path: exportPath,
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
severity: "info",
|
|
407
|
+
nextStep: "Run export_structure_snapshot before relying on export-based recovery.",
|
|
408
|
+
}
|
|
409
|
+
);
|
|
410
|
+
return "missing";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (exportStat.isSymbolicLink()) {
|
|
414
|
+
addDiagnostic(
|
|
415
|
+
diagnostics,
|
|
416
|
+
"structure_export_symlink",
|
|
417
|
+
`Structure export for project "${projectId}" is a symlink, which is not trusted diagnostics input.`,
|
|
418
|
+
{
|
|
419
|
+
project_id: projectId,
|
|
420
|
+
export_path: exportPath,
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
nextStep: "Use a regular generated structure export file under WRITING_SYNC_DIR.",
|
|
424
|
+
}
|
|
425
|
+
);
|
|
426
|
+
return "symlink";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!exportStat.isFile()) {
|
|
430
|
+
addDiagnostic(
|
|
431
|
+
diagnostics,
|
|
432
|
+
"structure_export_not_regular",
|
|
433
|
+
`Structure export for project "${projectId}" is not a regular file.`,
|
|
434
|
+
{
|
|
435
|
+
project_id: projectId,
|
|
436
|
+
export_path: exportPath,
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
nextStep: "Regenerate the export with export_structure_snapshot before using it for recovery.",
|
|
440
|
+
}
|
|
441
|
+
);
|
|
442
|
+
return "not_regular";
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return "regular";
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function diagnoseStructureExports(db, diagnostics, {
|
|
449
|
+
syncDir,
|
|
450
|
+
exportDir,
|
|
451
|
+
projectId,
|
|
452
|
+
}) {
|
|
453
|
+
if (!syncDir) return [];
|
|
454
|
+
|
|
455
|
+
const exportChecks = [];
|
|
456
|
+
const projects = readProjectRows(db, projectId);
|
|
457
|
+
for (const project of projects) {
|
|
458
|
+
const expectedProjectId = project.project_id;
|
|
459
|
+
let exportPath;
|
|
460
|
+
try {
|
|
461
|
+
exportPath = resolveStructureExportPath(syncDir, exportDir, expectedProjectId);
|
|
462
|
+
} catch {
|
|
463
|
+
addDiagnostic(
|
|
464
|
+
diagnostics,
|
|
465
|
+
"structure_export_invalid_location",
|
|
466
|
+
`Structure export location for project "${expectedProjectId}" is outside the active sync root.`,
|
|
467
|
+
{
|
|
468
|
+
project_id: expectedProjectId,
|
|
469
|
+
export_dir: exportDir,
|
|
470
|
+
sync_dir: syncDir,
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
nextStep: "Use an export directory inside WRITING_SYNC_DIR before trusting generated structure exports.",
|
|
474
|
+
}
|
|
475
|
+
);
|
|
476
|
+
exportChecks.push({
|
|
477
|
+
project_id: expectedProjectId,
|
|
478
|
+
export_path: null,
|
|
479
|
+
trusted: false,
|
|
480
|
+
status: "invalid_location",
|
|
481
|
+
});
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const exportFileKind = diagnoseStructureExportFileKind(diagnostics, {
|
|
486
|
+
projectId: expectedProjectId,
|
|
487
|
+
exportPath,
|
|
488
|
+
});
|
|
489
|
+
if (exportFileKind !== "regular") {
|
|
490
|
+
exportChecks.push({
|
|
491
|
+
project_id: expectedProjectId,
|
|
492
|
+
export_path: exportPath,
|
|
493
|
+
trusted: false,
|
|
494
|
+
status: exportFileKind,
|
|
495
|
+
});
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const parsed = readStructureExportFile(exportPath);
|
|
500
|
+
if (parsed.error) {
|
|
501
|
+
addDiagnostic(
|
|
502
|
+
diagnostics,
|
|
503
|
+
"structure_export_unreadable",
|
|
504
|
+
`Structure export for project "${expectedProjectId}" could not be read as JSON.`,
|
|
505
|
+
{
|
|
506
|
+
project_id: expectedProjectId,
|
|
507
|
+
export_path: exportPath,
|
|
508
|
+
error: parsed.error.message,
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
nextStep: "Regenerate the export with export_structure_snapshot before using it for recovery.",
|
|
512
|
+
}
|
|
513
|
+
);
|
|
514
|
+
exportChecks.push({
|
|
515
|
+
project_id: expectedProjectId,
|
|
516
|
+
export_path: exportPath,
|
|
517
|
+
trusted: false,
|
|
518
|
+
status: "unreadable",
|
|
519
|
+
});
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const exportedProjectId = parsed.project?.project_id ?? parsed.export?.project_id ?? null;
|
|
524
|
+
if (exportedProjectId !== expectedProjectId) {
|
|
525
|
+
addDiagnostic(
|
|
526
|
+
diagnostics,
|
|
527
|
+
"structure_export_project_mismatch",
|
|
528
|
+
`Structure export for project "${expectedProjectId}" belongs to project "${exportedProjectId ?? "unknown"}".`,
|
|
529
|
+
{
|
|
530
|
+
project_id: expectedProjectId,
|
|
531
|
+
export_path: exportPath,
|
|
532
|
+
exported_project_id: exportedProjectId,
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
nextStep: "Regenerate the export for this project before using it for recovery.",
|
|
536
|
+
}
|
|
537
|
+
);
|
|
538
|
+
exportChecks.push({
|
|
539
|
+
project_id: expectedProjectId,
|
|
540
|
+
export_path: exportPath,
|
|
541
|
+
trusted: false,
|
|
542
|
+
status: "wrong_project",
|
|
543
|
+
});
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const exportedSchemaVersion = parsed.export?.schema_version ?? null;
|
|
548
|
+
if (exportedSchemaVersion !== STRUCTURE_EXPORT_SCHEMA_VERSION) {
|
|
549
|
+
addDiagnostic(
|
|
550
|
+
diagnostics,
|
|
551
|
+
"structure_export_incompatible_schema",
|
|
552
|
+
`Structure export for project "${expectedProjectId}" has schema version "${exportedSchemaVersion ?? "unknown"}"; expected "${STRUCTURE_EXPORT_SCHEMA_VERSION}".`,
|
|
553
|
+
{
|
|
554
|
+
project_id: expectedProjectId,
|
|
555
|
+
export_path: exportPath,
|
|
556
|
+
exported_schema_version: exportedSchemaVersion,
|
|
557
|
+
expected_schema_version: STRUCTURE_EXPORT_SCHEMA_VERSION,
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
nextStep: "Regenerate the export with the current server before using it for recovery.",
|
|
561
|
+
}
|
|
562
|
+
);
|
|
563
|
+
exportChecks.push({
|
|
564
|
+
project_id: expectedProjectId,
|
|
565
|
+
export_path: exportPath,
|
|
566
|
+
trusted: false,
|
|
567
|
+
status: "incompatible_schema",
|
|
568
|
+
});
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let built;
|
|
573
|
+
try {
|
|
574
|
+
built = buildStructureExport(db, {
|
|
575
|
+
projectId: expectedProjectId,
|
|
576
|
+
syncDir,
|
|
577
|
+
});
|
|
578
|
+
} catch (error) {
|
|
579
|
+
addDiagnostic(
|
|
580
|
+
diagnostics,
|
|
581
|
+
"structure_export_current_snapshot_failed",
|
|
582
|
+
`Could not build current structure snapshot for project "${expectedProjectId}".`,
|
|
583
|
+
{
|
|
584
|
+
project_id: expectedProjectId,
|
|
585
|
+
export_path: exportPath,
|
|
586
|
+
error_code: "CURRENT_SNAPSHOT_FAILED",
|
|
587
|
+
error_message: error instanceof Error ? error.message : String(error),
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
nextStep: "Repair the canonical project record before trusting structure exports.",
|
|
591
|
+
}
|
|
592
|
+
);
|
|
593
|
+
exportChecks.push({
|
|
594
|
+
project_id: expectedProjectId,
|
|
595
|
+
export_path: exportPath,
|
|
596
|
+
trusted: false,
|
|
597
|
+
status: "current_snapshot_failed",
|
|
598
|
+
});
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
if (!built.ok) {
|
|
602
|
+
addDiagnostic(
|
|
603
|
+
diagnostics,
|
|
604
|
+
"structure_export_current_snapshot_failed",
|
|
605
|
+
`Could not build current structure snapshot for project "${expectedProjectId}".`,
|
|
606
|
+
{
|
|
607
|
+
project_id: expectedProjectId,
|
|
608
|
+
export_path: exportPath,
|
|
609
|
+
error_code: built.error.code,
|
|
610
|
+
error_message: built.error.message,
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
nextStep: "Repair the canonical project record before trusting structure exports.",
|
|
614
|
+
}
|
|
615
|
+
);
|
|
616
|
+
exportChecks.push({
|
|
617
|
+
project_id: expectedProjectId,
|
|
618
|
+
export_path: exportPath,
|
|
619
|
+
trusted: false,
|
|
620
|
+
status: "current_snapshot_failed",
|
|
621
|
+
});
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const exportedChecksum = parsed.export?.structure_checksum ?? null;
|
|
626
|
+
const computedExportChecksum = computeStructureChecksum(parsed);
|
|
627
|
+
if (!exportedChecksum || exportedChecksum !== computedExportChecksum) {
|
|
628
|
+
addDiagnostic(
|
|
629
|
+
diagnostics,
|
|
630
|
+
"structure_export_checksum_mismatch",
|
|
631
|
+
`Structure export for project "${expectedProjectId}" does not match its embedded checksum.`,
|
|
632
|
+
{
|
|
633
|
+
project_id: expectedProjectId,
|
|
634
|
+
export_path: exportPath,
|
|
635
|
+
exported_checksum: exportedChecksum,
|
|
636
|
+
computed_checksum: computedExportChecksum,
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
nextStep: "Regenerate the export with export_structure_snapshot before using it for recovery.",
|
|
640
|
+
}
|
|
641
|
+
);
|
|
642
|
+
exportChecks.push({
|
|
643
|
+
project_id: expectedProjectId,
|
|
644
|
+
export_path: exportPath,
|
|
645
|
+
trusted: false,
|
|
646
|
+
status: "checksum_mismatch",
|
|
647
|
+
});
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const currentChecksum = built.snapshot.export.structure_checksum;
|
|
652
|
+
if (exportedChecksum !== currentChecksum) {
|
|
653
|
+
addDiagnostic(
|
|
654
|
+
diagnostics,
|
|
655
|
+
"structure_export_stale",
|
|
656
|
+
`Structure export for project "${expectedProjectId}" is stale relative to current SQLite canonical state.`,
|
|
657
|
+
{
|
|
658
|
+
project_id: expectedProjectId,
|
|
659
|
+
export_path: exportPath,
|
|
660
|
+
exported_checksum: exportedChecksum,
|
|
661
|
+
current_checksum: currentChecksum,
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
nextStep: "Regenerate the export with export_structure_snapshot, then review the Git diff.",
|
|
665
|
+
}
|
|
666
|
+
);
|
|
667
|
+
exportChecks.push({
|
|
668
|
+
project_id: expectedProjectId,
|
|
669
|
+
export_path: exportPath,
|
|
670
|
+
trusted: false,
|
|
671
|
+
status: "stale",
|
|
672
|
+
});
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
exportChecks.push({
|
|
677
|
+
project_id: expectedProjectId,
|
|
678
|
+
export_path: exportPath,
|
|
679
|
+
trusted: true,
|
|
680
|
+
status: "current",
|
|
681
|
+
schema_version: exportedSchemaVersion,
|
|
682
|
+
structure_checksum: currentChecksum,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return exportChecks;
|
|
687
|
+
}
|
|
688
|
+
|
|
337
689
|
export function runStructureDiagnostics(db, {
|
|
338
690
|
syncDir,
|
|
691
|
+
structureExportDir = null,
|
|
339
692
|
projectId = null,
|
|
340
693
|
} = {}) {
|
|
341
694
|
const diagnostics = [];
|
|
@@ -349,6 +702,12 @@ export function runStructureDiagnostics(db, {
|
|
|
349
702
|
diagnoseObservedFiles(syncDir, diagnostics, { scenes, epigraphs });
|
|
350
703
|
}
|
|
351
704
|
|
|
705
|
+
const structureExports = diagnoseStructureExports(db, diagnostics, {
|
|
706
|
+
syncDir,
|
|
707
|
+
exportDir: structureExportDir,
|
|
708
|
+
projectId,
|
|
709
|
+
});
|
|
710
|
+
|
|
352
711
|
diagnostics.sort((a, b) => {
|
|
353
712
|
const projectCompare = String(a.details.project_id ?? "").localeCompare(String(b.details.project_id ?? ""));
|
|
354
713
|
if (projectCompare) return projectCompare;
|
|
@@ -363,6 +722,7 @@ export function runStructureDiagnostics(db, {
|
|
|
363
722
|
project_id: projectId,
|
|
364
723
|
scenes: scenes.length,
|
|
365
724
|
epigraphs: epigraphs.length,
|
|
725
|
+
structure_exports: structureExports,
|
|
366
726
|
},
|
|
367
727
|
summary: {
|
|
368
728
|
total: diagnostics.length,
|