@izkac/forgekit 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izkac/forgekit",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/integrity.mjs CHANGED
@@ -58,14 +58,52 @@ 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;
64
101
  const change = session && isNonEmptyString(session.openspecChange) ? session.openspecChange : null;
65
102
  if (!change) return null;
66
103
 
67
- const openspecDir = path.join(cwd, 'openspec', 'changes', change);
68
- if (session.planType === 'openspec') return openspecDir;
104
+ const openspecChanges = path.join(cwd, 'openspec', 'changes');
105
+ const openspecDir = path.join(openspecChanges, change);
106
+ if (session.planType === 'openspec') return liveOrArchived(openspecChanges, change);
69
107
 
70
108
  let specsRoot = DEFAULT_SPECS_DIR;
71
109
  try {
@@ -73,13 +111,18 @@ export function resolveChangeDir(opts = {}) {
73
111
  } catch {
74
112
  // keep default
75
113
  }
76
- const specsDir = path.join(cwd, specsRoot, 'changes', change);
77
- if (session.planType === 'specs') return specsDir;
114
+ const specsChanges = path.join(cwd, specsRoot, 'changes');
115
+ const specsDir = path.join(specsChanges, change);
116
+ if (session.planType === 'specs') return liveOrArchived(specsChanges, change);
78
117
 
79
- // planType unknown — prefer whichever exists
118
+ // planType unknown — prefer whichever exists (live first, then archived)
80
119
  if (fs.existsSync(openspecDir)) return openspecDir;
81
120
  if (fs.existsSync(specsDir)) return specsDir;
82
- return openspecDir;
121
+ return (
122
+ findArchivedChangeDir(openspecChanges, change) ??
123
+ findArchivedChangeDir(specsChanges, change) ??
124
+ openspecDir
125
+ );
83
126
  }
84
127
 
85
128
  /**
@@ -105,6 +105,70 @@ 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
+ } finally {
140
+ fs.rmSync(cwd, { recursive: true, force: true });
141
+ }
142
+ }
143
+ });
144
+
145
+ test('runIntegrityChecks: passes after archive (spine resolves in archive dir)', () => {
146
+ const cwd = tmp('forge-int-archived-');
147
+ try {
148
+ const sessionDir = makeSessionDir(cwd);
149
+ const session = { planType: 'openspec', openspecChange: 'add-customer-registry', slug: 'add-customer-registry' };
150
+
151
+ // Green while live: spine in the change dir (sync-only notApplicable).
152
+ const liveDir = path.join(cwd, 'openspec', 'changes', 'add-customer-registry');
153
+ fs.mkdirSync(liveDir, { recursive: true });
154
+ fs.writeFileSync(
155
+ path.join(liveDir, 'spine.json'),
156
+ `${JSON.stringify({ rows: [], notApplicable: 'sync HTTP only' }, null, 2)}\n`,
157
+ 'utf8',
158
+ );
159
+ assert.equal(runIntegrityChecks({ cwd, sessionDir, session }).ok, true);
160
+
161
+ // Archive the change — the mechanical gate must STILL pass (the bug: it
162
+ // used to look only at the vanished live path and fail).
163
+ const archived = path.join(cwd, 'openspec', 'changes', 'archive', '2026-07-20-add-customer-registry');
164
+ fs.mkdirSync(path.dirname(archived), { recursive: true });
165
+ fs.renameSync(liveDir, archived);
166
+ assert.equal(runIntegrityChecks({ cwd, sessionDir, session }).ok, true);
167
+ } finally {
168
+ fs.rmSync(cwd, { recursive: true, force: true });
169
+ }
170
+ });
171
+
108
172
  test('deferrals: add, list, resolve lifecycle', () => {
109
173
  const dir = tmp('forge-defer-');
110
174
  try {