@fission-ai/openspec 1.13.1 → 1.14.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 +15 -3
- package/dist/cli/index.js +39 -1
- package/dist/commands/spec.js +1 -0
- package/dist/commands/workflow/instructions.js +66 -18
- package/dist/commands/workflow/shared.d.ts +7 -0
- package/dist/commands/workflow/status.js +15 -2
- package/dist/core/archive.js +258 -78
- package/dist/core/artifact-graph/instruction-loader.d.ts +7 -0
- package/dist/core/artifact-graph/instruction-loader.js +18 -1
- package/dist/core/artifact-graph/outputs.d.ts +1 -1
- package/dist/core/artifact-graph/outputs.js +38 -8
- package/dist/core/change-metadata/schema.d.ts +3 -0
- package/dist/core/change-metadata/schema.js +10 -0
- package/dist/core/change-status-policy.d.ts +8 -0
- package/dist/core/change-status-policy.js +33 -2
- package/dist/core/command-generation/adapters/atomcode.d.ts +24 -0
- package/dist/core/command-generation/adapters/atomcode.js +46 -0
- package/dist/core/command-generation/adapters/codestudio.d.ts +13 -0
- package/dist/core/command-generation/adapters/codestudio.js +27 -0
- package/dist/core/command-generation/adapters/continue.js +4 -0
- package/dist/core/command-generation/adapters/easycode.d.ts +17 -0
- package/dist/core/command-generation/adapters/easycode.js +28 -0
- package/dist/core/command-generation/adapters/gemini.js +1 -35
- package/dist/core/command-generation/adapters/gigacode.d.ts +17 -0
- package/dist/core/command-generation/adapters/gigacode.js +31 -0
- package/dist/core/command-generation/adapters/index.d.ts +4 -0
- package/dist/core/command-generation/adapters/index.js +4 -0
- package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
- package/dist/core/command-generation/adapters/kilocode.js +2 -2
- package/dist/core/command-generation/registry.js +8 -0
- package/dist/core/command-generation/toml.d.ts +19 -0
- package/dist/core/command-generation/toml.js +39 -0
- package/dist/core/command-surface.js +1 -1
- package/dist/core/completions/command-registry.js +19 -0
- package/dist/core/completions/installers/zsh-installer.js +4 -2
- package/dist/core/config-prompts.js +6 -6
- package/dist/core/config.js +11 -1
- package/dist/core/init.js +44 -14
- package/dist/core/legacy-cleanup.d.ts +1 -1
- package/dist/core/legacy-cleanup.js +40 -11
- package/dist/core/list.d.ts +2 -0
- package/dist/core/list.js +49 -23
- package/dist/core/parsers/markdown-parser.js +5 -0
- package/dist/core/parsers/requirement-blocks.d.ts +40 -3
- package/dist/core/parsers/requirement-blocks.js +74 -25
- package/dist/core/project-config.js +45 -1
- package/dist/core/references.js +3 -1
- package/dist/core/root-selection.d.ts +6 -0
- package/dist/core/root-selection.js +16 -0
- package/dist/core/schemas/base.schema.d.ts +3 -0
- package/dist/core/schemas/base.schema.js +6 -0
- package/dist/core/schemas/change.schema.d.ts +8 -0
- package/dist/core/schemas/spec.schema.d.ts +2 -0
- package/dist/core/specs-apply.d.ts +20 -0
- package/dist/core/specs-apply.js +19 -6
- package/dist/core/templates/workflows/apply-change.js +5 -2
- package/dist/core/templates/workflows/archive-change.js +56 -20
- package/dist/core/templates/workflows/bulk-archive-change.js +34 -16
- package/dist/core/templates/workflows/continue-change.js +0 -2
- package/dist/core/templates/workflows/explore.js +2 -2
- package/dist/core/templates/workflows/ff-change.js +2 -2
- package/dist/core/templates/workflows/onboard.js +9 -4
- package/dist/core/templates/workflows/update-change.js +21 -9
- package/dist/core/templates/workflows/verify-change.js +115 -52
- package/dist/core/update.js +20 -7
- package/dist/core/validation/constants.d.ts +1 -1
- package/dist/core/validation/constants.js +1 -1
- package/dist/core/validation/purpose-placeholder.js +24 -9
- package/dist/core/validation/validator.js +15 -6
- package/dist/core/version-check.d.ts +23 -1
- package/dist/core/version-check.js +112 -26
- package/dist/core/view.d.ts +2 -0
- package/dist/core/view.js +62 -4
- package/dist/utils/change-metadata.d.ts +20 -1
- package/dist/utils/change-metadata.js +57 -1
- package/dist/utils/command-references.d.ts +1 -1
- package/dist/utils/command-references.js +2 -2
- package/dist/utils/file-system.js +20 -3
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/line-endings.d.ts +34 -0
- package/dist/utils/line-endings.js +46 -0
- package/package.json +2 -2
- package/schemas/spec-driven/schema.yaml +10 -1
- package/schemas/spec-driven/templates/proposal.md +6 -3
package/dist/core/archive.js
CHANGED
|
@@ -7,9 +7,9 @@ import { Validator } from './validation/validator.js';
|
|
|
7
7
|
import { VALIDATION_MESSAGES } from './validation/constants.js';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { emitStoreRootBanner, isRootSelectionError, resolveOpenSpecRoot, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from './root-selection.js';
|
|
10
|
-
import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, finalizeRetiredSpec, } from './specs-apply.js';
|
|
10
|
+
import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, finalizeRetiredSpec, pruneEmptyDirs, } from './specs-apply.js';
|
|
11
11
|
import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
|
12
|
-
import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } from '../utils/change-metadata.js';
|
|
12
|
+
import { METADATA_FILENAME, formatUnknownChangeMetadataKeysMessage, readRetireCapabilitiesMarker, readSkipSpecsMarker, readUnknownChangeMetadataKeys, } from '../utils/change-metadata.js';
|
|
13
13
|
import { confirmPrompt, isNonInteractivePromptError } from '../utils/interactive.js';
|
|
14
14
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
15
15
|
import { folderStyleNameProblem } from './id.js';
|
|
@@ -289,6 +289,14 @@ async function copyDirContents(src, dest) {
|
|
|
289
289
|
}
|
|
290
290
|
await fs.chmod(dest, sourceStat.mode & 0o7777);
|
|
291
291
|
}
|
|
292
|
+
/** SHA-256 of one file's bytes, streamed so a large file is never held whole. */
|
|
293
|
+
async function fingerprintFileContents(filePath) {
|
|
294
|
+
const fileHash = createHash('sha256');
|
|
295
|
+
for await (const chunk of createReadStream(filePath)) {
|
|
296
|
+
fileHash.update(chunk);
|
|
297
|
+
}
|
|
298
|
+
return fileHash.digest();
|
|
299
|
+
}
|
|
292
300
|
async function fingerprintDirectoryContents(root) {
|
|
293
301
|
const hash = createHash('sha256');
|
|
294
302
|
const updateHashField = (label, value) => {
|
|
@@ -301,13 +309,7 @@ async function fingerprintDirectoryContents(root) {
|
|
|
301
309
|
hash.update(labelBuffer);
|
|
302
310
|
hash.update(valueBuffer);
|
|
303
311
|
};
|
|
304
|
-
const fingerprintFile =
|
|
305
|
-
const fileHash = createHash('sha256');
|
|
306
|
-
for await (const chunk of createReadStream(filePath)) {
|
|
307
|
-
fileHash.update(chunk);
|
|
308
|
-
}
|
|
309
|
-
return fileHash.digest();
|
|
310
|
-
};
|
|
312
|
+
const fingerprintFile = fingerprintFileContents;
|
|
311
313
|
const visit = async (dir, relativeDir) => {
|
|
312
314
|
const before = await fs.lstat(dir, { bigint: true });
|
|
313
315
|
if (!before.isDirectory()) {
|
|
@@ -367,15 +369,198 @@ async function assertCopiedDirectoryUnchanged(stagedSource, destination, expecte
|
|
|
367
369
|
}
|
|
368
370
|
/**
|
|
369
371
|
* Move a directory from src to dest. On Windows, fs.rename() can fail with
|
|
370
|
-
* EPERM, and cross-device moves fail with EXDEV.
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
372
|
+
* EPERM, and cross-device moves fail with EXDEV. Prefer renaming the source
|
|
373
|
+
* to a private sibling first, then copy-then-remove. When that staging rename
|
|
374
|
+
* also fails with EPERM/EXDEV — the usual Windows case for a directory that
|
|
375
|
+
* still has children, because a watcher holds a directory-enumeration handle —
|
|
376
|
+
* copy from the original source instead. Fingerprints still abort if the tree
|
|
377
|
+
* changes mid-copy. A staging failure that is not EPERM/EXDEV still leaves
|
|
378
|
+
* the source untouched rather than copying through a path we could not claim.
|
|
374
379
|
*/
|
|
375
380
|
class MoveDestinationRetainedError extends Error {
|
|
376
381
|
}
|
|
377
382
|
class RetirementBackupsRetainedError extends Error {
|
|
378
383
|
}
|
|
384
|
+
function isFallbackRenameCode(code) {
|
|
385
|
+
return code === 'EPERM' || code === 'EXDEV';
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Every entry under `root`, deepest first, as paths relative to it.
|
|
389
|
+
*
|
|
390
|
+
* The listing is what bounds the removal below. Anything that appears after it
|
|
391
|
+
* is simply not in the set, so it cannot be deleted by the cleanup.
|
|
392
|
+
*/
|
|
393
|
+
async function listTreeEntriesDeepestFirst(root) {
|
|
394
|
+
const entries = [];
|
|
395
|
+
const visit = async (dir, relativeDir) => {
|
|
396
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
397
|
+
const relative = relativeDir === '' ? entry.name : path.join(relativeDir, entry.name);
|
|
398
|
+
if (entry.isDirectory()) {
|
|
399
|
+
await visit(path.join(dir, entry.name), relative);
|
|
400
|
+
entries.push({ relative, kind: 'directory' });
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
// A symlink to a directory is not a directory here, and its content is
|
|
404
|
+
// its target, not bytes to read - reading one raises EISDIR. Record the
|
|
405
|
+
// kind so cleanup compares each entry the way the copy wrote it.
|
|
406
|
+
entries.push({ relative, kind: entry.isSymbolicLink() ? 'symlink' : 'file' });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
await visit(root, '');
|
|
411
|
+
return entries;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Remove exactly the entries that were copied and verified, deepest first.
|
|
415
|
+
*
|
|
416
|
+
* The move is only safe to finish by deleting the source, and the source of the
|
|
417
|
+
* unstaged fallback is still the live change directory: the archive claim
|
|
418
|
+
* covers the destination, not it. A recursive remove would delete whatever is
|
|
419
|
+
* there at that moment, including a file a concurrent writer added after the
|
|
420
|
+
* final fingerprint - data that never reached the destination.
|
|
421
|
+
*
|
|
422
|
+
* Removing a named set instead means a late arrival is never in it. It is left
|
|
423
|
+
* on disk, and the `rmdir` of its parent fails with ENOTEMPTY, which the caller
|
|
424
|
+
* reports as a retained destination. The move does not complete silently.
|
|
425
|
+
*
|
|
426
|
+
* `entries` must be listed before the final fingerprint, so that an arrival is
|
|
427
|
+
* either caught by that fingerprint or absent from the set.
|
|
428
|
+
*
|
|
429
|
+
* An edit to a file that is already in the set is covered by claiming each file
|
|
430
|
+
* before reading it: `rename` is atomic, so once a file is under its claim name
|
|
431
|
+
* the bytes there are ours. A writer that rewrites the file by path after that
|
|
432
|
+
* point creates a new file at the original path, which is not in `entries`, is
|
|
433
|
+
* never deleted, and makes the parent `rmdir` fail with ENOTEMPTY - reported as
|
|
434
|
+
* a retained destination. A writer that got there first is caught by comparing
|
|
435
|
+
* the claimed bytes against the copy: on a mismatch the file is put back and
|
|
436
|
+
* the move is abandoned with both trees intact, because the destination holds
|
|
437
|
+
* the older content and deleting the source would lose the newer.
|
|
438
|
+
*
|
|
439
|
+
* What remains outside this, as for any copy, is a writer holding an open
|
|
440
|
+
* descriptor that writes through it after the comparison. Staging is still
|
|
441
|
+
* preferred whenever the rename is permitted at all.
|
|
442
|
+
*/
|
|
443
|
+
/**
|
|
444
|
+
* A claim suffix no entry in this move can already carry.
|
|
445
|
+
*
|
|
446
|
+
* A fixed suffix collides with a source file that legitimately ends in it:
|
|
447
|
+
* claiming `x` would rename it over a real `x.openspec-claim`, and that file's
|
|
448
|
+
* own turn would then fail with ENOENT after part of the live source had
|
|
449
|
+
* already been removed. So draw a fresh suffix per move and prove it against
|
|
450
|
+
* the very set being removed - if no entry ends with the suffix, no claim of
|
|
451
|
+
* one entry can land on another.
|
|
452
|
+
*/
|
|
453
|
+
function makeClaimSuffix(entries) {
|
|
454
|
+
const names = entries.map((entry) => entry.relative);
|
|
455
|
+
for (;;) {
|
|
456
|
+
const suffix = `.openspec-claim-${randomUUID()}`;
|
|
457
|
+
if (!names.some((name) => name.endsWith(suffix)))
|
|
458
|
+
return suffix;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/** What the entry holds now, for comparison against the copy. */
|
|
462
|
+
async function readEntryIdentity(entryPath, kind) {
|
|
463
|
+
return kind === 'symlink'
|
|
464
|
+
? `symlink:${await fs.readlink(entryPath)}`
|
|
465
|
+
: `file:${(await fingerprintFileContents(entryPath)).toString('hex')}`;
|
|
466
|
+
}
|
|
467
|
+
async function removeVerifiedTree(root, entries, destination) {
|
|
468
|
+
const claimSuffix = makeClaimSuffix(entries);
|
|
469
|
+
for (const entry of entries) {
|
|
470
|
+
const target = path.join(root, entry.relative);
|
|
471
|
+
if (entry.kind === 'directory') {
|
|
472
|
+
await fs.rmdir(target);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
const claimed = target + claimSuffix;
|
|
476
|
+
await fs.rename(target, claimed);
|
|
477
|
+
let claimedIdentity;
|
|
478
|
+
let copiedIdentity;
|
|
479
|
+
try {
|
|
480
|
+
claimedIdentity = await readEntryIdentity(claimed, entry.kind);
|
|
481
|
+
copiedIdentity = await readEntryIdentity(path.join(destination, entry.relative), entry.kind);
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
await fs.rename(claimed, target).catch(() => undefined);
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
if (claimedIdentity !== copiedIdentity) {
|
|
488
|
+
await fs.rename(claimed, target).catch(() => undefined);
|
|
489
|
+
throw new Error(`${target} changed after it was verified, so the copy at ${destination} ` +
|
|
490
|
+
'does not hold its current content.');
|
|
491
|
+
}
|
|
492
|
+
await fs.rm(claimed, { force: true });
|
|
493
|
+
}
|
|
494
|
+
await fs.rmdir(root);
|
|
495
|
+
}
|
|
496
|
+
async function copyThenRemoveDirectory(source, dest, options, restoreSource) {
|
|
497
|
+
let destIsOurs = false;
|
|
498
|
+
let sourceFingerprint;
|
|
499
|
+
try {
|
|
500
|
+
sourceFingerprint = await fingerprintDirectoryContents(source);
|
|
501
|
+
await fs.mkdir(dest, { mode: 0o700 });
|
|
502
|
+
destIsOurs = true;
|
|
503
|
+
await copyDirContents(source, dest);
|
|
504
|
+
await options.verifyCopiedDestination?.(source);
|
|
505
|
+
await assertCopiedDirectoryUnchanged(source, dest, sourceFingerprint);
|
|
506
|
+
}
|
|
507
|
+
catch (copyError) {
|
|
508
|
+
if (destIsOurs) {
|
|
509
|
+
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
|
510
|
+
}
|
|
511
|
+
if (restoreSource) {
|
|
512
|
+
try {
|
|
513
|
+
await restoreSource();
|
|
514
|
+
}
|
|
515
|
+
catch (restoreError) {
|
|
516
|
+
throw new Error(`${copyError instanceof Error ? copyError.message : String(copyError)} ` +
|
|
517
|
+
`Could not restore the staged source at ${source} ` +
|
|
518
|
+
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (copyError.code === 'EEXIST') {
|
|
522
|
+
throw new ArchiveBlockedError('archive_target_exists', `Archive '${path.basename(dest)}' already exists.`);
|
|
523
|
+
}
|
|
524
|
+
throw copyError;
|
|
525
|
+
}
|
|
526
|
+
let verifiedEntries;
|
|
527
|
+
try {
|
|
528
|
+
// Listed before the verification, not after it. A file that arrives before
|
|
529
|
+
// the fingerprint changes it and aborts the move; one that arrives after is
|
|
530
|
+
// not in this set. Listing afterwards would leave a window in which an
|
|
531
|
+
// arrival is both unverified and deletable.
|
|
532
|
+
verifiedEntries = await listTreeEntriesDeepestFirst(source);
|
|
533
|
+
await options.verifyCopiedDestination?.(source);
|
|
534
|
+
await assertCopiedDirectoryUnchanged(source, dest, sourceFingerprint);
|
|
535
|
+
}
|
|
536
|
+
catch (verificationError) {
|
|
537
|
+
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
|
538
|
+
if (restoreSource) {
|
|
539
|
+
try {
|
|
540
|
+
await restoreSource();
|
|
541
|
+
}
|
|
542
|
+
catch (restoreError) {
|
|
543
|
+
throw new Error(`${verificationError instanceof Error ? verificationError.message : String(verificationError)} ` +
|
|
544
|
+
`Could not restore the staged source at ${source} ` +
|
|
545
|
+
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
throw verificationError;
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
await removeVerifiedTree(source, verifiedEntries, dest);
|
|
552
|
+
}
|
|
553
|
+
catch (cleanupError) {
|
|
554
|
+
// Removal may already have deleted part of the source, or stopped on an
|
|
555
|
+
// entry that appeared after verification. The destination is now the only
|
|
556
|
+
// complete copy, so never erase it while trying to make this failed move
|
|
557
|
+
// look atomic.
|
|
558
|
+
throw new MoveDestinationRetainedError(`Copied ${source} to ${dest}, but could not remove the source at ` +
|
|
559
|
+
`${source} completely ` +
|
|
560
|
+
`(${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}). ` +
|
|
561
|
+
'The complete destination was retained for recovery.');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
379
564
|
async function moveDirectory(src, dest, options = {}) {
|
|
380
565
|
try {
|
|
381
566
|
await fs.rename(src, dest);
|
|
@@ -387,71 +572,24 @@ async function moveDirectory(src, dest, options = {}) {
|
|
|
387
572
|
if (code === 'ENOTEMPTY' || code === 'EEXIST') {
|
|
388
573
|
throw new ArchiveBlockedError('archive_target_exists', `Archive '${path.basename(dest)}' already exists.`);
|
|
389
574
|
}
|
|
390
|
-
if (code
|
|
575
|
+
if (isFallbackRenameCode(code)) {
|
|
391
576
|
const stagedSource = path.join(path.dirname(src), `.openspec-move-${randomUUID()}`);
|
|
392
577
|
try {
|
|
393
578
|
await fs.rename(src, stagedSource);
|
|
394
579
|
}
|
|
395
580
|
catch (stageError) {
|
|
581
|
+
const stageCode = stageError?.code;
|
|
582
|
+
if (isFallbackRenameCode(stageCode)) {
|
|
583
|
+
await copyThenRemoveDirectory(src, dest, options);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
396
586
|
throw new Error(`Could not safely stage ${src} before the fallback archive copy ` +
|
|
397
587
|
`(${stageError instanceof Error ? stageError.message : String(stageError)}). ` +
|
|
398
588
|
'No fallback copy was attempted.');
|
|
399
589
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
stagedFingerprint = await fingerprintDirectoryContents(stagedSource);
|
|
404
|
-
await fs.mkdir(dest, { mode: 0o700 });
|
|
405
|
-
destIsOurs = true;
|
|
406
|
-
await copyDirContents(stagedSource, dest);
|
|
407
|
-
await options.verifyCopiedDestination?.(stagedSource);
|
|
408
|
-
await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint);
|
|
409
|
-
}
|
|
410
|
-
catch (copyError) {
|
|
411
|
-
if (destIsOurs) {
|
|
412
|
-
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
|
413
|
-
}
|
|
414
|
-
try {
|
|
415
|
-
await fs.rename(stagedSource, src);
|
|
416
|
-
}
|
|
417
|
-
catch (restoreError) {
|
|
418
|
-
throw new Error(`${copyError instanceof Error ? copyError.message : String(copyError)} ` +
|
|
419
|
-
`Could not restore the staged source at ${stagedSource} ` +
|
|
420
|
-
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
|
|
421
|
-
}
|
|
422
|
-
if (copyError.code === 'EEXIST') {
|
|
423
|
-
throw new ArchiveBlockedError('archive_target_exists', `Archive '${path.basename(dest)}' already exists.`);
|
|
424
|
-
}
|
|
425
|
-
throw copyError;
|
|
426
|
-
}
|
|
427
|
-
try {
|
|
428
|
-
await options.verifyCopiedDestination?.(stagedSource);
|
|
429
|
-
await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint);
|
|
430
|
-
}
|
|
431
|
-
catch (verificationError) {
|
|
432
|
-
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
|
433
|
-
try {
|
|
434
|
-
await fs.rename(stagedSource, src);
|
|
435
|
-
}
|
|
436
|
-
catch (restoreError) {
|
|
437
|
-
throw new Error(`${verificationError instanceof Error ? verificationError.message : String(verificationError)} ` +
|
|
438
|
-
`Could not restore the staged source at ${stagedSource} ` +
|
|
439
|
-
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
|
|
440
|
-
}
|
|
441
|
-
throw verificationError;
|
|
442
|
-
}
|
|
443
|
-
try {
|
|
444
|
-
await fs.rm(stagedSource, { recursive: true, force: true });
|
|
445
|
-
}
|
|
446
|
-
catch (cleanupError) {
|
|
447
|
-
// Recursive removal may already have deleted part of the source. The
|
|
448
|
-
// destination is now the only complete copy, so never erase it while
|
|
449
|
-
// trying to make this failed move look atomic.
|
|
450
|
-
throw new MoveDestinationRetainedError(`Copied ${src} to ${dest}, but could not remove the staged source at ` +
|
|
451
|
-
`${stagedSource} completely ` +
|
|
452
|
-
`(${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}). ` +
|
|
453
|
-
'The complete destination was retained for recovery.');
|
|
454
|
-
}
|
|
590
|
+
await copyThenRemoveDirectory(stagedSource, dest, options, async () => {
|
|
591
|
+
await fs.rename(stagedSource, src);
|
|
592
|
+
});
|
|
455
593
|
}
|
|
456
594
|
else {
|
|
457
595
|
throw err;
|
|
@@ -473,6 +611,10 @@ async function assertArchiveDestinationAvailable(archivePath, archiveName) {
|
|
|
473
611
|
function archiveClaimPath(archivePath, _archiveName) {
|
|
474
612
|
return path.join(path.dirname(archivePath), '.openspec-archive.lock');
|
|
475
613
|
}
|
|
614
|
+
function isSameArchiveClaimFile(first, second) {
|
|
615
|
+
return (first.ino === second.ino &&
|
|
616
|
+
(first.dev === second.dev || first.dev === 0n || second.dev === 0n));
|
|
617
|
+
}
|
|
476
618
|
async function releaseArchiveClaim(claim, claimPath) {
|
|
477
619
|
const owned = await claim.handle.stat({ bigint: true }).catch(() => undefined);
|
|
478
620
|
await claim.handle.close().catch(() => undefined);
|
|
@@ -485,10 +627,8 @@ async function releaseArchiveClaim(claim, claimPath) {
|
|
|
485
627
|
const current = await fs.lstat(claimPath, { bigint: true });
|
|
486
628
|
const contents = await fs.readFile(claimPath, 'utf8');
|
|
487
629
|
const currentAfterRead = await fs.lstat(claimPath, { bigint: true });
|
|
488
|
-
if (current
|
|
489
|
-
current
|
|
490
|
-
current.dev === currentAfterRead.dev &&
|
|
491
|
-
current.ino === currentAfterRead.ino &&
|
|
630
|
+
if (isSameArchiveClaimFile(current, owned) &&
|
|
631
|
+
isSameArchiveClaimFile(current, currentAfterRead) &&
|
|
492
632
|
contents === claim.contents) {
|
|
493
633
|
await fs.unlink(claimPath);
|
|
494
634
|
}
|
|
@@ -666,7 +806,29 @@ async function assertDistinctMutationTargets(mutations) {
|
|
|
666
806
|
owners.set(identity, mutation.update.id);
|
|
667
807
|
}
|
|
668
808
|
}
|
|
669
|
-
|
|
809
|
+
/**
|
|
810
|
+
* The deepest directory at or above `dir` that exists, never going above
|
|
811
|
+
* `boundaryDir`. Used as the floor for a rollback prune: everything below it
|
|
812
|
+
* was created by the write being undone, and it was not.
|
|
813
|
+
*/
|
|
814
|
+
async function deepestExistingAncestor(dir, boundaryDir) {
|
|
815
|
+
let current = dir;
|
|
816
|
+
for (;;) {
|
|
817
|
+
if (current === boundaryDir || !current.startsWith(boundaryDir + path.sep)) {
|
|
818
|
+
return boundaryDir;
|
|
819
|
+
}
|
|
820
|
+
try {
|
|
821
|
+
await fs.lstat(current);
|
|
822
|
+
return current;
|
|
823
|
+
}
|
|
824
|
+
catch (error) {
|
|
825
|
+
if (error.code !== 'ENOENT')
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
current = path.dirname(current);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
async function captureSpecSnapshots(mutations, mainSpecsDir) {
|
|
670
832
|
return Promise.all(mutations.map(async ({ update, outcome, rebuilt }) => {
|
|
671
833
|
try {
|
|
672
834
|
const stat = await fs.lstat(update.target);
|
|
@@ -714,6 +876,7 @@ async function captureSpecSnapshots(mutations) {
|
|
|
714
876
|
target: update.target,
|
|
715
877
|
existed: false,
|
|
716
878
|
outcome,
|
|
879
|
+
pruneBoundary: await deepestExistingAncestor(path.dirname(update.target), mainSpecsDir),
|
|
717
880
|
...(outcome === 'write' ? { expectedContent: Buffer.from(rebuilt) } : {}),
|
|
718
881
|
};
|
|
719
882
|
}
|
|
@@ -721,7 +884,7 @@ async function captureSpecSnapshots(mutations) {
|
|
|
721
884
|
}
|
|
722
885
|
}));
|
|
723
886
|
}
|
|
724
|
-
async function restoreSpecSnapshots(snapshots) {
|
|
887
|
+
async function restoreSpecSnapshots(snapshots, mainSpecsDir) {
|
|
725
888
|
const errors = [];
|
|
726
889
|
for (const snapshot of [...snapshots].reverse()) {
|
|
727
890
|
try {
|
|
@@ -799,6 +962,10 @@ async function restoreSpecSnapshots(snapshots) {
|
|
|
799
962
|
}
|
|
800
963
|
if (!snapshot.existed) {
|
|
801
964
|
await fs.rm(snapshot.target, { force: true });
|
|
965
|
+
// Only a capability directory this write created is ours to take back.
|
|
966
|
+
// One the user already had stays, empty or not, with its own mode -
|
|
967
|
+
// pruneEmptyDirs never removes its boundary.
|
|
968
|
+
await pruneEmptyDirs(path.dirname(snapshot.target), snapshot.pruneBoundary ?? mainSpecsDir);
|
|
802
969
|
continue;
|
|
803
970
|
}
|
|
804
971
|
if (snapshot.symlink !== undefined) {
|
|
@@ -958,6 +1125,13 @@ export class ArchiveCommand {
|
|
|
958
1125
|
if (nested) {
|
|
959
1126
|
throw new ArchiveBlockedError('archive_change_is_namespace_folder', `Cannot archive '${changeName}': ${describeNestedChange(nested)}`, `Rename openspec/changes/${nested.nested[0]}/ to a flat change directory, then archive it.`);
|
|
960
1127
|
}
|
|
1128
|
+
const unknownMetadataKeys = readUnknownChangeMetadataKeys(changeDir);
|
|
1129
|
+
const unknownMetadataWarning = unknownMetadataKeys.length > 0
|
|
1130
|
+
? formatUnknownChangeMetadataKeysMessage(unknownMetadataKeys)
|
|
1131
|
+
: undefined;
|
|
1132
|
+
if (unknownMetadataWarning && !json) {
|
|
1133
|
+
console.warn(chalk.yellow(unknownMetadataWarning));
|
|
1134
|
+
}
|
|
961
1135
|
const skipValidation = options.validate === false || options.noValidate === true;
|
|
962
1136
|
// Validate specs and change before archiving
|
|
963
1137
|
if (!skipValidation) {
|
|
@@ -1406,7 +1580,7 @@ export class ArchiveCommand {
|
|
|
1406
1580
|
'No files were changed; review the new content and rerun.');
|
|
1407
1581
|
}
|
|
1408
1582
|
}
|
|
1409
|
-
const specSnapshots = await captureSpecSnapshots(mutations);
|
|
1583
|
+
const specSnapshots = await captureSpecSnapshots(mutations, mainSpecsDir);
|
|
1410
1584
|
const specSnapshotsByTarget = new Map(specSnapshots.map((snapshot) => [snapshot.target, snapshot]));
|
|
1411
1585
|
const mutationAttempts = new Set();
|
|
1412
1586
|
try {
|
|
@@ -1611,7 +1785,7 @@ export class ArchiveCommand {
|
|
|
1611
1785
|
throw error;
|
|
1612
1786
|
const rollbackErrors = [];
|
|
1613
1787
|
try {
|
|
1614
|
-
await restoreSpecSnapshots(specSnapshots.filter(({ target }) => mutationAttempts.has(target)));
|
|
1788
|
+
await restoreSpecSnapshots(specSnapshots.filter(({ target }) => mutationAttempts.has(target)), mainSpecsDir);
|
|
1615
1789
|
}
|
|
1616
1790
|
catch (rollbackError) {
|
|
1617
1791
|
rollbackErrors.push(rollbackError instanceof Error
|
|
@@ -1660,7 +1834,13 @@ export class ArchiveCommand {
|
|
|
1660
1834
|
path: archivePath,
|
|
1661
1835
|
specsUpdated,
|
|
1662
1836
|
...(totals ? { totals } : {}),
|
|
1663
|
-
...(specWarnings.length > 0
|
|
1837
|
+
...(specWarnings.length > 0 || unknownMetadataWarning
|
|
1838
|
+
? {
|
|
1839
|
+
warnings: unknownMetadataWarning
|
|
1840
|
+
? [...specWarnings, unknownMetadataWarning]
|
|
1841
|
+
: specWarnings,
|
|
1842
|
+
}
|
|
1843
|
+
: {}),
|
|
1664
1844
|
};
|
|
1665
1845
|
}
|
|
1666
1846
|
finally {
|
|
@@ -32,6 +32,8 @@ export interface ChangeContext {
|
|
|
32
32
|
planningHome?: PlanningHome;
|
|
33
33
|
/** Parsed change metadata, when present */
|
|
34
34
|
metadata?: ChangeMetadata;
|
|
35
|
+
/** Non-fatal metadata diagnostics for text and JSON command surfaces */
|
|
36
|
+
warnings?: string[];
|
|
35
37
|
/**
|
|
36
38
|
* Artifact IDs counted as complete only because the change declares
|
|
37
39
|
* skip_specs, not because their files exist. Kept separate so status can
|
|
@@ -85,6 +87,8 @@ export interface ArtifactInstructions {
|
|
|
85
87
|
skipped?: boolean;
|
|
86
88
|
/** Present only when skipped: tells the consumer not to create the artifact */
|
|
87
89
|
warning?: string;
|
|
90
|
+
/** Non-fatal metadata diagnostics */
|
|
91
|
+
warnings?: string[];
|
|
88
92
|
}
|
|
89
93
|
/**
|
|
90
94
|
* Warning attached to instructions for an artifact skipped via skip_specs.
|
|
@@ -152,6 +156,8 @@ export interface ChangeStatus {
|
|
|
152
156
|
applyRequires: string[];
|
|
153
157
|
/** Status of each artifact */
|
|
154
158
|
artifacts: ArtifactStatus[];
|
|
159
|
+
/** Non-fatal metadata diagnostics */
|
|
160
|
+
warnings?: string[];
|
|
155
161
|
}
|
|
156
162
|
export interface ArtifactPathSummary {
|
|
157
163
|
outputPath: string;
|
|
@@ -211,5 +217,6 @@ export declare function generateInstructions(context: ChangeContext, artifactId:
|
|
|
211
217
|
*/
|
|
212
218
|
export declare function formatChangeStatus(context: ChangeContext, options?: {
|
|
213
219
|
storeId?: string;
|
|
220
|
+
implementationRoot?: string;
|
|
214
221
|
}): ChangeStatus;
|
|
215
222
|
//# sourceMappingURL=instruction-loader.d.ts.map
|
|
@@ -4,7 +4,7 @@ import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js'
|
|
|
4
4
|
import { ArtifactGraph } from './graph.js';
|
|
5
5
|
import { detectCompleted } from './state.js';
|
|
6
6
|
import { isSpecsArtifactPath, resolveArtifactOutputPath, resolveArtifactOutputs, } from './outputs.js';
|
|
7
|
-
import { readChangeMetadata, resolveSchemaForChange } from '../../utils/change-metadata.js';
|
|
7
|
+
import { formatUnknownChangeMetadataKeysMessage, readChangeMetadata, readUnknownChangeMetadataKeys, resolveSchemaForChange, } from '../../utils/change-metadata.js';
|
|
8
8
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
9
9
|
import { buildActionContext, buildNextSteps, summarizePlanningHome, } from '../change-status-policy.js';
|
|
10
10
|
import { readProjectConfig, validateConfigRules } from '../project-config.js';
|
|
@@ -78,6 +78,10 @@ export function loadTemplate(schemaName, templatePath, projectRoot) {
|
|
|
78
78
|
export function loadChangeContext(projectRoot, changeName, schemaName, options = {}) {
|
|
79
79
|
const changeDir = FileSystemUtils.canonicalizeExistingPath(options.changeDir ?? path.join(projectRoot, 'openspec', 'changes', changeName));
|
|
80
80
|
const metadata = readChangeMetadata(changeDir, projectRoot) ?? undefined;
|
|
81
|
+
const unknownMetadataKeys = readUnknownChangeMetadataKeys(changeDir);
|
|
82
|
+
const warnings = unknownMetadataKeys.length > 0
|
|
83
|
+
? [formatUnknownChangeMetadataKeysMessage(unknownMetadataKeys)]
|
|
84
|
+
: [];
|
|
81
85
|
const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName, projectRoot, {
|
|
82
86
|
metadata: metadata ?? null,
|
|
83
87
|
projectConfig: options.projectConfig,
|
|
@@ -107,6 +111,7 @@ export function loadChangeContext(projectRoot, changeName, schemaName, options =
|
|
|
107
111
|
projectRoot,
|
|
108
112
|
...(options.planningHome ? { planningHome: options.planningHome } : {}),
|
|
109
113
|
...(metadata ? { metadata } : {}),
|
|
114
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
110
115
|
...(skippedArtifacts.size > 0 ? { skippedArtifacts } : {}),
|
|
111
116
|
};
|
|
112
117
|
}
|
|
@@ -164,6 +169,7 @@ export function generateInstructions(context, artifactId, projectRoot, options =
|
|
|
164
169
|
context: configContext,
|
|
165
170
|
rules: configRules,
|
|
166
171
|
...(options.references !== undefined ? { references: options.references } : {}),
|
|
172
|
+
...(context.warnings ? { warnings: context.warnings } : {}),
|
|
167
173
|
...(context.skippedArtifacts?.has(artifact.id)
|
|
168
174
|
? { skipped: true, warning: SKIP_SPECS_INSTRUCTIONS_WARNING }
|
|
169
175
|
: {}),
|
|
@@ -279,8 +285,19 @@ export function formatChangeStatus(context, options = {}) {
|
|
|
279
285
|
actionContext: buildActionContext({
|
|
280
286
|
projectRoot: context.projectRoot,
|
|
281
287
|
artifactIds,
|
|
288
|
+
...(options.storeId
|
|
289
|
+
? {
|
|
290
|
+
store: {
|
|
291
|
+
id: options.storeId,
|
|
292
|
+
...(options.implementationRoot
|
|
293
|
+
? { implementationRoot: options.implementationRoot }
|
|
294
|
+
: {}),
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
: {}),
|
|
282
298
|
}),
|
|
283
299
|
artifacts: artifactStatuses,
|
|
300
|
+
...(context.warnings ? { warnings: context.warnings } : {}),
|
|
284
301
|
};
|
|
285
302
|
}
|
|
286
303
|
//# sourceMappingURL=instruction-loader.js.map
|
|
@@ -2,11 +2,31 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import fg from 'fast-glob';
|
|
4
4
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
5
|
+
const EXTGLOB_RE = /[!*+?@]\([^(]*\)/u;
|
|
6
|
+
const BRACE_EXPANSION_SEPARATORS_RE = /,|\.\./u;
|
|
7
|
+
function hasBraceExpansion(pattern) {
|
|
8
|
+
const openings = [];
|
|
9
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
10
|
+
if (pattern[index] === '{')
|
|
11
|
+
openings.push(index);
|
|
12
|
+
if (pattern[index] !== '}')
|
|
13
|
+
continue;
|
|
14
|
+
const opening = openings.pop();
|
|
15
|
+
if (opening !== undefined && BRACE_EXPANSION_SEPARATORS_RE.test(pattern.slice(opening, index))) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
5
21
|
/**
|
|
6
|
-
*
|
|
22
|
+
* Recognizes artifact globs while preserving literal output filenames.
|
|
7
23
|
*/
|
|
8
24
|
export function isGlobPattern(pattern) {
|
|
9
|
-
|
|
25
|
+
// Keep the original wildcard rules and recognize brace expansions and extglobs.
|
|
26
|
+
// Its full dynamic predicate also reinterprets literal !, parentheses, and backslashes.
|
|
27
|
+
const normalized = FileSystemUtils.toPosixPath(pattern);
|
|
28
|
+
return normalized.includes('*') || normalized.includes('?') || normalized.includes('[')
|
|
29
|
+
|| EXTGLOB_RE.test(normalized) || hasBraceExpansion(normalized);
|
|
10
30
|
}
|
|
11
31
|
/**
|
|
12
32
|
* Returns whether an artifact generates files under the change's specs/ tree.
|
|
@@ -82,16 +102,26 @@ export function resolveArtifactOutputs(changeDir, generates) {
|
|
|
82
102
|
}
|
|
83
103
|
}
|
|
84
104
|
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
|
|
85
|
-
|
|
86
|
-
const matches = fg
|
|
87
|
-
.sync(normalizedPattern, {
|
|
105
|
+
const globOptions = {
|
|
88
106
|
cwd: changeDir,
|
|
89
107
|
onlyFiles: true,
|
|
90
108
|
absolute: true,
|
|
91
|
-
// Preserve
|
|
92
|
-
// concrete match is canonically confined below before it is returned.
|
|
109
|
+
// Preserve linked artifact directories; confine traversal and concrete matches.
|
|
93
110
|
followSymbolicLinks: true,
|
|
94
|
-
}
|
|
111
|
+
};
|
|
112
|
+
// Task generation expands braces without accessing the filesystem. Validate
|
|
113
|
+
// every task base before globbing, including paths introduced by expansion.
|
|
114
|
+
const tasks = fg.generateTasks(normalizedPattern, globOptions);
|
|
115
|
+
for (const task of tasks) {
|
|
116
|
+
FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, task.base));
|
|
117
|
+
}
|
|
118
|
+
for (const task of tasks) {
|
|
119
|
+
for (const positivePattern of task.positive) {
|
|
120
|
+
assertGlobDirectoryTraversal(changeDir, changeDir, positivePattern.split('/').slice(0, -1));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const matches = fg
|
|
124
|
+
.sync(normalizedPattern, globOptions)
|
|
95
125
|
.map((match) => {
|
|
96
126
|
const normalizedMatch = path.normalize(match);
|
|
97
127
|
FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
|
|
@@ -5,6 +5,9 @@ export declare const InitiativeLinkSchema: z.ZodObject<{
|
|
|
5
5
|
id: z.ZodString;
|
|
6
6
|
}, z.core.$strict>;
|
|
7
7
|
export type InitiativeLink = z.infer<typeof InitiativeLinkSchema>;
|
|
8
|
+
/** Top-level keys ChangeMetadataSchema recognizes. Anything else is ignored. */
|
|
9
|
+
export declare const CHANGE_METADATA_KNOWN_KEYS: readonly ["schema", "created", "goal", "affected_areas", "initiative", "skip_specs", "retire_capabilities"];
|
|
10
|
+
export type ChangeMetadataKnownKey = (typeof CHANGE_METADATA_KNOWN_KEYS)[number];
|
|
8
11
|
export declare const ChangeMetadataSchema: z.ZodObject<{
|
|
9
12
|
schema: z.ZodString;
|
|
10
13
|
created: z.ZodOptional<z.ZodString>;
|
|
@@ -13,6 +13,16 @@ export const InitiativeLinkSchema = z.object({
|
|
|
13
13
|
store: KebabIdentifierSchema('Store id'),
|
|
14
14
|
id: KebabIdentifierSchema('Initiative id'),
|
|
15
15
|
}).strict();
|
|
16
|
+
/** Top-level keys ChangeMetadataSchema recognizes. Anything else is ignored. */
|
|
17
|
+
export const CHANGE_METADATA_KNOWN_KEYS = [
|
|
18
|
+
'schema',
|
|
19
|
+
'created',
|
|
20
|
+
'goal',
|
|
21
|
+
'affected_areas',
|
|
22
|
+
'initiative',
|
|
23
|
+
'skip_specs',
|
|
24
|
+
'retire_capabilities',
|
|
25
|
+
];
|
|
16
26
|
// Per-change metadata schema. The schema field is validated against available
|
|
17
27
|
// workflow schemas when metadata is read or written.
|
|
18
28
|
export const ChangeMetadataSchema = z.object({
|
|
@@ -30,6 +30,14 @@ export interface ChangeNextStepsInput {
|
|
|
30
30
|
export interface ActionContextInput {
|
|
31
31
|
projectRoot: string;
|
|
32
32
|
artifactIds: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Set when the root is a store: the store holds the planning artifacts,
|
|
35
|
+
* and `implementationRoot` is the project that declares it, if any.
|
|
36
|
+
*/
|
|
37
|
+
store?: {
|
|
38
|
+
id: string;
|
|
39
|
+
implementationRoot?: string;
|
|
40
|
+
};
|
|
33
41
|
}
|
|
34
42
|
export declare function summarizePlanningHome(planningHome: PlanningHome | undefined): PlanningHomeSummary | undefined;
|
|
35
43
|
export declare function buildActionContext(input: ActionContextInput): ActionContext;
|