open-wiki-spec 0.2.4 → 0.3.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 +54 -0
- package/README.md +50 -3
- package/dist/cli/commands/apply.d.ts.map +1 -1
- package/dist/cli/commands/apply.js +96 -4
- package/dist/cli/commands/apply.js.map +1 -1
- package/dist/cli/commands/archive.d.ts.map +1 -1
- package/dist/cli/commands/archive.js +19 -5
- package/dist/cli/commands/archive.js.map +1 -1
- package/dist/cli/commands/bulk-archive.d.ts +15 -0
- package/dist/cli/commands/bulk-archive.d.ts.map +1 -0
- package/dist/cli/commands/bulk-archive.js +97 -0
- package/dist/cli/commands/bulk-archive.js.map +1 -0
- package/dist/cli/commands/continue.d.ts.map +1 -1
- package/dist/cli/commands/continue.js +15 -2
- package/dist/cli/commands/continue.js.map +1 -1
- package/dist/cli/commands/error-handler.d.ts +1 -5
- package/dist/cli/commands/error-handler.d.ts.map +1 -1
- package/dist/cli/commands/error-handler.js +48 -3
- package/dist/cli/commands/error-handler.js.map +1 -1
- package/dist/cli/commands/init.d.ts +0 -3
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +22 -4
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/list.d.ts +2 -17
- package/dist/cli/commands/list.d.ts.map +1 -1
- package/dist/cli/commands/list.js +15 -3
- package/dist/cli/commands/list.js.map +1 -1
- package/dist/cli/commands/migrate.d.ts.map +1 -1
- package/dist/cli/commands/migrate.js +6 -9
- package/dist/cli/commands/migrate.js.map +1 -1
- package/dist/cli/commands/propose.d.ts.map +1 -1
- package/dist/cli/commands/propose.js +46 -6
- package/dist/cli/commands/propose.js.map +1 -1
- package/dist/cli/commands/query.d.ts.map +1 -1
- package/dist/cli/commands/query.js +99 -4
- package/dist/cli/commands/query.js.map +1 -1
- package/dist/cli/commands/retrieve.d.ts +7 -0
- package/dist/cli/commands/retrieve.d.ts.map +1 -0
- package/dist/cli/commands/retrieve.js +70 -0
- package/dist/cli/commands/retrieve.js.map +1 -0
- package/dist/cli/commands/revert.d.ts +18 -0
- package/dist/cli/commands/revert.d.ts.map +1 -0
- package/dist/cli/commands/revert.js +109 -0
- package/dist/cli/commands/revert.js.map +1 -0
- package/dist/cli/commands/status.d.ts +2 -19
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +42 -3
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/verify.d.ts.map +1 -1
- package/dist/cli/commands/verify.js +12 -1
- package/dist/cli/commands/verify.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +68 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/init-engine.d.ts.map +1 -1
- package/dist/cli/init/init-engine.js +19 -6
- package/dist/cli/init/init-engine.js.map +1 -1
- package/dist/cli/init/meta-files.d.ts +2 -1
- package/dist/cli/init/meta-files.d.ts.map +1 -1
- package/dist/cli/init/meta-files.js +167 -8
- package/dist/cli/init/meta-files.js.map +1 -1
- package/dist/cli/init/skill-generator.d.ts +3 -0
- package/dist/cli/init/skill-generator.d.ts.map +1 -1
- package/dist/cli/init/skill-generator.js +184 -3
- package/dist/cli/init/skill-generator.js.map +1 -1
- package/dist/cli/json-envelope.d.ts +15 -0
- package/dist/cli/json-envelope.d.ts.map +1 -0
- package/dist/cli/json-envelope.js +86 -0
- package/dist/cli/json-envelope.js.map +1 -0
- package/dist/cli/schema-check.d.ts +27 -0
- package/dist/cli/schema-check.d.ts.map +1 -0
- package/dist/cli/schema-check.js +33 -0
- package/dist/cli/schema-check.js.map +1 -0
- package/dist/cli/vault-discovery.d.ts.map +1 -1
- package/dist/cli/vault-discovery.js +16 -2
- package/dist/cli/vault-discovery.js.map +1 -1
- package/dist/core/embedding/cache.d.ts +12 -3
- package/dist/core/embedding/cache.d.ts.map +1 -1
- package/dist/core/embedding/cache.js +42 -5
- package/dist/core/embedding/cache.js.map +1 -1
- package/dist/core/embedding/embedder.d.ts.map +1 -1
- package/dist/core/embedding/embedder.js +73 -6
- package/dist/core/embedding/embedder.js.map +1 -1
- package/dist/core/index/build.d.ts.map +1 -1
- package/dist/core/index/build.js +54 -6
- package/dist/core/index/build.js.map +1 -1
- package/dist/core/index/resolve.d.ts.map +1 -1
- package/dist/core/index/resolve.js +27 -12
- package/dist/core/index/resolve.js.map +1 -1
- package/dist/core/index/scan.d.ts +10 -1
- package/dist/core/index/scan.d.ts.map +1 -1
- package/dist/core/index/scan.js +59 -10
- package/dist/core/index/scan.js.map +1 -1
- package/dist/core/index/schema-version.d.ts +28 -0
- package/dist/core/index/schema-version.d.ts.map +1 -1
- package/dist/core/index/schema-version.js +47 -0
- package/dist/core/index/schema-version.js.map +1 -1
- package/dist/core/migrate/change-converter.d.ts.map +1 -1
- package/dist/core/migrate/change-converter.js +52 -5
- package/dist/core/migrate/change-converter.js.map +1 -1
- package/dist/core/migrate/migrate.d.ts.map +1 -1
- package/dist/core/migrate/migrate.js +115 -1
- package/dist/core/migrate/migrate.js.map +1 -1
- package/dist/core/migrate/spec-converter.js +5 -0
- package/dist/core/migrate/spec-converter.js.map +1 -1
- package/dist/core/migrate/types.d.ts +9 -0
- package/dist/core/migrate/types.d.ts.map +1 -1
- package/dist/core/parser/frontmatter-parser.d.ts +0 -6
- package/dist/core/parser/frontmatter-parser.d.ts.map +1 -1
- package/dist/core/parser/frontmatter-parser.js +59 -2
- package/dist/core/parser/frontmatter-parser.js.map +1 -1
- package/dist/core/parser/requirement-parser.js +4 -0
- package/dist/core/parser/requirement-parser.js.map +1 -1
- package/dist/core/parser/task-parser.d.ts.map +1 -1
- package/dist/core/parser/task-parser.js +19 -0
- package/dist/core/parser/task-parser.js.map +1 -1
- package/dist/core/parser/wikilink-parser.d.ts.map +1 -1
- package/dist/core/parser/wikilink-parser.js +21 -5
- package/dist/core/parser/wikilink-parser.js.map +1 -1
- package/dist/core/retrieval/classify.d.ts +11 -4
- package/dist/core/retrieval/classify.d.ts.map +1 -1
- package/dist/core/retrieval/classify.js +76 -14
- package/dist/core/retrieval/classify.js.map +1 -1
- package/dist/core/retrieval/lexical.d.ts.map +1 -1
- package/dist/core/retrieval/lexical.js +31 -12
- package/dist/core/retrieval/lexical.js.map +1 -1
- package/dist/core/retrieval/retrieve.d.ts.map +1 -1
- package/dist/core/retrieval/retrieve.js +45 -5
- package/dist/core/retrieval/retrieve.js.map +1 -1
- package/dist/core/retrieval/scoring.d.ts.map +1 -1
- package/dist/core/retrieval/scoring.js +37 -12
- package/dist/core/retrieval/scoring.js.map +1 -1
- package/dist/core/schema/change.schema.d.ts.map +1 -1
- package/dist/core/schema/change.schema.js +6 -1
- package/dist/core/schema/change.schema.js.map +1 -1
- package/dist/core/schema/feature.schema.d.ts +1 -1
- package/dist/core/schema/feature.schema.d.ts.map +1 -1
- package/dist/core/schema/feature.schema.js +1 -0
- package/dist/core/schema/feature.schema.js.map +1 -1
- package/dist/core/schema/frontmatter.d.ts +15 -0
- package/dist/core/schema/frontmatter.d.ts.map +1 -1
- package/dist/core/schema/index.d.ts +15 -0
- package/dist/core/schema/index.d.ts.map +1 -1
- package/dist/core/schema/query.schema.d.ts +15 -0
- package/dist/core/schema/query.schema.d.ts.map +1 -1
- package/dist/core/schema/query.schema.js +5 -0
- package/dist/core/schema/query.schema.js.map +1 -1
- package/dist/core/schema/templates.d.ts +0 -4
- package/dist/core/schema/templates.d.ts.map +1 -1
- package/dist/core/schema/templates.js +25 -7
- package/dist/core/schema/templates.js.map +1 -1
- package/dist/core/sequencing/analyze.d.ts.map +1 -1
- package/dist/core/sequencing/analyze.js +3 -1
- package/dist/core/sequencing/analyze.js.map +1 -1
- package/dist/core/sequencing/priority-queue.d.ts +9 -3
- package/dist/core/sequencing/priority-queue.d.ts.map +1 -1
- package/dist/core/sequencing/priority-queue.js +10 -4
- package/dist/core/sequencing/priority-queue.js.map +1 -1
- package/dist/core/sequencing/stale-detector.d.ts.map +1 -1
- package/dist/core/sequencing/stale-detector.js +5 -0
- package/dist/core/sequencing/stale-detector.js.map +1 -1
- package/dist/core/workflow/apply/apply.d.ts.map +1 -1
- package/dist/core/workflow/apply/apply.js +429 -25
- package/dist/core/workflow/apply/apply.js.map +1 -1
- package/dist/core/workflow/apply/delta-parser.d.ts.map +1 -1
- package/dist/core/workflow/apply/delta-parser.js +43 -9
- package/dist/core/workflow/apply/delta-parser.js.map +1 -1
- package/dist/core/workflow/apply/feature-updater.d.ts.map +1 -1
- package/dist/core/workflow/apply/feature-updater.js +27 -11
- package/dist/core/workflow/apply/feature-updater.js.map +1 -1
- package/dist/core/workflow/apply/types.d.ts +9 -0
- package/dist/core/workflow/apply/types.d.ts.map +1 -1
- package/dist/core/workflow/continue/continue.d.ts +18 -0
- package/dist/core/workflow/continue/continue.d.ts.map +1 -1
- package/dist/core/workflow/continue/continue.js +134 -15
- package/dist/core/workflow/continue/continue.js.map +1 -1
- package/dist/core/workflow/continue/next-action.d.ts.map +1 -1
- package/dist/core/workflow/continue/next-action.js +16 -3
- package/dist/core/workflow/continue/next-action.js.map +1 -1
- package/dist/core/workflow/continue/types.d.ts +12 -0
- package/dist/core/workflow/continue/types.d.ts.map +1 -1
- package/dist/core/workflow/propose/note-creator.d.ts +2 -2
- package/dist/core/workflow/propose/note-creator.d.ts.map +1 -1
- package/dist/core/workflow/propose/note-creator.js +89 -12
- package/dist/core/workflow/propose/note-creator.js.map +1 -1
- package/dist/core/workflow/propose/propose.d.ts.map +1 -1
- package/dist/core/workflow/propose/propose.js +189 -47
- package/dist/core/workflow/propose/propose.js.map +1 -1
- package/dist/core/workflow/propose/query-normalizer.d.ts.map +1 -1
- package/dist/core/workflow/propose/query-normalizer.js +37 -5
- package/dist/core/workflow/propose/query-normalizer.js.map +1 -1
- package/dist/core/workflow/propose/types.d.ts +15 -0
- package/dist/core/workflow/propose/types.d.ts.map +1 -1
- package/dist/core/workflow/propose/types.js +6 -1
- package/dist/core/workflow/propose/types.js.map +1 -1
- package/dist/core/workflow/query/query-note-creator.js +4 -0
- package/dist/core/workflow/query/query-note-creator.js.map +1 -1
- package/dist/core/workflow/verify/coherence.d.ts +10 -0
- package/dist/core/workflow/verify/coherence.d.ts.map +1 -1
- package/dist/core/workflow/verify/coherence.js +91 -4
- package/dist/core/workflow/verify/coherence.js.map +1 -1
- package/dist/core/workflow/verify/completeness.d.ts +1 -1
- package/dist/core/workflow/verify/completeness.d.ts.map +1 -1
- package/dist/core/workflow/verify/completeness.js +53 -10
- package/dist/core/workflow/verify/completeness.js.map +1 -1
- package/dist/core/workflow/verify/correctness.d.ts +1 -1
- package/dist/core/workflow/verify/correctness.d.ts.map +1 -1
- package/dist/core/workflow/verify/correctness.js +95 -14
- package/dist/core/workflow/verify/correctness.js.map +1 -1
- package/dist/core/workflow/verify/vault-integrity.d.ts +11 -0
- package/dist/core/workflow/verify/vault-integrity.d.ts.map +1 -1
- package/dist/core/workflow/verify/vault-integrity.js +80 -3
- package/dist/core/workflow/verify/vault-integrity.js.map +1 -1
- package/dist/core/workflow/verify/verify.d.ts +0 -5
- package/dist/core/workflow/verify/verify.d.ts.map +1 -1
- package/dist/core/workflow/verify/verify.js +54 -3
- package/dist/core/workflow/verify/verify.js.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/types/cli-contracts.d.ts +78 -0
- package/dist/types/cli-contracts.d.ts.map +1 -0
- package/dist/types/cli-contracts.js +2 -0
- package/dist/types/cli-contracts.js.map +1 -0
- package/dist/types/error-codes.d.ts +25 -0
- package/dist/types/error-codes.d.ts.map +1 -0
- package/dist/types/error-codes.js +27 -0
- package/dist/types/error-codes.js.map +1 -0
- package/dist/types/frontmatter.d.ts +9 -2
- package/dist/types/frontmatter.d.ts.map +1 -1
- package/dist/types/next-action.d.ts +21 -0
- package/dist/types/next-action.d.ts.map +1 -1
- package/dist/types/retrieval.d.ts +17 -0
- package/dist/types/retrieval.d.ts.map +1 -1
- package/dist/types/verify.d.ts +58 -1
- package/dist/types/verify.d.ts.map +1 -1
- package/dist/types/verify.js +58 -1
- package/dist/types/verify.js.map +1 -1
- package/dist/utils/conventions.d.ts +9 -0
- package/dist/utils/conventions.d.ts.map +1 -0
- package/dist/utils/conventions.js +22 -0
- package/dist/utils/conventions.js.map +1 -0
- package/dist/utils/id-generator.d.ts.map +1 -1
- package/dist/utils/id-generator.js +8 -0
- package/dist/utils/id-generator.js.map +1 -1
- package/dist/utils/normalize.d.ts +7 -4
- package/dist/utils/normalize.d.ts.map +1 -1
- package/dist/utils/normalize.js +43 -6
- package/dist/utils/normalize.js.map +1 -1
- package/package.json +3 -2
|
@@ -21,6 +21,79 @@ function defaultExclusiveCreateFile(filePath, content) {
|
|
|
21
21
|
fs.closeSync(fd);
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Recover from a prior crash: restore .ows-backup-* files and remove .ows-tmp-* files.
|
|
26
|
+
* Called before acquiring lock to ensure clean state.
|
|
27
|
+
*
|
|
28
|
+
* Returns a list of per-file recovery failures so the caller can decide
|
|
29
|
+
* whether to abort. Previously these were swallowed silently, which could
|
|
30
|
+
* leave the vault in a partially-recovered state where some Feature files
|
|
31
|
+
* were successfully restored from backup but others were not — the next
|
|
32
|
+
* apply would then operate on mixed pre-/post-crash content without the
|
|
33
|
+
* user ever seeing a warning.
|
|
34
|
+
*/
|
|
35
|
+
function recoverFromCrash(vaultRoot, deps) {
|
|
36
|
+
const failures = [];
|
|
37
|
+
const wikiDir = join(vaultRoot, 'wiki');
|
|
38
|
+
try {
|
|
39
|
+
const allFiles = fs.readdirSync(wikiDir, { recursive: true });
|
|
40
|
+
// Step 0: Check for a commit marker. If present, ALL renames
|
|
41
|
+
// succeeded in a prior run but the process crashed before cleanup.
|
|
42
|
+
// Forward-recover: delete backups + commit marker, keep new files.
|
|
43
|
+
// If absent AND backups exist, the renames were partial — rollback
|
|
44
|
+
// by restoring all backups.
|
|
45
|
+
const commitMarker = allFiles.find((f) => String(f).includes('.ows-commit-'));
|
|
46
|
+
if (commitMarker) {
|
|
47
|
+
// Forward recovery: all renames completed → delete backups + marker
|
|
48
|
+
for (const relFile of allFiles) {
|
|
49
|
+
const file = String(relFile);
|
|
50
|
+
const absPath = join(wikiDir, file);
|
|
51
|
+
if (file.includes('.ows-backup-') || file.includes('.ows-tmp-') || file.includes('.ows-commit-')) {
|
|
52
|
+
try {
|
|
53
|
+
if (deps.deleteFile)
|
|
54
|
+
deps.deleteFile(absPath);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
failures.push(`forward-recovery cleanup failed for ${file}: ${err.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return failures;
|
|
62
|
+
}
|
|
63
|
+
// No commit marker → rollback any partial renames
|
|
64
|
+
for (const relFile of allFiles) {
|
|
65
|
+
const file = String(relFile);
|
|
66
|
+
const absPath = join(wikiDir, file);
|
|
67
|
+
if (file.includes('.ows-tmp-')) {
|
|
68
|
+
try {
|
|
69
|
+
if (deps.deleteFile)
|
|
70
|
+
deps.deleteFile(absPath);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
failures.push(`failed to remove stale tmp file ${file}: ${err.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (file.includes('.ows-backup-')) {
|
|
77
|
+
// Rollback: ALWAYS restore backup regardless of whether the
|
|
78
|
+
// original path exists. If the original was overwritten by a
|
|
79
|
+
// partial rename, the backup is the authoritative pre-apply
|
|
80
|
+
// version. This prevents the mixed-state bug where some files
|
|
81
|
+
// have post-apply content and others have pre-apply content.
|
|
82
|
+
const originalPath = absPath.replace(/\.ows-backup-\d+/, '');
|
|
83
|
+
try {
|
|
84
|
+
fs.renameSync(absPath, originalPath);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
failures.push(`failed to restore backup ${file}: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// wiki dir might not exist yet — ignore
|
|
94
|
+
}
|
|
95
|
+
return failures;
|
|
96
|
+
}
|
|
24
97
|
function acquireLock(vaultRoot, deps) {
|
|
25
98
|
const lockPath = join(vaultRoot, 'wiki', LOCK_FILENAME);
|
|
26
99
|
const lockContent = JSON.stringify({ pid: process.pid, timestamp: new Date().toISOString() });
|
|
@@ -128,7 +201,13 @@ export function applyChange(options, index, deps) {
|
|
|
128
201
|
throw new Error(`Cannot apply change with status "${changeRecord.status}". ` +
|
|
129
202
|
'Expected "in_progress". Use \'continue\' to advance through the status lifecycle first.');
|
|
130
203
|
}
|
|
131
|
-
// Check all tasks complete
|
|
204
|
+
// Check all tasks complete. Reject changes with zero tasks — a change with
|
|
205
|
+
// no tasks is an incomplete spec (Tasks is a hard prerequisite per the
|
|
206
|
+
// planned transition), and allowing it would let users apply a Change that
|
|
207
|
+
// was never actually implemented.
|
|
208
|
+
if (parsed.tasks.length === 0) {
|
|
209
|
+
throw new Error('Cannot apply: change has no tasks defined. Add implementation tasks to the ## Tasks section before applying.');
|
|
210
|
+
}
|
|
132
211
|
const uncheckedTasks = parsed.tasks.filter((t) => !t.done);
|
|
133
212
|
if (uncheckedTasks.length > 0) {
|
|
134
213
|
throw new Error(`Cannot apply: ${uncheckedTasks.length} unchecked task(s) remaining. ` +
|
|
@@ -136,11 +215,18 @@ export function applyChange(options, index, deps) {
|
|
|
136
215
|
}
|
|
137
216
|
// 2. Parse Delta Summary
|
|
138
217
|
const resolveWikilink = (target) => {
|
|
139
|
-
//
|
|
218
|
+
// Lookup by id first (highest priority), then title, then alias
|
|
140
219
|
for (const record of index.records.values()) {
|
|
141
|
-
if (record.
|
|
220
|
+
if (record.id === target)
|
|
221
|
+
return record.id;
|
|
222
|
+
}
|
|
223
|
+
for (const record of index.records.values()) {
|
|
224
|
+
if (record.title === target)
|
|
225
|
+
return record.id;
|
|
226
|
+
}
|
|
227
|
+
for (const record of index.records.values()) {
|
|
228
|
+
if (record.aliases.some((a) => a === target))
|
|
142
229
|
return record.id;
|
|
143
|
-
}
|
|
144
230
|
}
|
|
145
231
|
return undefined;
|
|
146
232
|
};
|
|
@@ -153,6 +239,57 @@ export function applyChange(options, index, deps) {
|
|
|
153
239
|
warnings,
|
|
154
240
|
});
|
|
155
241
|
}
|
|
242
|
+
// Hard-fail on any Unparseable line. These warnings are only emitted when
|
|
243
|
+
// a delta line STARTS with ADDED/MODIFIED/REMOVED/RENAMED but fails the
|
|
244
|
+
// full regex (e.g., missing quotes around the requirement name, or the
|
|
245
|
+
// target was written as plain text instead of a `[[wikilink]]`). Such
|
|
246
|
+
// lines are unambiguously intended as delta entries — silently applying
|
|
247
|
+
// only the parseable siblings produces a partial change that is
|
|
248
|
+
// impossible to audit after the fact. Block the whole apply so the user
|
|
249
|
+
// fixes the syntax explicitly.
|
|
250
|
+
const unparseable = deltaPlan.warnings.filter((w) => w.startsWith('Unparseable Delta Summary entry'));
|
|
251
|
+
if (unparseable.length > 0) {
|
|
252
|
+
return makeResult(options, changeRecord, {
|
|
253
|
+
success: false,
|
|
254
|
+
errors: [
|
|
255
|
+
`Delta Summary has ${unparseable.length} unparseable line(s). ` +
|
|
256
|
+
`Fix the syntax — requirement names must be quoted ` +
|
|
257
|
+
`(e.g., \`ADDED requirement "FooBar" to [[Feature: Auth]]\`) ` +
|
|
258
|
+
`and target Features must be wikilinks, not plain text:`,
|
|
259
|
+
...unparseable.map((u) => ` ${u}`),
|
|
260
|
+
],
|
|
261
|
+
warnings,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
// Validate that every Delta Summary entry targets a Feature declared in the
|
|
265
|
+
// change's frontmatter (`feature` or `features`). This prevents a Change that
|
|
266
|
+
// claims `features: [A, B]` from accidentally modifying Feature C.
|
|
267
|
+
const declaredFeatureIds = new Set();
|
|
268
|
+
if (changeRecord.feature)
|
|
269
|
+
declaredFeatureIds.add(changeRecord.feature);
|
|
270
|
+
if (changeRecord.features) {
|
|
271
|
+
for (const f of changeRecord.features)
|
|
272
|
+
declaredFeatureIds.add(f);
|
|
273
|
+
}
|
|
274
|
+
if (declaredFeatureIds.size > 0) {
|
|
275
|
+
const undeclaredTargets = [];
|
|
276
|
+
for (const [noteKey] of deltaPlan.byTargetNote) {
|
|
277
|
+
if (!declaredFeatureIds.has(noteKey)) {
|
|
278
|
+
undeclaredTargets.push(noteKey);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (undeclaredTargets.length > 0) {
|
|
282
|
+
return makeResult(options, changeRecord, {
|
|
283
|
+
success: false,
|
|
284
|
+
errors: [
|
|
285
|
+
`Delta Summary targets Feature(s) not declared in the change's frontmatter: ${undeclaredTargets.join(', ')}. ` +
|
|
286
|
+
`Declared features: ${Array.from(declaredFeatureIds).join(', ')}. ` +
|
|
287
|
+
`Either add the undeclared targets to the change's "features" field, or fix the Delta Summary to point at the declared Features.`,
|
|
288
|
+
],
|
|
289
|
+
warnings,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
156
293
|
// Validate delta conflicts
|
|
157
294
|
const conflictErrors = validateDeltaConflicts(deltaPlan);
|
|
158
295
|
if (conflictErrors.length > 0) {
|
|
@@ -163,10 +300,22 @@ export function applyChange(options, index, deps) {
|
|
|
163
300
|
for (const [noteKey] of deltaPlan.byTargetNote) {
|
|
164
301
|
const noteRecord = index.records.get(noteKey);
|
|
165
302
|
if (!noteRecord) {
|
|
166
|
-
errors.push(`Target note "${noteKey}" not
|
|
303
|
+
errors.push(`Target note "${noteKey}" referenced in Delta Summary does not exist in the vault. ` +
|
|
304
|
+
`Either the Feature was deleted, renamed, or the Change's Delta Summary points to the wrong target. ` +
|
|
305
|
+
`Fix the Delta Summary wikilink or restore the missing note.`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// Resolve path: absolute paths stay as-is, relative paths resolve against vault root
|
|
309
|
+
const resolvedNotePath = resolve(options.vaultRoot, noteRecord.path);
|
|
310
|
+
let noteParsed;
|
|
311
|
+
try {
|
|
312
|
+
noteParsed = deps.parseNote(resolvedNotePath);
|
|
313
|
+
}
|
|
314
|
+
catch (parseErr) {
|
|
315
|
+
errors.push(`Failed to read target note "${noteKey}" at ${noteRecord.path}: ${parseErr.message}. ` +
|
|
316
|
+
`The file may have been deleted or become unreadable since the index was built.`);
|
|
167
317
|
continue;
|
|
168
318
|
}
|
|
169
|
-
const noteParsed = deps.parseNote(resolve(options.vaultRoot, noteRecord.path));
|
|
170
319
|
const reqMap = new Map();
|
|
171
320
|
for (const req of noteParsed.requirements) {
|
|
172
321
|
reqMap.set(req.name, req);
|
|
@@ -190,6 +339,19 @@ export function applyChange(options, index, deps) {
|
|
|
190
339
|
errors: staleReport.staleEntries.map((s) => `STALE: ${s.entry.op} "${s.entry.targetName}" - ${s.reason}`),
|
|
191
340
|
});
|
|
192
341
|
}
|
|
342
|
+
// 5b. If --force-stale was used and there actually was a stale report,
|
|
343
|
+
// record a prominent warning per entry so the audit trail isn't silent.
|
|
344
|
+
// Without this, a forced apply looks identical to a clean apply in the
|
|
345
|
+
// resulting ApplyResult — making it impossible for reviewers to tell
|
|
346
|
+
// after the fact that the stale guard was bypassed on purpose.
|
|
347
|
+
if (staleReport.blocked && options.forceStale && staleReport.staleEntries.length > 0) {
|
|
348
|
+
warnings.push(`--force-stale: bypassed ${staleReport.staleEntries.length} stale base check(s). ` +
|
|
349
|
+
'Applied requirement deltas against a base that has already been modified ' +
|
|
350
|
+
'by another change. Review the result carefully and re-verify the Feature.');
|
|
351
|
+
for (const s of staleReport.staleEntries) {
|
|
352
|
+
warnings.push(` force-stale: ${s.entry.op} "${s.entry.targetName}" - ${s.reason}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
193
355
|
// === PHASE 1: Validate and compute ===
|
|
194
356
|
const featureResults = [];
|
|
195
357
|
const allPostValidations = [];
|
|
@@ -231,10 +393,40 @@ export function applyChange(options, index, deps) {
|
|
|
231
393
|
const agentEntries = reqEntries.filter((e) => AGENT_DRIVEN_OPS.has(e.op));
|
|
232
394
|
const allReqEntries = [...mechEntries, ...agentEntries];
|
|
233
395
|
const resolvedNotePath = resolve(options.vaultRoot, noteRecord.path);
|
|
234
|
-
// Read Feature file content for programmatic editing
|
|
235
|
-
|
|
396
|
+
// Read Feature file content for programmatic editing. The file may
|
|
397
|
+
// have been deleted or replaced between parseNote (earlier in this
|
|
398
|
+
// function) and this read — for example, an Obsidian user moving
|
|
399
|
+
// files while `ows apply` runs. Turn a raw exception into a
|
|
400
|
+
// structured `errors` entry so the caller gets a clean failure
|
|
401
|
+
// report instead of the process aborting mid-apply.
|
|
402
|
+
let featureContent;
|
|
403
|
+
try {
|
|
404
|
+
featureContent = deps.readFile(resolvedNotePath);
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
const code = err.code;
|
|
408
|
+
const reason = code === 'ENOENT'
|
|
409
|
+
? 'file no longer exists (was it moved or deleted mid-apply?)'
|
|
410
|
+
: code === 'EACCES'
|
|
411
|
+
? 'permission denied while reading'
|
|
412
|
+
: err.message;
|
|
413
|
+
errors.push(`Failed to re-read Feature "${noteKey}" at ${noteRecord.path}: ${reason}. ` +
|
|
414
|
+
'Aborting apply before any writes.');
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
236
417
|
const result = applyDeltaToFeature(noteKey, resolvedNotePath, reqMap, allReqEntries, featureContent, options.changeId);
|
|
237
418
|
featureResults.push(result);
|
|
419
|
+
// Hard-fail on any semantic operation failure within a Feature.
|
|
420
|
+
// Without this, a multi-Feature change (features: [A, B]) could
|
|
421
|
+
// see Feature A succeed and Feature B fail at the operation level
|
|
422
|
+
// (e.g., "## Requirements section not found"), yet Phase 2 would
|
|
423
|
+
// still write A and skip B — producing a partial apply. Promoting
|
|
424
|
+
// operation-level failures to the global errors array ensures the
|
|
425
|
+
// entire apply aborts before any writes.
|
|
426
|
+
const failedOps = result.operations.filter((op) => !op.success);
|
|
427
|
+
for (const op of failedOps) {
|
|
428
|
+
errors.push(`Feature "${noteKey}": ${op.entry.op} "${op.entry.targetName}" failed: ${op.error ?? 'unknown error'}`);
|
|
429
|
+
}
|
|
238
430
|
// Collect agent-driven ops (MODIFIED/ADDED need agent to fill content)
|
|
239
431
|
for (const entry of agentEntries) {
|
|
240
432
|
pendingAgentOps.push({
|
|
@@ -307,10 +499,43 @@ export function applyChange(options, index, deps) {
|
|
|
307
499
|
// Uses atomic temp-file pattern: write to .ows-tmp first, then rename all at once.
|
|
308
500
|
let statusTransitioned = false;
|
|
309
501
|
const modifiedFiles = [];
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
502
|
+
// Always skip auto-transition when agent-driven ops remain — even if
|
|
503
|
+
// `noAutoTransition` is false. Previously the default behavior was
|
|
504
|
+
// to still flip the Change to `applied` while leaving `MODIFIED/ADDED`
|
|
505
|
+
// markers unfilled in the Feature, which verify later caught via
|
|
506
|
+
// `UNFILLED_APPLY_MARKER`. That verdict was correct but came AFTER
|
|
507
|
+
// the status transition, leaving the Change in a lying state. Skip
|
|
508
|
+
// the transition up front so the Change stays `in_progress` until
|
|
509
|
+
// the agent actually fills the markers and runs apply again.
|
|
510
|
+
const skipTransition = pendingAgentOps.length > 0;
|
|
511
|
+
if (skipTransition && !options.noAutoTransition) {
|
|
512
|
+
warnings.push(`Auto-transition to "applied" blocked: ${pendingAgentOps.length} agent-driven op(s) still need marker fill. ` +
|
|
513
|
+
'Fill the MODIFIED/ADDED markers in the Feature note, then re-run `ows apply`.');
|
|
514
|
+
}
|
|
313
515
|
if (!options.dryRun) {
|
|
516
|
+
// Recover from prior crash (restore backups, clean temp files).
|
|
517
|
+
// Failures here are not fatal for tmp-file leftovers, but unrestored
|
|
518
|
+
// backups mean the vault is in a half-restored state — surface them
|
|
519
|
+
// as errors so the user can intervene before we clobber more files.
|
|
520
|
+
const recoveryFailures = recoverFromCrash(options.vaultRoot, deps);
|
|
521
|
+
for (const fail of recoveryFailures) {
|
|
522
|
+
if (fail.startsWith('failed to restore backup')) {
|
|
523
|
+
errors.push(`Pre-apply crash recovery could not restore a backup: ${fail}. ` +
|
|
524
|
+
'Manually inspect wiki/ for .ows-backup-* files, restore them, then retry.');
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
warnings.push(`Pre-apply crash recovery warning: ${fail}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (errors.length > 0) {
|
|
531
|
+
return makeResult(options, changeRecord, {
|
|
532
|
+
success: false,
|
|
533
|
+
staleReport,
|
|
534
|
+
featureResults,
|
|
535
|
+
errors,
|
|
536
|
+
warnings,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
314
539
|
// Acquire vault-level lock
|
|
315
540
|
if (!acquireLock(options.vaultRoot, deps)) {
|
|
316
541
|
errors.push('Cannot apply: another apply operation is in progress (wiki/.ows-lock exists)');
|
|
@@ -335,7 +560,12 @@ export function applyChange(options, index, deps) {
|
|
|
335
560
|
const pendingWrites = [];
|
|
336
561
|
for (const featureResult of featureResults) {
|
|
337
562
|
if (featureResult.requiresWrite && featureResult.updatedContent) {
|
|
338
|
-
|
|
563
|
+
// Only append Change Log when actually transitioning to applied
|
|
564
|
+
// (skip when --no-auto-transition with pending agent ops)
|
|
565
|
+
const content = skipTransition
|
|
566
|
+
? featureResult.updatedContent
|
|
567
|
+
: appendChangeLogEntry(featureResult.updatedContent, changeRecord, deltaPlan.byTargetNote.get(featureResult.featureId) ?? []);
|
|
568
|
+
pendingWrites.push({ path: featureResult.featurePath, content });
|
|
339
569
|
}
|
|
340
570
|
}
|
|
341
571
|
// Status transition content — skip if noAutoTransition with pending ops
|
|
@@ -346,7 +576,10 @@ export function applyChange(options, index, deps) {
|
|
|
346
576
|
pendingWrites.push({ path: resolvedChangePath, content: updatedChangeContent });
|
|
347
577
|
// Use unique temp suffix to avoid collisions
|
|
348
578
|
const tmpSuffix = `.ows-tmp-${Date.now()}`;
|
|
349
|
-
// Phase 2a: Write all to temp files
|
|
579
|
+
// Phase 2a: Write all to temp files, preserving the original file
|
|
580
|
+
// mode so permissions survive the write (a 0444 read-only vault
|
|
581
|
+
// stays 0444, preserving the user's intent). When copyFileMode is
|
|
582
|
+
// not injected, we fall through to whatever umask produces.
|
|
350
583
|
const tmpPaths = [];
|
|
351
584
|
let writeFailed = false;
|
|
352
585
|
for (const { path, content } of pendingWrites) {
|
|
@@ -355,6 +588,16 @@ export function applyChange(options, index, deps) {
|
|
|
355
588
|
try {
|
|
356
589
|
deps.writeFile(tmpPath, content);
|
|
357
590
|
tmpPaths.push(tmpPath);
|
|
591
|
+
if (deps.copyFileMode) {
|
|
592
|
+
try {
|
|
593
|
+
deps.copyFileMode(path, tmpPath);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// Mode preservation is best-effort — on platforms where
|
|
597
|
+
// chmod is a no-op (Windows without admin) or the source
|
|
598
|
+
// file was already replaced, just use default permissions.
|
|
599
|
+
}
|
|
600
|
+
}
|
|
358
601
|
}
|
|
359
602
|
catch (err) {
|
|
360
603
|
errors.push(`Failed to write temp file ${tmpPath}: ${err.message}`);
|
|
@@ -387,13 +630,15 @@ export function applyChange(options, index, deps) {
|
|
|
387
630
|
}
|
|
388
631
|
}
|
|
389
632
|
if (backupFailed) {
|
|
390
|
-
// Restore any backups already made
|
|
633
|
+
// Restore any backups already made. Surface any restore failure
|
|
634
|
+
// so the user knows a backup file is orphaned and the original missing.
|
|
391
635
|
for (const { original, backup } of backedUpPaths) {
|
|
392
636
|
try {
|
|
393
637
|
deps.moveFile(backup, original);
|
|
394
638
|
}
|
|
395
|
-
catch {
|
|
396
|
-
|
|
639
|
+
catch (restoreErr) {
|
|
640
|
+
errors.push(`CRITICAL: Failed to restore backup "${backup}" -> "${original}": ${restoreErr.message}. ` +
|
|
641
|
+
`Manual recovery required: move the backup file back to the original path.`);
|
|
397
642
|
}
|
|
398
643
|
}
|
|
399
644
|
// Cleanup temp files
|
|
@@ -429,13 +674,14 @@ export function applyChange(options, index, deps) {
|
|
|
429
674
|
// Best-effort — swallow errors
|
|
430
675
|
}
|
|
431
676
|
}
|
|
432
|
-
// Restore all backups
|
|
677
|
+
// Restore all backups. Surface restore failures so users can recover manually.
|
|
433
678
|
for (const { original, backup } of backedUpPaths) {
|
|
434
679
|
try {
|
|
435
680
|
deps.moveFile(backup, original);
|
|
436
681
|
}
|
|
437
|
-
catch {
|
|
438
|
-
|
|
682
|
+
catch (restoreErr) {
|
|
683
|
+
errors.push(`CRITICAL: Failed to restore backup "${backup}" -> "${original}": ${restoreErr.message}. ` +
|
|
684
|
+
`Manual recovery required.`);
|
|
439
685
|
}
|
|
440
686
|
}
|
|
441
687
|
// Cleanup remaining temp files (the ones not yet renamed)
|
|
@@ -445,17 +691,40 @@ export function applyChange(options, index, deps) {
|
|
|
445
691
|
cleanupTempFiles(remainingTmps, deps);
|
|
446
692
|
}
|
|
447
693
|
else {
|
|
448
|
-
// All
|
|
694
|
+
// All renames succeeded. Write a commit marker so that
|
|
695
|
+
// crash recovery knows this is a complete apply — forward
|
|
696
|
+
// recovery (delete backups) instead of rollback (restore
|
|
697
|
+
// backups). The marker is deleted after backup cleanup.
|
|
698
|
+
const commitMarkerPath = join(options.vaultRoot, 'wiki', `.ows-commit-${Date.now()}`);
|
|
699
|
+
try {
|
|
700
|
+
deps.writeFile(commitMarkerPath, `${options.changeId}|${Date.now()}`);
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
// Commit marker write failure is non-fatal — without it,
|
|
704
|
+
// a crash here would trigger rollback recovery on next run,
|
|
705
|
+
// which is safe (just means the apply would be re-run).
|
|
706
|
+
}
|
|
707
|
+
// All writes succeeded — delete backups. Surface any
|
|
708
|
+
// cleanup failure as a warning.
|
|
449
709
|
for (const { backup } of backedUpPaths) {
|
|
450
710
|
try {
|
|
451
711
|
if (deps.deleteFile) {
|
|
452
712
|
deps.deleteFile(backup);
|
|
453
713
|
}
|
|
454
714
|
}
|
|
455
|
-
catch {
|
|
456
|
-
|
|
715
|
+
catch (err) {
|
|
716
|
+
warnings.push(`Orphan backup: apply succeeded but could not delete ${backup} (${err.message}). ` +
|
|
717
|
+
'Remove the file manually or check wiki/ permissions.');
|
|
457
718
|
}
|
|
458
719
|
}
|
|
720
|
+
// Remove commit marker now that backups are cleaned up
|
|
721
|
+
try {
|
|
722
|
+
if (deps.deleteFile)
|
|
723
|
+
deps.deleteFile(commitMarkerPath);
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
// Non-fatal — marker will be cleaned up on next apply.
|
|
727
|
+
}
|
|
459
728
|
for (const { path } of pendingWrites) {
|
|
460
729
|
if (path !== resolvedChangePath) {
|
|
461
730
|
modifiedFiles.push(path);
|
|
@@ -470,6 +739,17 @@ export function applyChange(options, index, deps) {
|
|
|
470
739
|
releaseLock(options.vaultRoot, deps);
|
|
471
740
|
}
|
|
472
741
|
}
|
|
742
|
+
// Dry-run warning: when dry-run reports success but pendingAgentOps
|
|
743
|
+
// remain, the caller must still fill markers and run a real apply
|
|
744
|
+
// later. Without this note, a CI that runs `--dry-run` then trusts
|
|
745
|
+
// `success: true` would skip the real apply and leave the change
|
|
746
|
+
// stuck in in_progress. Surface it explicitly in warnings so both
|
|
747
|
+
// JSON and human consumers see it.
|
|
748
|
+
if (options.dryRun && pendingAgentOps.length > 0 && errors.length === 0) {
|
|
749
|
+
warnings.push(`Dry-run success is PROVISIONAL: ${pendingAgentOps.length} agent-driven operation(s) ` +
|
|
750
|
+
`still need human/LLM authoring (MODIFIED/ADDED requirement markers). ` +
|
|
751
|
+
'Real apply cannot skip this step — fill the markers then run `ows apply` without --dry-run.');
|
|
752
|
+
}
|
|
473
753
|
return {
|
|
474
754
|
changeId: options.changeId,
|
|
475
755
|
changeName: changeRecord.title,
|
|
@@ -531,9 +811,9 @@ export function archiveChange(options, index, deps) {
|
|
|
531
811
|
function preValidateEntry(entry, requirements) {
|
|
532
812
|
switch (entry.op) {
|
|
533
813
|
case 'ADDED':
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
814
|
+
// If requirement already exists (e.g., from a prior --no-auto-transition run),
|
|
815
|
+
// treat as no-op rather than error — the skeleton was already placed.
|
|
816
|
+
return null;
|
|
537
817
|
case 'MODIFIED':
|
|
538
818
|
return !requirements.has(entry.targetName)
|
|
539
819
|
? `Requirement "${entry.targetName}" not found (MODIFIED requires existence)`
|
|
@@ -566,6 +846,130 @@ function cleanupTempFiles(tmpPaths, deps) {
|
|
|
566
846
|
}
|
|
567
847
|
}
|
|
568
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* Maximum number of rows kept in a Feature note's Change Log table.
|
|
851
|
+
* Older entries are dropped (still preserved in archived Change notes).
|
|
852
|
+
*/
|
|
853
|
+
const CHANGE_LOG_MAX_ROWS = 50;
|
|
854
|
+
/**
|
|
855
|
+
* Trim a Change Log section so only the most recent CHANGE_LOG_MAX_ROWS remain.
|
|
856
|
+
* If rows were dropped, adds a comment marker indicating the trim.
|
|
857
|
+
*/
|
|
858
|
+
function trimChangeLogSection(featureContent) {
|
|
859
|
+
const changeLogMatch = featureContent.match(/^## Change Log\s*$/m);
|
|
860
|
+
if (!changeLogMatch)
|
|
861
|
+
return featureContent;
|
|
862
|
+
const sectionStart = changeLogMatch.index + changeLogMatch[0].length;
|
|
863
|
+
const nextHeadingMatch = featureContent.slice(sectionStart).match(/^## /m);
|
|
864
|
+
const sectionEnd = nextHeadingMatch
|
|
865
|
+
? sectionStart + nextHeadingMatch.index
|
|
866
|
+
: featureContent.length;
|
|
867
|
+
const sectionContent = featureContent.slice(sectionStart, sectionEnd);
|
|
868
|
+
// Find table rows (lines starting with `| ` that are not header/separator)
|
|
869
|
+
const lines = sectionContent.split('\n');
|
|
870
|
+
const rowLines = [];
|
|
871
|
+
let headerDone = false;
|
|
872
|
+
for (let i = 0; i < lines.length; i++) {
|
|
873
|
+
const line = lines[i];
|
|
874
|
+
if (line.match(/^\|[-\s|]+\|\s*$/)) {
|
|
875
|
+
headerDone = true;
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
if (headerDone && line.startsWith('| ') && line.trim().endsWith('|')) {
|
|
879
|
+
rowLines.push({ line, index: i });
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
if (rowLines.length <= CHANGE_LOG_MAX_ROWS)
|
|
883
|
+
return featureContent;
|
|
884
|
+
// Keep the first CHANGE_LOG_MAX_ROWS rows (newest, since we prepend)
|
|
885
|
+
const keepRows = rowLines.slice(0, CHANGE_LOG_MAX_ROWS);
|
|
886
|
+
const dropCount = rowLines.length - CHANGE_LOG_MAX_ROWS;
|
|
887
|
+
const lastKeptIdx = keepRows[keepRows.length - 1].index;
|
|
888
|
+
// Build the trimmed section: up to last kept row + marker + empty line
|
|
889
|
+
const keptSectionLines = lines.slice(0, lastKeptIdx + 1);
|
|
890
|
+
keptSectionLines.push('');
|
|
891
|
+
keptSectionLines.push(`<!-- ${dropCount} older entries trimmed; see wiki/99-archive/ for full history -->`);
|
|
892
|
+
keptSectionLines.push('');
|
|
893
|
+
const newSectionContent = keptSectionLines.join('\n');
|
|
894
|
+
return featureContent.slice(0, sectionStart) + newSectionContent + featureContent.slice(sectionEnd);
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Append a Change Log entry row to a Feature note's ## Change Log section.
|
|
898
|
+
* If the section or table header doesn't exist, creates them.
|
|
899
|
+
* Trims older entries when the table grows beyond CHANGE_LOG_MAX_ROWS.
|
|
900
|
+
*/
|
|
901
|
+
function appendChangeLogEntry(featureContent, changeRecord, deltaEntries) {
|
|
902
|
+
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
|
|
903
|
+
const changeLink = `[[${changeRecord.title}]]`;
|
|
904
|
+
const summary = deltaEntries
|
|
905
|
+
.map((e) => {
|
|
906
|
+
if (e.op === 'RENAMED' && e.newName) {
|
|
907
|
+
return `${e.op} [${e.targetType}] ${e.targetName} → ${e.newName}`;
|
|
908
|
+
}
|
|
909
|
+
return `${e.op} [${e.targetType}] ${e.targetName}`;
|
|
910
|
+
})
|
|
911
|
+
.join(', ');
|
|
912
|
+
const newRow = `| ${date} | ${changeLink} | ${summary} |`;
|
|
913
|
+
// Idempotency: if ANY row containing this change's wikilink already
|
|
914
|
+
// exists in the Change Log section, skip the append. Previously the
|
|
915
|
+
// check required both date AND change link to match — which meant a
|
|
916
|
+
// revert on day 1 followed by re-apply on day 2 would insert a
|
|
917
|
+
// duplicate entry. Matching on the change link alone is safe because
|
|
918
|
+
// each Change has a unique title/id, and a single apply should
|
|
919
|
+
// produce at most one log row per Feature.
|
|
920
|
+
const existingChangeLogMatch = featureContent.match(/^## Change Log\s*$/m);
|
|
921
|
+
if (existingChangeLogMatch) {
|
|
922
|
+
const exStart = existingChangeLogMatch.index + existingChangeLogMatch[0].length;
|
|
923
|
+
const exNextHeading = featureContent.slice(exStart).match(/^## /m);
|
|
924
|
+
const exEnd = exNextHeading ? exStart + exNextHeading.index : featureContent.length;
|
|
925
|
+
const exSection = featureContent.slice(exStart, exEnd);
|
|
926
|
+
// Escape the change link for use in a literal regex test
|
|
927
|
+
const escapedLink = changeLink.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
|
|
928
|
+
const dupRegex = new RegExp(`${escapedLink}`, 'm');
|
|
929
|
+
if (dupRegex.test(exSection)) {
|
|
930
|
+
return featureContent; // Change already logged — skip append.
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
let updated;
|
|
934
|
+
// Check if ## Change Log section exists
|
|
935
|
+
const changeLogMatch = featureContent.match(/^## Change Log\s*$/m);
|
|
936
|
+
if (changeLogMatch) {
|
|
937
|
+
// Extract the Change Log section content (bounded by next ## heading or EOF)
|
|
938
|
+
const sectionStart = changeLogMatch.index + changeLogMatch[0].length;
|
|
939
|
+
const nextHeadingMatch = featureContent.slice(sectionStart).match(/^## /m);
|
|
940
|
+
const sectionEnd = nextHeadingMatch
|
|
941
|
+
? sectionStart + nextHeadingMatch.index
|
|
942
|
+
: featureContent.length;
|
|
943
|
+
const sectionContent = featureContent.slice(sectionStart, sectionEnd);
|
|
944
|
+
// Look for table separator ONLY within this section (handle EOF without trailing newline)
|
|
945
|
+
const sepMatch = sectionContent.match(/(\|[-\s|]+\|)\s*(?:\n|$)/);
|
|
946
|
+
if (sepMatch) {
|
|
947
|
+
// Insert after the separator row within the section
|
|
948
|
+
const sepAbsPos = sectionStart + sepMatch.index + sepMatch[0].length;
|
|
949
|
+
updated = featureContent.slice(0, sepAbsPos) + newRow + '\n' + featureContent.slice(sepAbsPos);
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
// Section exists but no table — insert table + row after heading
|
|
953
|
+
const tableBlock = `\n\n| Date | Change | Summary |\n|------|--------|---------|\n${newRow}\n`;
|
|
954
|
+
updated = featureContent.slice(0, sectionStart) + tableBlock + featureContent.slice(sectionStart);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
// No Change Log section — insert before ## Related Notes (or at end)
|
|
959
|
+
const relatedMatch = featureContent.match(/^## Related Notes\s*$/m);
|
|
960
|
+
const section = `## Change Log\n\n| Date | Change | Summary |\n|------|--------|---------|\n${newRow}\n\n`;
|
|
961
|
+
if (relatedMatch) {
|
|
962
|
+
updated = featureContent.slice(0, relatedMatch.index) + section + featureContent.slice(relatedMatch.index);
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
// Ensure trailing newline before appending
|
|
966
|
+
const base = featureContent.endsWith('\n') ? featureContent : featureContent + '\n';
|
|
967
|
+
updated = base + '\n' + section;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
// Trim older entries to keep the table bounded
|
|
971
|
+
return trimChangeLogSection(updated);
|
|
972
|
+
}
|
|
569
973
|
function makeResult(options, changeRecord, overrides) {
|
|
570
974
|
return {
|
|
571
975
|
changeId: options.changeId,
|