@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izkac/forgekit",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/e2e.mjs CHANGED
@@ -60,7 +60,9 @@ if (!sessionId) {
60
60
  }
61
61
 
62
62
  const { dir, session } = loadSession(sessionId);
63
- const file = e2ePath({ session, sessionDir: dir });
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 openspecDir = path.join(cwd, 'openspec', 'changes', change);
68
- if (session.planType === 'openspec') return openspecDir;
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 specsDir = path.join(cwd, specsRoot, 'changes', change);
77
- if (session.planType === 'specs') return specsDir;
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
  /**
@@ -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
- const file = spinePath({ session, sessionDir: dir });
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 {