@izkac/forgekit 0.3.1 → 0.3.3
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/package.json +1 -1
- package/src/e2e.mjs +3 -1
- package/src/integrity.mjs +58 -6
- package/src/integrity.test.mjs +88 -0
- package/src/spine.mjs +3 -1
package/package.json
CHANGED
package/src/e2e.mjs
CHANGED
|
@@ -60,7 +60,9 @@ if (!sessionId) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
const { dir, session } = loadSession(sessionId);
|
|
63
|
-
|
|
63
|
+
// init writes: target the live change dir only (never fall back into the
|
|
64
|
+
// archive). run/check/status read: allow the archive fallback.
|
|
65
|
+
const file = e2ePath({ session, sessionDir: dir, forWrite: sub === 'init' });
|
|
64
66
|
|
|
65
67
|
if (sub === 'init') {
|
|
66
68
|
try {
|
package/src/integrity.mjs
CHANGED
|
@@ -58,14 +58,58 @@ function isNonEmptyString(value) {
|
|
|
58
58
|
*
|
|
59
59
|
* @param {{ cwd?: string, session?: Record<string, unknown> | null }} opts
|
|
60
60
|
*/
|
|
61
|
+
/**
|
|
62
|
+
* The archived copy of a change, when the live dir is gone. Archiving moves
|
|
63
|
+
* `changes/<change>/` to `changes/archive/<YYYY-MM-DD>-<change>/`, so the
|
|
64
|
+
* done-gate integrity check (which runs *after* archive in the finish flow)
|
|
65
|
+
* would otherwise never find spine.json/e2e.json. Returns null if no match.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} changesDir absolute path to `<root>/changes`
|
|
68
|
+
* @param {string} change change name
|
|
69
|
+
*/
|
|
70
|
+
function findArchivedChangeDir(changesDir, change) {
|
|
71
|
+
const archiveDir = path.join(changesDir, 'archive');
|
|
72
|
+
if (!fs.existsSync(archiveDir)) return null;
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = fs.readdirSync(archiveDir, { withFileTypes: true });
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const matches = entries
|
|
80
|
+
.filter((e) => e.isDirectory())
|
|
81
|
+
.map((e) => e.name)
|
|
82
|
+
// CLI + documented manual archive both name dirs `YYYY-MM-DD-<change>`;
|
|
83
|
+
// slice(11) drops the `YYYY-MM-DD-` prefix so the suffix must equal the
|
|
84
|
+
// change exactly (no false match on `…-other-<change>`).
|
|
85
|
+
.filter((name) => name === change || (/^\d{4}-\d{2}-\d{2}-/.test(name) && name.slice(11) === change))
|
|
86
|
+
.sort();
|
|
87
|
+
// Date-prefixed names sort lexically by date — newest archive wins.
|
|
88
|
+
return matches.length ? path.join(archiveDir, matches[matches.length - 1]) : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Live change dir if present, else its archived copy, else the live path. */
|
|
92
|
+
function liveOrArchived(changesDir, change) {
|
|
93
|
+
const liveDir = path.join(changesDir, change);
|
|
94
|
+
if (fs.existsSync(liveDir)) return liveDir;
|
|
95
|
+
return findArchivedChangeDir(changesDir, change) ?? liveDir;
|
|
96
|
+
}
|
|
97
|
+
|
|
61
98
|
export function resolveChangeDir(opts = {}) {
|
|
62
99
|
const cwd = opts.cwd ?? process.cwd();
|
|
63
100
|
const session = opts.session ?? null;
|
|
101
|
+
// Write callers (spine/e2e init) must always target the LIVE change dir —
|
|
102
|
+
// scaffolding into an archived record would corrupt frozen history. Only
|
|
103
|
+
// read callers (checks, gates) fall back to the archive.
|
|
104
|
+
const forWrite = opts.forWrite === true;
|
|
64
105
|
const change = session && isNonEmptyString(session.openspecChange) ? session.openspecChange : null;
|
|
65
106
|
if (!change) return null;
|
|
66
107
|
|
|
67
|
-
const
|
|
68
|
-
|
|
108
|
+
const openspecChanges = path.join(cwd, 'openspec', 'changes');
|
|
109
|
+
const openspecDir = path.join(openspecChanges, change);
|
|
110
|
+
if (session.planType === 'openspec') {
|
|
111
|
+
return forWrite ? openspecDir : liveOrArchived(openspecChanges, change);
|
|
112
|
+
}
|
|
69
113
|
|
|
70
114
|
let specsRoot = DEFAULT_SPECS_DIR;
|
|
71
115
|
try {
|
|
@@ -73,13 +117,21 @@ export function resolveChangeDir(opts = {}) {
|
|
|
73
117
|
} catch {
|
|
74
118
|
// keep default
|
|
75
119
|
}
|
|
76
|
-
const
|
|
77
|
-
|
|
120
|
+
const specsChanges = path.join(cwd, specsRoot, 'changes');
|
|
121
|
+
const specsDir = path.join(specsChanges, change);
|
|
122
|
+
if (session.planType === 'specs') {
|
|
123
|
+
return forWrite ? specsDir : liveOrArchived(specsChanges, change);
|
|
124
|
+
}
|
|
78
125
|
|
|
79
|
-
// planType unknown — prefer whichever exists
|
|
126
|
+
// planType unknown — prefer whichever exists (live first, then archived)
|
|
80
127
|
if (fs.existsSync(openspecDir)) return openspecDir;
|
|
81
128
|
if (fs.existsSync(specsDir)) return specsDir;
|
|
82
|
-
return openspecDir;
|
|
129
|
+
if (forWrite) return openspecDir;
|
|
130
|
+
return (
|
|
131
|
+
findArchivedChangeDir(openspecChanges, change) ??
|
|
132
|
+
findArchivedChangeDir(specsChanges, change) ??
|
|
133
|
+
openspecDir
|
|
134
|
+
);
|
|
83
135
|
}
|
|
84
136
|
|
|
85
137
|
/**
|
package/src/integrity.test.mjs
CHANGED
|
@@ -105,6 +105,94 @@ test('resolveChangeDir: openspec plan type and session-dir fallback', () => {
|
|
|
105
105
|
}
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
test('resolveChangeDir: falls back to the archived copy after archive', () => {
|
|
109
|
+
for (const [planType, root] of [
|
|
110
|
+
['openspec', ['openspec', 'changes']],
|
|
111
|
+
['specs', ['specs', 'changes']],
|
|
112
|
+
]) {
|
|
113
|
+
const cwd = tmp('forge-changedir-arch-');
|
|
114
|
+
try {
|
|
115
|
+
const changesDir = path.join(cwd, ...root);
|
|
116
|
+
const liveDir = path.join(changesDir, 'add-customer-registry');
|
|
117
|
+
fs.mkdirSync(liveDir, { recursive: true });
|
|
118
|
+
const session = { planType, openspecChange: 'add-customer-registry' };
|
|
119
|
+
|
|
120
|
+
// Live dir present → live path.
|
|
121
|
+
assert.equal(resolveChangeDir({ cwd, session }), liveDir);
|
|
122
|
+
|
|
123
|
+
// Archive moves it → resolve follows into changes/archive/<date>-<name>.
|
|
124
|
+
const archived = path.join(changesDir, 'archive', '2026-07-20-add-customer-registry');
|
|
125
|
+
fs.mkdirSync(path.dirname(archived), { recursive: true });
|
|
126
|
+
fs.renameSync(liveDir, archived);
|
|
127
|
+
assert.equal(resolveChangeDir({ cwd, session }), archived);
|
|
128
|
+
|
|
129
|
+
// Newest archive wins when a change name recurs.
|
|
130
|
+
const older = path.join(changesDir, 'archive', '2025-01-01-add-customer-registry');
|
|
131
|
+
fs.mkdirSync(older, { recursive: true });
|
|
132
|
+
assert.equal(resolveChangeDir({ cwd, session }), archived);
|
|
133
|
+
|
|
134
|
+
// No false match on a different change that ends with the same words.
|
|
135
|
+
fs.mkdirSync(path.join(changesDir, 'archive', '2026-07-20-extra-add-customer-registry'), {
|
|
136
|
+
recursive: true,
|
|
137
|
+
});
|
|
138
|
+
assert.equal(resolveChangeDir({ cwd, session }), archived);
|
|
139
|
+
|
|
140
|
+
// forWrite (spine/e2e init) never follows into the archive — writes must
|
|
141
|
+
// target the live change dir so scaffolding can't corrupt frozen history.
|
|
142
|
+
assert.equal(resolveChangeDir({ cwd, session, forWrite: true }), liveDir);
|
|
143
|
+
} finally {
|
|
144
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('spinePath/e2ePath forWrite target the live dir even after archive', () => {
|
|
150
|
+
const cwd = tmp('forge-write-guard-');
|
|
151
|
+
try {
|
|
152
|
+
const session = { planType: 'openspec', openspecChange: 'add-customer-registry' };
|
|
153
|
+
const changesDir = path.join(cwd, 'openspec', 'changes');
|
|
154
|
+
// Only the archived copy exists — mimics running init after archive.
|
|
155
|
+
const archived = path.join(changesDir, 'archive', '2026-07-20-add-customer-registry');
|
|
156
|
+
fs.mkdirSync(archived, { recursive: true });
|
|
157
|
+
const liveDir = path.join(changesDir, 'add-customer-registry');
|
|
158
|
+
|
|
159
|
+
// Read path resolves the archive; write path stays on the (absent) live dir.
|
|
160
|
+
assert.equal(spinePath({ cwd, session }), path.join(archived, 'spine.json'));
|
|
161
|
+
assert.equal(spinePath({ cwd, session, forWrite: true }), path.join(liveDir, 'spine.json'));
|
|
162
|
+
assert.equal(e2ePath({ cwd, session }), path.join(archived, 'e2e.json'));
|
|
163
|
+
assert.equal(e2ePath({ cwd, session, forWrite: true }), path.join(liveDir, 'e2e.json'));
|
|
164
|
+
} finally {
|
|
165
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('runIntegrityChecks: passes after archive (spine resolves in archive dir)', () => {
|
|
170
|
+
const cwd = tmp('forge-int-archived-');
|
|
171
|
+
try {
|
|
172
|
+
const sessionDir = makeSessionDir(cwd);
|
|
173
|
+
const session = { planType: 'openspec', openspecChange: 'add-customer-registry', slug: 'add-customer-registry' };
|
|
174
|
+
|
|
175
|
+
// Green while live: spine in the change dir (sync-only notApplicable).
|
|
176
|
+
const liveDir = path.join(cwd, 'openspec', 'changes', 'add-customer-registry');
|
|
177
|
+
fs.mkdirSync(liveDir, { recursive: true });
|
|
178
|
+
fs.writeFileSync(
|
|
179
|
+
path.join(liveDir, 'spine.json'),
|
|
180
|
+
`${JSON.stringify({ rows: [], notApplicable: 'sync HTTP only' }, null, 2)}\n`,
|
|
181
|
+
'utf8',
|
|
182
|
+
);
|
|
183
|
+
assert.equal(runIntegrityChecks({ cwd, sessionDir, session }).ok, true);
|
|
184
|
+
|
|
185
|
+
// Archive the change — the mechanical gate must STILL pass (the bug: it
|
|
186
|
+
// used to look only at the vanished live path and fail).
|
|
187
|
+
const archived = path.join(cwd, 'openspec', 'changes', 'archive', '2026-07-20-add-customer-registry');
|
|
188
|
+
fs.mkdirSync(path.dirname(archived), { recursive: true });
|
|
189
|
+
fs.renameSync(liveDir, archived);
|
|
190
|
+
assert.equal(runIntegrityChecks({ cwd, sessionDir, session }).ok, true);
|
|
191
|
+
} finally {
|
|
192
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
108
196
|
test('deferrals: add, list, resolve lifecycle', () => {
|
|
109
197
|
const dir = tmp('forge-defer-');
|
|
110
198
|
try {
|
package/src/spine.mjs
CHANGED
|
@@ -48,7 +48,9 @@ if (!sessionId) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const { dir, session } = loadSession(sessionId);
|
|
51
|
-
|
|
51
|
+
// init writes: target the live change dir only (never fall back into the
|
|
52
|
+
// archive). check/status read: allow the archive fallback.
|
|
53
|
+
const file = spinePath({ session, sessionDir: dir, forWrite: sub === 'init' });
|
|
52
54
|
|
|
53
55
|
if (sub === 'init') {
|
|
54
56
|
try {
|