@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 +1 -1
- package/src/integrity.mjs +49 -6
- package/src/integrity.test.mjs +64 -0
package/package.json
CHANGED
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
|
|
68
|
-
|
|
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
|
|
77
|
-
|
|
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
|
|
121
|
+
return (
|
|
122
|
+
findArchivedChangeDir(openspecChanges, change) ??
|
|
123
|
+
findArchivedChangeDir(specsChanges, change) ??
|
|
124
|
+
openspecDir
|
|
125
|
+
);
|
|
83
126
|
}
|
|
84
127
|
|
|
85
128
|
/**
|
package/src/integrity.test.mjs
CHANGED
|
@@ -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 {
|