@peterxiaoyang/superspec 0.1.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/README.md +47 -0
- package/adapters/codex/agents/architect.toml +157 -0
- package/adapters/codex/agents/code-reviewer.toml +175 -0
- package/adapters/codex/agents/critic.toml +114 -0
- package/adapters/codex/agents/test-engineer.toml +163 -0
- package/adapters/codex/agents/verifier.toml +119 -0
- package/adapters/codex/install-map.json +81 -0
- package/bin/launch.js +37 -0
- package/bin/superspec-guard.js +4 -0
- package/bin/superspec-init.js +4 -0
- package/bin/superspec.js +4 -0
- package/dist/src/archive.d.ts +23 -0
- package/dist/src/archive.js +428 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +20 -0
- package/dist/src/cli_args.d.ts +12 -0
- package/dist/src/cli_args.js +146 -0
- package/dist/src/core.d.ts +19 -0
- package/dist/src/core.js +357 -0
- package/dist/src/disclosure.d.ts +35 -0
- package/dist/src/disclosure.js +671 -0
- package/dist/src/evidence.d.ts +28 -0
- package/dist/src/evidence.js +849 -0
- package/dist/src/gates.d.ts +16 -0
- package/dist/src/gates.js +1470 -0
- package/dist/src/git.d.ts +8 -0
- package/dist/src/git.js +112 -0
- package/dist/src/init_cli.d.ts +2 -0
- package/dist/src/init_cli.js +145 -0
- package/dist/src/install_engine.d.ts +54 -0
- package/dist/src/install_engine.js +351 -0
- package/dist/src/invariants.d.ts +16 -0
- package/dist/src/invariants.js +363 -0
- package/dist/src/openspec.d.ts +18 -0
- package/dist/src/openspec.js +157 -0
- package/dist/src/paths.d.ts +22 -0
- package/dist/src/paths.js +203 -0
- package/dist/src/project_init.d.ts +4 -0
- package/dist/src/project_init.js +161 -0
- package/dist/src/state.d.ts +37 -0
- package/dist/src/state.js +464 -0
- package/dist/src/tasks.d.ts +23 -0
- package/dist/src/tasks.js +225 -0
- package/dist/src/util.d.ts +120 -0
- package/dist/src/util.js +442 -0
- package/dist/superspec.d.ts +4 -0
- package/dist/superspec.js +57 -0
- package/dist/superspec_guard.d.ts +4 -0
- package/dist/superspec_guard.js +19 -0
- package/dist/superspec_init.d.ts +2 -0
- package/dist/superspec_init.js +17 -0
- package/package.json +63 -0
- package/schemas/install-manifest.schema.json +80 -0
- package/templates/sidecar/archive-preservation.json +11 -0
- package/templates/sidecar/business-invariants.md +38 -0
- package/templates/sidecar/config.yaml +13 -0
- package/templates/sidecar/discovery.md +24 -0
- package/templates/sidecar/test-contract.md +26 -0
- package/templates/workflow/prompts/architect.md +113 -0
- package/templates/workflow/prompts/code-reviewer.md +141 -0
- package/templates/workflow/prompts/critic.md +80 -0
- package/templates/workflow/prompts/test-engineer.md +130 -0
- package/templates/workflow/prompts/verifier.md +85 -0
- package/templates/workflow/skills/superspec-apply/SKILL.md +72 -0
- package/templates/workflow/skills/superspec-archive/SKILL.md +41 -0
- package/templates/workflow/skills/superspec-explore/SKILL.md +70 -0
- package/templates/workflow/skills/superspec-propose/SKILL.md +79 -0
- package/templates/workflow/skills/superspec-review/SKILL.md +237 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
import { STATE_LOCK_FILENAME, allow, block, now, reason, renderList, repr, runtime, safe_within, sha256_file, sha256_text, toPosix, walkFiles, } from "./util.js";
|
|
4
|
+
import { ensure_state_layout, sidecar_artifacts_dir, superspec_dir } from "./paths.js";
|
|
5
|
+
export function archive_manifest_path(changeRoot) {
|
|
6
|
+
return join(sidecar_artifacts_dir(changeRoot), "archive-preservation.json");
|
|
7
|
+
}
|
|
8
|
+
export function archive_preservation_dir(changeRoot) {
|
|
9
|
+
return join(changeRoot, "superspec-preservation");
|
|
10
|
+
}
|
|
11
|
+
export function archive_preservation_manifest_path(changeRoot) {
|
|
12
|
+
return join(archive_preservation_dir(changeRoot), "manifest.json");
|
|
13
|
+
}
|
|
14
|
+
function archive_staging_root(changeRoot, runId) {
|
|
15
|
+
return join(changeRoot, ".superspec-staging", runId);
|
|
16
|
+
}
|
|
17
|
+
function fsync_dir_best_effort(dirPath) {
|
|
18
|
+
let fd = null;
|
|
19
|
+
try {
|
|
20
|
+
fd = openSync(dirPath, "r");
|
|
21
|
+
fsyncSync(fd);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Directory fsync is best-effort across platforms.
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
if (fd !== null)
|
|
28
|
+
closeSync(fd);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function write_text(path, text) {
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
writeFileSync(path, text, "utf8");
|
|
34
|
+
}
|
|
35
|
+
function excluded_manifest_entry(relPath) {
|
|
36
|
+
if (relPath.endsWith(STATE_LOCK_FILENAME) || relPath.endsWith("superspec-state.tmp"))
|
|
37
|
+
return true;
|
|
38
|
+
if (relPath === ".superspec/artifacts/archive-preservation.json")
|
|
39
|
+
return true;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
function manifest_entries(changeRoot, fileOverrides = {}) {
|
|
43
|
+
const base = superspec_dir(changeRoot);
|
|
44
|
+
const entries = [];
|
|
45
|
+
const pendingOverrides = new Set(Object.keys(fileOverrides));
|
|
46
|
+
if (existsSync(base) && statSync(base).isDirectory()) {
|
|
47
|
+
for (const filePath of walkFiles(base).sort()) {
|
|
48
|
+
const relPath = toPosix(relative(changeRoot, filePath));
|
|
49
|
+
if (excluded_manifest_entry(relPath))
|
|
50
|
+
continue;
|
|
51
|
+
if (pendingOverrides.has(relPath)) {
|
|
52
|
+
entries.push({ path: relPath, sha256: sha256_text(fileOverrides[relPath]) });
|
|
53
|
+
pendingOverrides.delete(relPath);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
entries.push({ path: relPath, sha256: sha256_file(filePath) });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const relPath of [...pendingOverrides].sort()) {
|
|
61
|
+
if (excluded_manifest_entry(relPath))
|
|
62
|
+
continue;
|
|
63
|
+
entries.push({ path: relPath, sha256: sha256_text(fileOverrides[relPath]) });
|
|
64
|
+
}
|
|
65
|
+
return entries.sort((a, b) => String(a.path).localeCompare(String(b.path)));
|
|
66
|
+
}
|
|
67
|
+
export function sidecar_manifest_entries(changeRoot) {
|
|
68
|
+
return manifest_entries(changeRoot);
|
|
69
|
+
}
|
|
70
|
+
function archive_manifest_data(change, changeRoot, fileOverrides = {}) {
|
|
71
|
+
ensure_state_layout(changeRoot);
|
|
72
|
+
return {
|
|
73
|
+
schema_version: 1,
|
|
74
|
+
change_id: change,
|
|
75
|
+
created_at: now(),
|
|
76
|
+
kind: "superspec_archive_preservation",
|
|
77
|
+
entries: manifest_entries(changeRoot, fileOverrides),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function write_manifest_file(filePath, manifest) {
|
|
81
|
+
write_text(filePath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
82
|
+
return filePath;
|
|
83
|
+
}
|
|
84
|
+
export function write_archive_manifest(change, changeRoot) {
|
|
85
|
+
return write_manifest_file(archive_manifest_path(changeRoot), archive_manifest_data(change, changeRoot));
|
|
86
|
+
}
|
|
87
|
+
function stage_archive_bundle(change, changeRoot, runId, fileOverrides) {
|
|
88
|
+
const stagingRoot = archive_staging_root(changeRoot, runId);
|
|
89
|
+
const stagedManifestPath = join(stagingRoot, "next", "archive-preservation.json");
|
|
90
|
+
const stagedBundleDir = join(stagingRoot, "next", "superspec-preservation");
|
|
91
|
+
const stagedFilesRoot = join(stagedBundleDir, "files");
|
|
92
|
+
const manifest = archive_manifest_data(change, changeRoot, fileOverrides);
|
|
93
|
+
write_manifest_file(stagedManifestPath, manifest);
|
|
94
|
+
for (const entry of manifest.entries ?? []) {
|
|
95
|
+
const relPath = entry.path;
|
|
96
|
+
if (typeof relPath !== "string")
|
|
97
|
+
continue;
|
|
98
|
+
const dst = join(stagedFilesRoot, relPath);
|
|
99
|
+
if (relPath in fileOverrides) {
|
|
100
|
+
write_text(dst, fileOverrides[relPath]);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const src = join(changeRoot, relPath);
|
|
104
|
+
if (!existsSync(src) || !statSync(src).isFile())
|
|
105
|
+
continue;
|
|
106
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
107
|
+
copyFileSync(src, dst);
|
|
108
|
+
}
|
|
109
|
+
const bundleManifest = {
|
|
110
|
+
...manifest,
|
|
111
|
+
kind: "superspec_archive_preservation_bundle",
|
|
112
|
+
source_manifest: ".superspec/artifacts/archive-preservation.json",
|
|
113
|
+
files_root: "files",
|
|
114
|
+
};
|
|
115
|
+
const stagedBundleManifestPath = join(stagedBundleDir, "manifest.json");
|
|
116
|
+
write_text(stagedBundleManifestPath, `${JSON.stringify(bundleManifest, null, 2)}\n`);
|
|
117
|
+
return { staged_manifest_path: stagedManifestPath, staged_bundle_dir: stagedBundleDir };
|
|
118
|
+
}
|
|
119
|
+
function restore_file_from_backup(finalPath, backupPath, hadFinal) {
|
|
120
|
+
if (existsSync(backupPath)) {
|
|
121
|
+
if (existsSync(finalPath))
|
|
122
|
+
rmSync(finalPath, { force: true });
|
|
123
|
+
mkdirSync(dirname(finalPath), { recursive: true });
|
|
124
|
+
renameSync(backupPath, finalPath);
|
|
125
|
+
}
|
|
126
|
+
else if (!hadFinal) {
|
|
127
|
+
if (existsSync(finalPath))
|
|
128
|
+
rmSync(finalPath, { force: true });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
throw new Error(`backup missing for ${finalPath}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function restore_dir_from_backup(finalPath, backupPath, hadFinal) {
|
|
135
|
+
if (existsSync(backupPath)) {
|
|
136
|
+
if (existsSync(finalPath))
|
|
137
|
+
rmSync(finalPath, { recursive: true, force: true });
|
|
138
|
+
renameSync(backupPath, finalPath);
|
|
139
|
+
}
|
|
140
|
+
else if (!hadFinal) {
|
|
141
|
+
if (existsSync(finalPath))
|
|
142
|
+
rmSync(finalPath, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
throw new Error(`backup missing for ${finalPath}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function restore_promoted_bundle(state) {
|
|
149
|
+
restore_file_from_backup(state.final_manifest, state.backup_manifest, state.had_final_manifest);
|
|
150
|
+
restore_dir_from_backup(state.final_bundle_dir, state.backup_bundle_dir, state.had_final_bundle);
|
|
151
|
+
}
|
|
152
|
+
function promote_archive_bundle(changeRoot, runId, stagedManifestPath, stagedBundleDir) {
|
|
153
|
+
const finalManifest = archive_manifest_path(changeRoot);
|
|
154
|
+
const finalBundleDir = archive_preservation_dir(changeRoot);
|
|
155
|
+
const stagingRoot = archive_staging_root(changeRoot, runId);
|
|
156
|
+
const backupRoot = join(stagingRoot, "backup");
|
|
157
|
+
const backupManifest = join(backupRoot, "archive-preservation.json");
|
|
158
|
+
const backupBundleDir = join(backupRoot, "superspec-preservation");
|
|
159
|
+
const hadFinalManifest = existsSync(finalManifest);
|
|
160
|
+
const hadFinalBundle = existsSync(finalBundleDir);
|
|
161
|
+
const state = {
|
|
162
|
+
staging_root: stagingRoot,
|
|
163
|
+
final_manifest: finalManifest,
|
|
164
|
+
final_bundle_dir: finalBundleDir,
|
|
165
|
+
backup_manifest: backupManifest,
|
|
166
|
+
backup_bundle_dir: backupBundleDir,
|
|
167
|
+
had_final_manifest: hadFinalManifest,
|
|
168
|
+
had_final_bundle: hadFinalBundle,
|
|
169
|
+
};
|
|
170
|
+
mkdirSync(backupRoot, { recursive: true });
|
|
171
|
+
try {
|
|
172
|
+
if (hadFinalBundle) {
|
|
173
|
+
renameSync(finalBundleDir, backupBundleDir);
|
|
174
|
+
fsync_dir_best_effort(changeRoot);
|
|
175
|
+
}
|
|
176
|
+
if (hadFinalManifest) {
|
|
177
|
+
mkdirSync(dirname(backupManifest), { recursive: true });
|
|
178
|
+
renameSync(finalManifest, backupManifest);
|
|
179
|
+
fsync_dir_best_effort(dirname(finalManifest));
|
|
180
|
+
}
|
|
181
|
+
renameSync(stagedBundleDir, finalBundleDir);
|
|
182
|
+
fsync_dir_best_effort(changeRoot);
|
|
183
|
+
if (typeof runtime.after_archive_bundle_promote === "function") {
|
|
184
|
+
runtime.after_archive_bundle_promote({
|
|
185
|
+
change_root: changeRoot,
|
|
186
|
+
final_bundle_dir: finalBundleDir,
|
|
187
|
+
final_manifest: finalManifest,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
mkdirSync(dirname(finalManifest), { recursive: true });
|
|
191
|
+
renameSync(stagedManifestPath, finalManifest);
|
|
192
|
+
fsync_dir_best_effort(dirname(finalManifest));
|
|
193
|
+
return state;
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
try {
|
|
197
|
+
restore_promoted_bundle(state);
|
|
198
|
+
rmSync(stagingRoot, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
catch (restoreErr) {
|
|
201
|
+
throw new Error(`archive preservation promote failed: ${err.message}; rollback failed: ${restoreErr.message}`);
|
|
202
|
+
}
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export function begin_archive_preservation_bundle(change, changeRoot, opts = {}) {
|
|
207
|
+
const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
208
|
+
const staged = stage_archive_bundle(change, changeRoot, runId, opts.file_overrides ?? {});
|
|
209
|
+
const promotion = promote_archive_bundle(changeRoot, runId, staged.staged_manifest_path, staged.staged_bundle_dir);
|
|
210
|
+
let settled = false;
|
|
211
|
+
return {
|
|
212
|
+
manifest_path: archive_manifest_path(changeRoot),
|
|
213
|
+
bundle_manifest_path: archive_preservation_manifest_path(changeRoot),
|
|
214
|
+
commit: () => {
|
|
215
|
+
if (settled)
|
|
216
|
+
return;
|
|
217
|
+
settled = true;
|
|
218
|
+
try {
|
|
219
|
+
rmSync(promotion.staging_root, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// Commit is already durable; staging cleanup is best-effort.
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
rollback: () => {
|
|
226
|
+
if (settled)
|
|
227
|
+
return;
|
|
228
|
+
restore_promoted_bundle(promotion);
|
|
229
|
+
rmSync(promotion.staging_root, { recursive: true, force: true });
|
|
230
|
+
settled = true;
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
export function write_archive_preservation_bundle(change, changeRoot, opts = {}) {
|
|
235
|
+
const txn = begin_archive_preservation_bundle(change, changeRoot, opts);
|
|
236
|
+
txn.commit();
|
|
237
|
+
return txn.bundle_manifest_path;
|
|
238
|
+
}
|
|
239
|
+
export function find_archived_change(repoRoot, change) {
|
|
240
|
+
const archiveRoot = join(repoRoot, "openspec", "changes", "archive");
|
|
241
|
+
if (!existsSync(archiveRoot) || !lstatSync(archiveRoot).isDirectory())
|
|
242
|
+
return null;
|
|
243
|
+
const candidates = readdirSync(archiveRoot)
|
|
244
|
+
.filter((name) => name === change || (/^\d{4}-\d{2}-\d{2}-.+/.test(name) && name.slice(11) === change))
|
|
245
|
+
.map((name) => join(archiveRoot, name))
|
|
246
|
+
.filter((item) => {
|
|
247
|
+
const stat = lstatSync(item);
|
|
248
|
+
return !stat.isSymbolicLink() && stat.isDirectory();
|
|
249
|
+
})
|
|
250
|
+
.sort();
|
|
251
|
+
return candidates.at(-1) ?? null;
|
|
252
|
+
}
|
|
253
|
+
function manifest_change_id_reasons(change, manifest, label) {
|
|
254
|
+
if (manifest.change_id !== change) {
|
|
255
|
+
return [reason("archive_manifest_mismatch", `${label} manifest change_id=${repr(manifest.change_id)} does not match ${repr(change)}`)];
|
|
256
|
+
}
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
const PRIMARY_ARCHIVE_MANIFEST_KIND = "superspec_archive_preservation";
|
|
260
|
+
const FALLBACK_ARCHIVE_MANIFEST_KIND = "superspec_archive_preservation_bundle";
|
|
261
|
+
const REQUIRED_ARCHIVE_ENTRIES = [".superspec/ledger.jsonl", ".superspec/superspec-state.json"];
|
|
262
|
+
function manifest_shape_reasons(change, manifest, label, expectedKind) {
|
|
263
|
+
const reasons = manifest_change_id_reasons(change, manifest, label);
|
|
264
|
+
if (manifest.kind !== expectedKind) {
|
|
265
|
+
reasons.push(reason("archive_manifest_mismatch", `${label} manifest kind=${repr(manifest.kind)} does not match ${repr(expectedKind)}`));
|
|
266
|
+
}
|
|
267
|
+
if (!Array.isArray(manifest.entries) || manifest.entries.length === 0) {
|
|
268
|
+
reasons.push(reason("archive_manifest_mismatch", `${label} manifest entries must be a non-empty list`));
|
|
269
|
+
return reasons;
|
|
270
|
+
}
|
|
271
|
+
const paths = new Set();
|
|
272
|
+
for (const entry of manifest.entries) {
|
|
273
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
274
|
+
reasons.push(reason("archive_manifest_mismatch", `${label} manifest entries must be objects`));
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (typeof entry.path === "string")
|
|
278
|
+
paths.add(entry.path);
|
|
279
|
+
}
|
|
280
|
+
const missingRequired = REQUIRED_ARCHIVE_ENTRIES.filter((entry) => !paths.has(entry));
|
|
281
|
+
if (missingRequired.length > 0) {
|
|
282
|
+
reasons.push(reason("archive_manifest_mismatch", `${label} manifest missing required entries: ${renderList([...missingRequired])}`, [...missingRequired]));
|
|
283
|
+
}
|
|
284
|
+
return reasons;
|
|
285
|
+
}
|
|
286
|
+
function manifest_entry_target(baseRoot, relPath) {
|
|
287
|
+
if (!relPath.startsWith(".superspec/"))
|
|
288
|
+
return null;
|
|
289
|
+
return safe_within(baseRoot, relPath);
|
|
290
|
+
}
|
|
291
|
+
function preserved_file_sha(filePath) {
|
|
292
|
+
if (!existsSync(filePath))
|
|
293
|
+
return null;
|
|
294
|
+
const stat = lstatSync(filePath);
|
|
295
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink > 1)
|
|
296
|
+
return null;
|
|
297
|
+
return sha256_file(filePath);
|
|
298
|
+
}
|
|
299
|
+
export function check_archived(change, repoRoot) {
|
|
300
|
+
const archived = find_archived_change(repoRoot, change);
|
|
301
|
+
if (archived === null)
|
|
302
|
+
return block(change, "archived", [reason("archive_not_found", `archived change not found for ${change}`)]);
|
|
303
|
+
const manifestPath = archive_manifest_path(archived);
|
|
304
|
+
if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) {
|
|
305
|
+
const fallbackManifest = archive_preservation_manifest_path(archived);
|
|
306
|
+
if (!existsSync(fallbackManifest) || !statSync(fallbackManifest).isFile()) {
|
|
307
|
+
return block(change, "archived", [reason("superspec_not_preserved", "archive preservation manifest missing")]);
|
|
308
|
+
}
|
|
309
|
+
return check_archived_preservation_bundle(change, archived, fallbackManifest);
|
|
310
|
+
}
|
|
311
|
+
let manifest;
|
|
312
|
+
try {
|
|
313
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return block(change, "archived", [reason("superspec_not_preserved", "archive preservation manifest is invalid JSON")]);
|
|
317
|
+
}
|
|
318
|
+
const shapeReasons = manifest_shape_reasons(change, manifest, "archive preservation", PRIMARY_ARCHIVE_MANIFEST_KIND);
|
|
319
|
+
if (shapeReasons.length > 0)
|
|
320
|
+
return block(change, "archived", shapeReasons);
|
|
321
|
+
const missing = [];
|
|
322
|
+
const mismatched = [];
|
|
323
|
+
const invalid = [];
|
|
324
|
+
for (const entry of manifest.entries) {
|
|
325
|
+
const relPath = entry.path;
|
|
326
|
+
const expected = entry.sha256;
|
|
327
|
+
if (typeof relPath !== "string" || typeof expected !== "string") {
|
|
328
|
+
mismatched.push(String(relPath));
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const filePath = manifest_entry_target(archived, relPath);
|
|
332
|
+
if (filePath === null) {
|
|
333
|
+
invalid.push(relPath);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const actual = preserved_file_sha(filePath);
|
|
337
|
+
if (actual === null)
|
|
338
|
+
missing.push(relPath);
|
|
339
|
+
else if (actual !== expected)
|
|
340
|
+
mismatched.push(relPath);
|
|
341
|
+
}
|
|
342
|
+
const reasons = [];
|
|
343
|
+
if (invalid.length > 0)
|
|
344
|
+
reasons.push(reason("archive_manifest_mismatch", `archived .superspec manifest paths are invalid: ${renderList(invalid.slice(0, 20))}`, invalid.slice(0, 20)));
|
|
345
|
+
if (missing.length > 0)
|
|
346
|
+
reasons.push(reason("superspec_not_preserved", `archived .superspec files missing: ${renderList(missing.slice(0, 20))}`));
|
|
347
|
+
if (mismatched.length > 0)
|
|
348
|
+
reasons.push(reason("archive_manifest_mismatch", `archived .superspec files changed: ${renderList(mismatched.slice(0, 20))}`));
|
|
349
|
+
if (reasons.length > 0)
|
|
350
|
+
return block(change, "archived", reasons);
|
|
351
|
+
return allow(change, "archived", { gate_summary: { archive_root: archived, manifest_entries: (manifest.entries ?? []).length } });
|
|
352
|
+
}
|
|
353
|
+
export function check_archived_preservation_bundle(change, archived, manifestPath) {
|
|
354
|
+
let manifest;
|
|
355
|
+
try {
|
|
356
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return block(change, "archived", [reason("superspec_not_preserved", "fallback preservation manifest is invalid JSON")]);
|
|
360
|
+
}
|
|
361
|
+
const shapeReasons = manifest_shape_reasons(change, manifest, "fallback preservation", FALLBACK_ARCHIVE_MANIFEST_KIND);
|
|
362
|
+
if (shapeReasons.length > 0)
|
|
363
|
+
return block(change, "archived", shapeReasons);
|
|
364
|
+
if (typeof manifest.files_root !== "string" || !manifest.files_root) {
|
|
365
|
+
return block(change, "archived", [reason("archive_manifest_mismatch", "fallback preservation manifest requires files_root")]);
|
|
366
|
+
}
|
|
367
|
+
const filesRoot = safe_within(dirname(manifestPath), manifest.files_root);
|
|
368
|
+
if (filesRoot === null || !existsSync(filesRoot) || !lstatSync(filesRoot).isDirectory()) {
|
|
369
|
+
return block(change, "archived", [reason("archive_manifest_mismatch", `fallback preservation files_root is invalid: ${repr(manifest.files_root)}`)]);
|
|
370
|
+
}
|
|
371
|
+
const missing = [];
|
|
372
|
+
const mismatched = [];
|
|
373
|
+
const invalid = [];
|
|
374
|
+
for (const entry of manifest.entries) {
|
|
375
|
+
const relPath = entry.path;
|
|
376
|
+
const expected = entry.sha256;
|
|
377
|
+
if (typeof relPath !== "string" || typeof expected !== "string") {
|
|
378
|
+
mismatched.push(String(relPath));
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const filePath = manifest_entry_target(filesRoot, relPath);
|
|
382
|
+
if (filePath === null) {
|
|
383
|
+
invalid.push(relPath);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const actual = preserved_file_sha(filePath);
|
|
387
|
+
if (actual === null)
|
|
388
|
+
missing.push(relPath);
|
|
389
|
+
else if (actual !== expected)
|
|
390
|
+
mismatched.push(relPath);
|
|
391
|
+
}
|
|
392
|
+
const reasons = [];
|
|
393
|
+
if (invalid.length > 0)
|
|
394
|
+
reasons.push(reason("archive_manifest_mismatch", `fallback .superspec manifest paths are invalid: ${renderList(invalid.slice(0, 20))}`, invalid.slice(0, 20)));
|
|
395
|
+
if (missing.length > 0)
|
|
396
|
+
reasons.push(reason("superspec_not_preserved", `fallback .superspec files missing: ${renderList(missing.slice(0, 20))}`));
|
|
397
|
+
if (mismatched.length > 0)
|
|
398
|
+
reasons.push(reason("archive_manifest_mismatch", `fallback .superspec files changed: ${renderList(mismatched.slice(0, 20))}`));
|
|
399
|
+
if (reasons.length > 0)
|
|
400
|
+
return block(change, "archived", reasons);
|
|
401
|
+
return allow(change, "archived", {
|
|
402
|
+
gate_summary: {
|
|
403
|
+
archive_root: archived,
|
|
404
|
+
fallback_manifest: toPosix(relative(archived, manifestPath)),
|
|
405
|
+
manifest_entries: (manifest.entries ?? []).length,
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
export function preset_upgrade_required(preset, changedPaths, flags = {}) {
|
|
410
|
+
if (preset === "full")
|
|
411
|
+
return false;
|
|
412
|
+
if (preset === "hotfix")
|
|
413
|
+
return changedPaths.length >= 3 || Boolean(flags.architecture) || Boolean(flags.schema) || Boolean(flags.public_api) || Boolean(flags.cross_module);
|
|
414
|
+
if (preset === "tweak")
|
|
415
|
+
return changedPaths.length >= 5 || Boolean(flags.cross_module) || Number(flags.new_tests ?? 0) >= 5 || Boolean(flags.config_key_add_remove);
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
export function preset_upgrade_reasons(config, changedPaths, humanConfirmed = false, flags = {}) {
|
|
419
|
+
const preset = String(config.preset ?? "full");
|
|
420
|
+
if (!preset_upgrade_required(preset, changedPaths, flags))
|
|
421
|
+
return [];
|
|
422
|
+
if (humanConfirmed)
|
|
423
|
+
return [];
|
|
424
|
+
return [reason("preset_upgrade_requires_human_confirmation", `preset ${repr(preset)} must upgrade to full before proceeding`)];
|
|
425
|
+
}
|
|
426
|
+
export function preset_upgrade_required_from_context(config, changedPaths) {
|
|
427
|
+
return preset_upgrade_required(String(config.preset ?? "full"), changedPaths);
|
|
428
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(argv?: string[]): number;
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { block, dispatch, GuardError, printDecision, reason } from "./core.js";
|
|
2
|
+
import { emitArgparsePreamble, parse_argv } from "./cli_args.js";
|
|
3
|
+
export function main(argv = process.argv.slice(2)) {
|
|
4
|
+
let args = null;
|
|
5
|
+
try {
|
|
6
|
+
const argparseExit = emitArgparsePreamble(argv);
|
|
7
|
+
if (argparseExit !== null)
|
|
8
|
+
return argparseExit;
|
|
9
|
+
args = parse_argv(argv);
|
|
10
|
+
const [decision] = dispatch(args);
|
|
11
|
+
printDecision(decision);
|
|
12
|
+
return decision.allowed ? 0 : 1;
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
const change = args?.change ?? "?";
|
|
16
|
+
const errReason = err instanceof GuardError ? reason("guard_error", err.message) : reason("guard_internal_error", `${err.name}: ${err.message}`);
|
|
17
|
+
printDecision(block(change, "guard_error", [errReason]));
|
|
18
|
+
return 2;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type ParsedArgs = {
|
|
2
|
+
command: string;
|
|
3
|
+
change: string;
|
|
4
|
+
artifact?: string;
|
|
5
|
+
gate?: string;
|
|
6
|
+
task_id?: string;
|
|
7
|
+
create?: boolean;
|
|
8
|
+
force_unlock?: boolean;
|
|
9
|
+
rebuild_corrupt?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function emitArgparsePreamble(argv: string[]): number | null;
|
|
12
|
+
export declare function parse_argv(argv: string[]): ParsedArgs;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const SIMPLE_COMMANDS = [
|
|
2
|
+
"status",
|
|
3
|
+
"recompute",
|
|
4
|
+
"check-init",
|
|
5
|
+
"check-apply-ready",
|
|
6
|
+
"check-review-ready",
|
|
7
|
+
"check-review-complete",
|
|
8
|
+
"check-verify-ready",
|
|
9
|
+
"check-archive-ready",
|
|
10
|
+
"check-archived",
|
|
11
|
+
];
|
|
12
|
+
const COMMANDS = [
|
|
13
|
+
"init",
|
|
14
|
+
...SIMPLE_COMMANDS,
|
|
15
|
+
"check-artifact",
|
|
16
|
+
"check-enter",
|
|
17
|
+
"check-task-reopen",
|
|
18
|
+
"check-task-edit",
|
|
19
|
+
"check-task-complete",
|
|
20
|
+
];
|
|
21
|
+
const COMMAND_LIST = COMMANDS.join(",");
|
|
22
|
+
const COMMAND_CHOICES = COMMANDS.map((item) => `'${item}'`).join(", ");
|
|
23
|
+
function requiredValueFlags(command) {
|
|
24
|
+
const flags = ["--change"];
|
|
25
|
+
if (command === "check-artifact")
|
|
26
|
+
flags.push("--artifact");
|
|
27
|
+
if (command === "check-enter")
|
|
28
|
+
flags.push("--gate");
|
|
29
|
+
if (command === "check-task-reopen" || command === "check-task-edit" || command === "check-task-complete")
|
|
30
|
+
flags.push("--task-id");
|
|
31
|
+
return flags;
|
|
32
|
+
}
|
|
33
|
+
function requiredBooleanFlags(command) {
|
|
34
|
+
return command === "init" ? ["--create"] : [];
|
|
35
|
+
}
|
|
36
|
+
function optionalBooleanFlags(command) {
|
|
37
|
+
return command === "recompute" ? ["--force-unlock", "--rebuild-corrupt"] : [];
|
|
38
|
+
}
|
|
39
|
+
function rootUsage() {
|
|
40
|
+
return `usage: superspec_guard [-h]\n {${COMMAND_LIST}}\n ...\n`;
|
|
41
|
+
}
|
|
42
|
+
function rootHelp() {
|
|
43
|
+
return `${rootUsage()}\nsuperspec Sync Guard (v1)\n\npositional arguments:\n {${COMMAND_LIST}}\n\noptional arguments:\n -h, --help show this help message and exit\n`;
|
|
44
|
+
}
|
|
45
|
+
function commandUsage(command) {
|
|
46
|
+
const usageFlags = [
|
|
47
|
+
"[-h]",
|
|
48
|
+
...requiredValueFlags(command).map((flag) => `${flag} ${flag.slice(2).replace(/-/g, "_").toUpperCase()}`),
|
|
49
|
+
...requiredBooleanFlags(command),
|
|
50
|
+
...optionalBooleanFlags(command),
|
|
51
|
+
];
|
|
52
|
+
return `usage: superspec_guard ${command} ${usageFlags.join(" ")}\n`;
|
|
53
|
+
}
|
|
54
|
+
function commandHelp(command) {
|
|
55
|
+
const lines = [commandUsage(command), "\noptional arguments:\n", " -h, --help show this help message and exit\n"];
|
|
56
|
+
for (const flag of requiredValueFlags(command)) {
|
|
57
|
+
const metavariable = flag.slice(2).replace(/-/g, "_").toUpperCase();
|
|
58
|
+
lines.push(` ${flag} ${metavariable}\n`);
|
|
59
|
+
}
|
|
60
|
+
for (const flag of requiredBooleanFlags(command)) {
|
|
61
|
+
lines.push(` ${flag}\n`);
|
|
62
|
+
}
|
|
63
|
+
for (const flag of optionalBooleanFlags(command)) {
|
|
64
|
+
lines.push(` ${flag}\n`);
|
|
65
|
+
}
|
|
66
|
+
return lines.join("");
|
|
67
|
+
}
|
|
68
|
+
function hasFlag(argv, flag) {
|
|
69
|
+
return argv.includes(flag);
|
|
70
|
+
}
|
|
71
|
+
function missingRequiredFlags(command, args) {
|
|
72
|
+
return [...requiredValueFlags(command), ...requiredBooleanFlags(command)].filter((flag) => !hasFlag(args, flag));
|
|
73
|
+
}
|
|
74
|
+
export function emitArgparsePreamble(argv) {
|
|
75
|
+
if (argv.length === 0) {
|
|
76
|
+
process.stderr.write(`${rootUsage()}superspec_guard: error: the following arguments are required: command\n`);
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
const command = argv[0];
|
|
80
|
+
if (command === "-h" || command === "--help") {
|
|
81
|
+
process.stdout.write(rootHelp());
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
if (!COMMANDS.includes(command)) {
|
|
85
|
+
process.stderr.write(`${rootUsage()}superspec_guard: error: argument command: invalid choice: '${command}' (choose from ${COMMAND_CHOICES})\n`);
|
|
86
|
+
return 2;
|
|
87
|
+
}
|
|
88
|
+
const args = argv.slice(1);
|
|
89
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
90
|
+
process.stdout.write(commandHelp(command));
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
const missing = missingRequiredFlags(command, args);
|
|
94
|
+
if (missing.length > 0) {
|
|
95
|
+
process.stderr.write(`${commandUsage(command)}superspec_guard ${command}: error: the following arguments are required: ${missing.join(", ")}\n`);
|
|
96
|
+
return 2;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
export function parse_argv(argv) {
|
|
101
|
+
if (argv.length === 0)
|
|
102
|
+
throw new Error("missing command");
|
|
103
|
+
const command = argv[0];
|
|
104
|
+
const args = argv.slice(1);
|
|
105
|
+
const getValue = (flag) => {
|
|
106
|
+
const idx = args.indexOf(flag);
|
|
107
|
+
if (idx === -1)
|
|
108
|
+
return undefined;
|
|
109
|
+
return args[idx + 1];
|
|
110
|
+
};
|
|
111
|
+
const change = getValue("--change");
|
|
112
|
+
if (!change)
|
|
113
|
+
throw new Error("missing required --change");
|
|
114
|
+
if (command === "init") {
|
|
115
|
+
if (!hasFlag(args, "--create"))
|
|
116
|
+
throw new Error("missing required --create");
|
|
117
|
+
return { command, change, create: true };
|
|
118
|
+
}
|
|
119
|
+
if (command === "check-artifact") {
|
|
120
|
+
const artifact = getValue("--artifact");
|
|
121
|
+
if (!artifact)
|
|
122
|
+
throw new Error("missing required --artifact");
|
|
123
|
+
return { command, change, artifact };
|
|
124
|
+
}
|
|
125
|
+
if (command === "check-enter") {
|
|
126
|
+
const gate = getValue("--gate");
|
|
127
|
+
if (!gate)
|
|
128
|
+
throw new Error("missing required --gate");
|
|
129
|
+
return { command, change, gate };
|
|
130
|
+
}
|
|
131
|
+
if (command === "check-task-reopen" || command === "check-task-edit" || command === "check-task-complete") {
|
|
132
|
+
const taskId = getValue("--task-id");
|
|
133
|
+
if (!taskId)
|
|
134
|
+
throw new Error("missing required --task-id");
|
|
135
|
+
return { command, change, task_id: taskId };
|
|
136
|
+
}
|
|
137
|
+
const simple = new Set(SIMPLE_COMMANDS);
|
|
138
|
+
if (!simple.has(command))
|
|
139
|
+
throw new Error(`unknown command: ${command}`);
|
|
140
|
+
return {
|
|
141
|
+
command,
|
|
142
|
+
change,
|
|
143
|
+
force_unlock: command === "recompute" && hasFlag(args, "--force-unlock"),
|
|
144
|
+
rebuild_corrupt: command === "recompute" && hasFlag(args, "--rebuild-corrupt"),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export * from "./util.ts";
|
|
3
|
+
export * from "./openspec.ts";
|
|
4
|
+
export * from "./paths.ts";
|
|
5
|
+
export * from "./disclosure.ts";
|
|
6
|
+
export * from "./evidence.ts";
|
|
7
|
+
export * from "./tasks.ts";
|
|
8
|
+
export * from "./invariants.ts";
|
|
9
|
+
export * from "./git.ts";
|
|
10
|
+
export * from "./state.ts";
|
|
11
|
+
export * from "./archive.ts";
|
|
12
|
+
export * from "./gates.ts";
|
|
13
|
+
export * from "./install_engine.ts";
|
|
14
|
+
import type { ParsedArgs } from "./cli_args.ts";
|
|
15
|
+
import type { JsonMap } from "./util.ts";
|
|
16
|
+
export declare function load_context(change: string): [JsonMap, string, string, JsonMap[]];
|
|
17
|
+
export declare function cmd_status(change: string): JsonMap;
|
|
18
|
+
export declare function cmd_init_summary(change: string, changeRoot: string, decision: JsonMap): JsonMap;
|
|
19
|
+
export declare function dispatch(args: ParsedArgs): [JsonMap, string];
|