@fission-ai/openspec 1.13.0 → 1.13.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/README.md +16 -4
- package/dist/commands/change.js +12 -1
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +161 -22
- package/dist/commands/feedback.js +6 -4
- package/dist/commands/schema.js +30 -9
- package/dist/commands/store.js +5 -2
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/workflow/instructions.js +69 -22
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.d.ts +5 -0
- package/dist/commands/workflow/shared.js +8 -0
- package/dist/commands/workflow/status.d.ts +5 -1
- package/dist/commands/workflow/status.js +42 -5
- package/dist/core/archive.js +260 -77
- package/dist/core/artifact-graph/outputs.d.ts +1 -1
- package/dist/core/artifact-graph/outputs.js +38 -8
- package/dist/core/artifact-graph/schema.d.ts +14 -0
- package/dist/core/artifact-graph/schema.js +52 -0
- package/dist/core/artifact-graph/types.js +12 -1
- package/dist/core/change-status-policy.d.ts +14 -0
- package/dist/core/change-status-policy.js +14 -6
- package/dist/core/command-generation/adapters/continue.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/generator.js +2 -0
- package/dist/core/completion-tip.js +7 -7
- package/dist/core/completions/installers/bash-installer.js +15 -7
- package/dist/core/completions/installers/shell-quote.d.ts +11 -0
- package/dist/core/completions/installers/shell-quote.js +13 -0
- package/dist/core/completions/installers/zsh-installer.js +3 -2
- package/dist/core/config-schema.d.ts +1 -1
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +27 -1
- package/dist/core/global-config.d.ts +30 -2
- package/dist/core/global-config.js +57 -3
- package/dist/core/init.js +22 -6
- package/dist/core/legacy-cleanup.d.ts +5 -1
- package/dist/core/legacy-cleanup.js +207 -14
- package/dist/core/list.js +55 -11
- package/dist/core/migration.js +6 -1
- package/dist/core/parsers/change-parser.d.ts +12 -1
- package/dist/core/parsers/change-parser.js +85 -87
- package/dist/core/parsers/markdown-parser.js +4 -3
- package/dist/core/parsers/requirement-blocks.d.ts +78 -6
- package/dist/core/parsers/requirement-blocks.js +155 -38
- package/dist/core/parsers/requirement-text.d.ts +13 -2
- package/dist/core/parsers/requirement-text.js +34 -7
- package/dist/core/parsers/spec-structure.js +4 -1
- package/dist/core/project-config.d.ts +9 -1
- package/dist/core/project-config.js +11 -2
- package/dist/core/references.d.ts +20 -0
- package/dist/core/references.js +56 -0
- package/dist/core/shared/skill-content-equivalence.js +5 -1
- package/dist/core/shared/skill-generation.js +36 -8
- package/dist/core/shared/tool-detection.js +9 -3
- package/dist/core/specs-apply.d.ts +20 -0
- package/dist/core/specs-apply.js +101 -17
- package/dist/core/store/git.d.ts +33 -0
- package/dist/core/store/git.js +60 -6
- package/dist/core/store/operations.d.ts +1 -1
- package/dist/core/store/operations.js +32 -10
- package/dist/core/store/registry.d.ts +2 -1
- package/dist/core/store/registry.js +5 -1
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/workflows/apply-change.js +18 -3
- package/dist/core/templates/workflows/archive-change.js +70 -21
- package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
- package/dist/core/templates/workflows/continue-change.js +14 -4
- package/dist/core/templates/workflows/explore.js +34 -16
- package/dist/core/templates/workflows/ff-change.js +21 -5
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +76 -41
- package/dist/core/templates/workflows/project-root.d.ts +29 -0
- package/dist/core/templates/workflows/project-root.js +38 -0
- package/dist/core/templates/workflows/propose.js +22 -5
- package/dist/core/templates/workflows/sync-specs.js +40 -1
- package/dist/core/templates/workflows/update-change.js +66 -27
- package/dist/core/templates/workflows/verify-change.js +121 -49
- package/dist/core/update.d.ts +12 -0
- package/dist/core/update.js +78 -10
- package/dist/core/validation/purpose-placeholder.js +24 -9
- package/dist/core/validation/task-checkboxes.d.ts +34 -0
- package/dist/core/validation/task-checkboxes.js +181 -0
- package/dist/core/validation/validator.d.ts +53 -1
- package/dist/core/validation/validator.js +160 -45
- package/dist/core/version-check.d.ts +14 -7
- package/dist/core/version-check.js +63 -17
- package/dist/core/worksets.js +6 -4
- package/dist/prompts/searchable-multi-select.d.ts +7 -0
- package/dist/prompts/searchable-multi-select.js +35 -6
- package/dist/telemetry/config.js +14 -2
- package/dist/telemetry/index.d.ts +4 -3
- package/dist/telemetry/index.js +22 -10
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/utils/file-system.js +20 -3
- package/dist/utils/line-endings.d.ts +34 -0
- package/dist/utils/line-endings.js +46 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +186 -0
- package/dist/utils/spec-discovery.d.ts +20 -0
- package/dist/utils/spec-discovery.js +59 -0
- package/dist/utils/task-progress.d.ts +1 -1
- package/dist/utils/task-progress.js +38 -6
- package/package.json +5 -5
- package/schemas/spec-driven/schema.yaml +18 -3
- package/schemas/spec-driven/templates/design.md +2 -0
- package/schemas/spec-driven/templates/proposal.md +2 -0
- package/schemas/spec-driven/templates/spec.md +2 -0
- package/schemas/spec-driven/templates/tasks.md +2 -0
package/dist/core/archive.js
CHANGED
|
@@ -7,12 +7,13 @@ 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';
|
|
11
|
-
import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
|
10
|
+
import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, finalizeRetiredSpec, pruneEmptyDirs, } from './specs-apply.js';
|
|
11
|
+
import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
|
|
12
12
|
import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } 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';
|
|
16
|
+
import { describeNestedChange, findNestedChangesIn, } from '../utils/nested-change.js';
|
|
16
17
|
function isMissingPathError(error) {
|
|
17
18
|
return (typeof error === 'object' &&
|
|
18
19
|
error !== null &&
|
|
@@ -288,6 +289,14 @@ async function copyDirContents(src, dest) {
|
|
|
288
289
|
}
|
|
289
290
|
await fs.chmod(dest, sourceStat.mode & 0o7777);
|
|
290
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
|
+
}
|
|
291
300
|
async function fingerprintDirectoryContents(root) {
|
|
292
301
|
const hash = createHash('sha256');
|
|
293
302
|
const updateHashField = (label, value) => {
|
|
@@ -300,13 +309,7 @@ async function fingerprintDirectoryContents(root) {
|
|
|
300
309
|
hash.update(labelBuffer);
|
|
301
310
|
hash.update(valueBuffer);
|
|
302
311
|
};
|
|
303
|
-
const fingerprintFile =
|
|
304
|
-
const fileHash = createHash('sha256');
|
|
305
|
-
for await (const chunk of createReadStream(filePath)) {
|
|
306
|
-
fileHash.update(chunk);
|
|
307
|
-
}
|
|
308
|
-
return fileHash.digest();
|
|
309
|
-
};
|
|
312
|
+
const fingerprintFile = fingerprintFileContents;
|
|
310
313
|
const visit = async (dir, relativeDir) => {
|
|
311
314
|
const before = await fs.lstat(dir, { bigint: true });
|
|
312
315
|
if (!before.isDirectory()) {
|
|
@@ -366,15 +369,198 @@ async function assertCopiedDirectoryUnchanged(stagedSource, destination, expecte
|
|
|
366
369
|
}
|
|
367
370
|
/**
|
|
368
371
|
* Move a directory from src to dest. On Windows, fs.rename() can fail with
|
|
369
|
-
* EPERM, and cross-device moves fail with EXDEV.
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
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.
|
|
373
379
|
*/
|
|
374
380
|
class MoveDestinationRetainedError extends Error {
|
|
375
381
|
}
|
|
376
382
|
class RetirementBackupsRetainedError extends Error {
|
|
377
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
|
+
}
|
|
378
564
|
async function moveDirectory(src, dest, options = {}) {
|
|
379
565
|
try {
|
|
380
566
|
await fs.rename(src, dest);
|
|
@@ -386,71 +572,24 @@ async function moveDirectory(src, dest, options = {}) {
|
|
|
386
572
|
if (code === 'ENOTEMPTY' || code === 'EEXIST') {
|
|
387
573
|
throw new ArchiveBlockedError('archive_target_exists', `Archive '${path.basename(dest)}' already exists.`);
|
|
388
574
|
}
|
|
389
|
-
if (code
|
|
575
|
+
if (isFallbackRenameCode(code)) {
|
|
390
576
|
const stagedSource = path.join(path.dirname(src), `.openspec-move-${randomUUID()}`);
|
|
391
577
|
try {
|
|
392
578
|
await fs.rename(src, stagedSource);
|
|
393
579
|
}
|
|
394
580
|
catch (stageError) {
|
|
581
|
+
const stageCode = stageError?.code;
|
|
582
|
+
if (isFallbackRenameCode(stageCode)) {
|
|
583
|
+
await copyThenRemoveDirectory(src, dest, options);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
395
586
|
throw new Error(`Could not safely stage ${src} before the fallback archive copy ` +
|
|
396
587
|
`(${stageError instanceof Error ? stageError.message : String(stageError)}). ` +
|
|
397
588
|
'No fallback copy was attempted.');
|
|
398
589
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
stagedFingerprint = await fingerprintDirectoryContents(stagedSource);
|
|
403
|
-
await fs.mkdir(dest, { mode: 0o700 });
|
|
404
|
-
destIsOurs = true;
|
|
405
|
-
await copyDirContents(stagedSource, dest);
|
|
406
|
-
await options.verifyCopiedDestination?.(stagedSource);
|
|
407
|
-
await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint);
|
|
408
|
-
}
|
|
409
|
-
catch (copyError) {
|
|
410
|
-
if (destIsOurs) {
|
|
411
|
-
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
|
412
|
-
}
|
|
413
|
-
try {
|
|
414
|
-
await fs.rename(stagedSource, src);
|
|
415
|
-
}
|
|
416
|
-
catch (restoreError) {
|
|
417
|
-
throw new Error(`${copyError instanceof Error ? copyError.message : String(copyError)} ` +
|
|
418
|
-
`Could not restore the staged source at ${stagedSource} ` +
|
|
419
|
-
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
|
|
420
|
-
}
|
|
421
|
-
if (copyError.code === 'EEXIST') {
|
|
422
|
-
throw new ArchiveBlockedError('archive_target_exists', `Archive '${path.basename(dest)}' already exists.`);
|
|
423
|
-
}
|
|
424
|
-
throw copyError;
|
|
425
|
-
}
|
|
426
|
-
try {
|
|
427
|
-
await options.verifyCopiedDestination?.(stagedSource);
|
|
428
|
-
await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint);
|
|
429
|
-
}
|
|
430
|
-
catch (verificationError) {
|
|
431
|
-
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
|
432
|
-
try {
|
|
433
|
-
await fs.rename(stagedSource, src);
|
|
434
|
-
}
|
|
435
|
-
catch (restoreError) {
|
|
436
|
-
throw new Error(`${verificationError instanceof Error ? verificationError.message : String(verificationError)} ` +
|
|
437
|
-
`Could not restore the staged source at ${stagedSource} ` +
|
|
438
|
-
`(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
|
|
439
|
-
}
|
|
440
|
-
throw verificationError;
|
|
441
|
-
}
|
|
442
|
-
try {
|
|
443
|
-
await fs.rm(stagedSource, { recursive: true, force: true });
|
|
444
|
-
}
|
|
445
|
-
catch (cleanupError) {
|
|
446
|
-
// Recursive removal may already have deleted part of the source. The
|
|
447
|
-
// destination is now the only complete copy, so never erase it while
|
|
448
|
-
// trying to make this failed move look atomic.
|
|
449
|
-
throw new MoveDestinationRetainedError(`Copied ${src} to ${dest}, but could not remove the staged source at ` +
|
|
450
|
-
`${stagedSource} completely ` +
|
|
451
|
-
`(${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}). ` +
|
|
452
|
-
'The complete destination was retained for recovery.');
|
|
453
|
-
}
|
|
590
|
+
await copyThenRemoveDirectory(stagedSource, dest, options, async () => {
|
|
591
|
+
await fs.rename(stagedSource, src);
|
|
592
|
+
});
|
|
454
593
|
}
|
|
455
594
|
else {
|
|
456
595
|
throw err;
|
|
@@ -472,6 +611,10 @@ async function assertArchiveDestinationAvailable(archivePath, archiveName) {
|
|
|
472
611
|
function archiveClaimPath(archivePath, _archiveName) {
|
|
473
612
|
return path.join(path.dirname(archivePath), '.openspec-archive.lock');
|
|
474
613
|
}
|
|
614
|
+
function isSameArchiveClaimFile(first, second) {
|
|
615
|
+
return (first.ino === second.ino &&
|
|
616
|
+
(first.dev === second.dev || first.dev === 0n || second.dev === 0n));
|
|
617
|
+
}
|
|
475
618
|
async function releaseArchiveClaim(claim, claimPath) {
|
|
476
619
|
const owned = await claim.handle.stat({ bigint: true }).catch(() => undefined);
|
|
477
620
|
await claim.handle.close().catch(() => undefined);
|
|
@@ -484,10 +627,8 @@ async function releaseArchiveClaim(claim, claimPath) {
|
|
|
484
627
|
const current = await fs.lstat(claimPath, { bigint: true });
|
|
485
628
|
const contents = await fs.readFile(claimPath, 'utf8');
|
|
486
629
|
const currentAfterRead = await fs.lstat(claimPath, { bigint: true });
|
|
487
|
-
if (current
|
|
488
|
-
current
|
|
489
|
-
current.dev === currentAfterRead.dev &&
|
|
490
|
-
current.ino === currentAfterRead.ino &&
|
|
630
|
+
if (isSameArchiveClaimFile(current, owned) &&
|
|
631
|
+
isSameArchiveClaimFile(current, currentAfterRead) &&
|
|
491
632
|
contents === claim.contents) {
|
|
492
633
|
await fs.unlink(claimPath);
|
|
493
634
|
}
|
|
@@ -665,7 +806,29 @@ async function assertDistinctMutationTargets(mutations) {
|
|
|
665
806
|
owners.set(identity, mutation.update.id);
|
|
666
807
|
}
|
|
667
808
|
}
|
|
668
|
-
|
|
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) {
|
|
669
832
|
return Promise.all(mutations.map(async ({ update, outcome, rebuilt }) => {
|
|
670
833
|
try {
|
|
671
834
|
const stat = await fs.lstat(update.target);
|
|
@@ -713,6 +876,7 @@ async function captureSpecSnapshots(mutations) {
|
|
|
713
876
|
target: update.target,
|
|
714
877
|
existed: false,
|
|
715
878
|
outcome,
|
|
879
|
+
pruneBoundary: await deepestExistingAncestor(path.dirname(update.target), mainSpecsDir),
|
|
716
880
|
...(outcome === 'write' ? { expectedContent: Buffer.from(rebuilt) } : {}),
|
|
717
881
|
};
|
|
718
882
|
}
|
|
@@ -720,7 +884,7 @@ async function captureSpecSnapshots(mutations) {
|
|
|
720
884
|
}
|
|
721
885
|
}));
|
|
722
886
|
}
|
|
723
|
-
async function restoreSpecSnapshots(snapshots) {
|
|
887
|
+
async function restoreSpecSnapshots(snapshots, mainSpecsDir) {
|
|
724
888
|
const errors = [];
|
|
725
889
|
for (const snapshot of [...snapshots].reverse()) {
|
|
726
890
|
try {
|
|
@@ -798,6 +962,10 @@ async function restoreSpecSnapshots(snapshots) {
|
|
|
798
962
|
}
|
|
799
963
|
if (!snapshot.existed) {
|
|
800
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);
|
|
801
969
|
continue;
|
|
802
970
|
}
|
|
803
971
|
if (snapshot.symlink !== undefined) {
|
|
@@ -949,6 +1117,14 @@ export class ArchiveCommand {
|
|
|
949
1117
|
? `Change '${changeName}' not found. Available changes: ${available.join(', ')}`
|
|
950
1118
|
: `Change '${changeName}' not found. No active changes exist in this root.`);
|
|
951
1119
|
}
|
|
1120
|
+
// Archiving a namespace folder moves an active, unfinished change into the
|
|
1121
|
+
// archive under a name nobody will look for, and never applies its deltas.
|
|
1122
|
+
// That is silent data loss, so it is refused outright rather than warned
|
|
1123
|
+
// about (#1846).
|
|
1124
|
+
const nested = await findNestedChangesIn(changesDir, changeName);
|
|
1125
|
+
if (nested) {
|
|
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.`);
|
|
1127
|
+
}
|
|
952
1128
|
const skipValidation = options.validate === false || options.noValidate === true;
|
|
953
1129
|
// Validate specs and change before archiving
|
|
954
1130
|
if (!skipValidation) {
|
|
@@ -993,6 +1169,13 @@ export class ArchiveCommand {
|
|
|
993
1169
|
// folder, so only a regular file counts.
|
|
994
1170
|
const rootSpecStat = await fs.stat(path.join(changeSpecsDir, 'spec.md')).catch(() => null);
|
|
995
1171
|
let hasDeltaSpecs = rootSpecStat?.isFile() === true;
|
|
1172
|
+
// Likewise for delta sections in any other file the merge path does not
|
|
1173
|
+
// read (specs/<capability>.md, a note beside spec.md): without this the
|
|
1174
|
+
// zero-delta leniency below archives the change as done with nothing
|
|
1175
|
+
// merged, although validate rejects it.
|
|
1176
|
+
if (!hasDeltaSpecs) {
|
|
1177
|
+
hasDeltaSpecs = (await findUnreadDeltaFiles(changeSpecsDir)).length > 0;
|
|
1178
|
+
}
|
|
996
1179
|
// A change that declares skip_specs must not carry any file under
|
|
997
1180
|
// specs/ — validate reports that as a conflict, so archive has to run
|
|
998
1181
|
// the same check instead of skipping validation because the files
|
|
@@ -1390,7 +1573,7 @@ export class ArchiveCommand {
|
|
|
1390
1573
|
'No files were changed; review the new content and rerun.');
|
|
1391
1574
|
}
|
|
1392
1575
|
}
|
|
1393
|
-
const specSnapshots = await captureSpecSnapshots(mutations);
|
|
1576
|
+
const specSnapshots = await captureSpecSnapshots(mutations, mainSpecsDir);
|
|
1394
1577
|
const specSnapshotsByTarget = new Map(specSnapshots.map((snapshot) => [snapshot.target, snapshot]));
|
|
1395
1578
|
const mutationAttempts = new Set();
|
|
1396
1579
|
try {
|
|
@@ -1595,7 +1778,7 @@ export class ArchiveCommand {
|
|
|
1595
1778
|
throw error;
|
|
1596
1779
|
const rollbackErrors = [];
|
|
1597
1780
|
try {
|
|
1598
|
-
await restoreSpecSnapshots(specSnapshots.filter(({ target }) => mutationAttempts.has(target)));
|
|
1781
|
+
await restoreSpecSnapshots(specSnapshots.filter(({ target }) => mutationAttempts.has(target)), mainSpecsDir);
|
|
1599
1782
|
}
|
|
1600
1783
|
catch (rollbackError) {
|
|
1601
1784
|
rollbackErrors.push(rollbackError instanceof Error
|
|
@@ -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);
|
|
@@ -10,4 +10,18 @@ export declare function loadSchema(filePath: string): SchemaYaml;
|
|
|
10
10
|
* Parses and validates an artifact schema from YAML content.
|
|
11
11
|
*/
|
|
12
12
|
export declare function parseSchema(yamlContent: string): SchemaYaml;
|
|
13
|
+
/**
|
|
14
|
+
* Describes an `apply.tracks` value that is not exactly equal to any artifact's
|
|
15
|
+
* `generates` value, or returns undefined when there is nothing to report.
|
|
16
|
+
*
|
|
17
|
+
* The tracked-tasks lookups select the artifact whose `generates` string equals
|
|
18
|
+
* `tracks`, so this is a progress-discovery problem, not a claim that nothing
|
|
19
|
+
* produces the file: a glob `generates: tasks/*.md` really does generate
|
|
20
|
+
* `tracks: tasks/main.md`, yet the strings differ, so the lookup still misses.
|
|
21
|
+
* Either way apply keeps working (it reads the path directly), but `openspec
|
|
22
|
+
* list` and `openspec status` fall back to counting the top-level `tasks.md`,
|
|
23
|
+
* and apply's remedy cannot name an artifact to build. A typo such as
|
|
24
|
+
* `task.md` is the other usual cause.
|
|
25
|
+
*/
|
|
26
|
+
export declare function findApplyTracksWarning(schema: SchemaYaml): string | undefined;
|
|
13
27
|
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -30,6 +30,8 @@ export function parseSchema(yamlContent) {
|
|
|
30
30
|
validateNoDuplicateIds(schema.artifacts);
|
|
31
31
|
// Check that all requires references are valid
|
|
32
32
|
validateRequiresReferences(schema.artifacts);
|
|
33
|
+
// Check that the apply phase names artifacts this schema declares
|
|
34
|
+
validateApplyReferences(schema);
|
|
33
35
|
// Check for cycles
|
|
34
36
|
validateNoCycles(schema.artifacts);
|
|
35
37
|
return schema;
|
|
@@ -59,6 +61,56 @@ function validateRequiresReferences(artifacts) {
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Validates that every `apply.requires` id is an artifact the schema declares.
|
|
66
|
+
*
|
|
67
|
+
* Apply skips an id that no artifact declares, so a typo silently dropped that
|
|
68
|
+
* artifact from the apply gate. An unknown artifact `requires` is already a
|
|
69
|
+
* load error, and this is the same kind of reference.
|
|
70
|
+
*
|
|
71
|
+
* `apply.tracks` is deliberately not checked here. It is a path, not an id:
|
|
72
|
+
* apply reads it as written, so a schema whose `tracks` value does not exactly
|
|
73
|
+
* match any `generates` value (a hand-written `TODO.md`, or `tasks/main.md`
|
|
74
|
+
* under a glob `generates: tasks/*.md` that really does produce it) works
|
|
75
|
+
* today, and failing the load would break every command on it.
|
|
76
|
+
* `openspec schema validate` reports that case as a warning instead
|
|
77
|
+
* (see `findApplyTracksWarning`).
|
|
78
|
+
*/
|
|
79
|
+
function validateApplyReferences(schema) {
|
|
80
|
+
const apply = schema.apply;
|
|
81
|
+
if (!apply)
|
|
82
|
+
return;
|
|
83
|
+
const validIds = schema.artifacts.map(a => a.id);
|
|
84
|
+
for (const req of apply.requires) {
|
|
85
|
+
if (!validIds.includes(req)) {
|
|
86
|
+
throw new SchemaValidationError(`Invalid apply.requires reference: '${req}' does not exist (artifacts: ${validIds.join(', ')})`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Describes an `apply.tracks` value that is not exactly equal to any artifact's
|
|
92
|
+
* `generates` value, or returns undefined when there is nothing to report.
|
|
93
|
+
*
|
|
94
|
+
* The tracked-tasks lookups select the artifact whose `generates` string equals
|
|
95
|
+
* `tracks`, so this is a progress-discovery problem, not a claim that nothing
|
|
96
|
+
* produces the file: a glob `generates: tasks/*.md` really does generate
|
|
97
|
+
* `tracks: tasks/main.md`, yet the strings differ, so the lookup still misses.
|
|
98
|
+
* Either way apply keeps working (it reads the path directly), but `openspec
|
|
99
|
+
* list` and `openspec status` fall back to counting the top-level `tasks.md`,
|
|
100
|
+
* and apply's remedy cannot name an artifact to build. A typo such as
|
|
101
|
+
* `task.md` is the other usual cause.
|
|
102
|
+
*/
|
|
103
|
+
export function findApplyTracksWarning(schema) {
|
|
104
|
+
const tracks = schema.apply?.tracks;
|
|
105
|
+
if (tracks == null || schema.artifacts.some(a => a.generates === tracks))
|
|
106
|
+
return undefined;
|
|
107
|
+
return (`apply.tracks '${tracks}' does not exactly match any artifact's generates value ` +
|
|
108
|
+
`(generates: ${schema.artifacts.map(a => a.generates).join(', ')}), ` +
|
|
109
|
+
`so OpenSpec cannot tell which artifact's progress it tracks. ` +
|
|
110
|
+
`Apply still reads that file as written, but list and status count tasks.md instead. ` +
|
|
111
|
+
`Make apply.tracks exactly equal one of those generates values, ` +
|
|
112
|
+
`or confirm that file is maintained outside the artifact graph.`);
|
|
113
|
+
}
|
|
62
114
|
/**
|
|
63
115
|
* Validates that there are no cyclic dependencies.
|
|
64
116
|
* Uses DFS to detect cycles and reports the full cycle path.
|
|
@@ -18,6 +18,9 @@ function relativePathSchema(fieldName) {
|
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
20
|
// Artifact definition schema
|
|
21
|
+
// Upper bound on artifacts in one schema. Keeps `validateNoCycles`' recursive
|
|
22
|
+
// DFS well inside the stack limit for any accepted input.
|
|
23
|
+
const MAX_ARTIFACTS = 1000;
|
|
21
24
|
export const ArtifactSchema = z.object({
|
|
22
25
|
id: z.string().min(1, { error: 'Artifact ID is required' }),
|
|
23
26
|
generates: relativePathSchema('generates field'),
|
|
@@ -40,7 +43,15 @@ export const SchemaYamlSchema = z.object({
|
|
|
40
43
|
name: z.string().min(1, { error: 'Schema name is required' }),
|
|
41
44
|
version: z.number().int().positive({ error: 'Version must be a positive integer' }),
|
|
42
45
|
description: z.string().optional(),
|
|
43
|
-
artifacts: z
|
|
46
|
+
artifacts: z
|
|
47
|
+
.array(ArtifactSchema)
|
|
48
|
+
.min(1, { error: 'At least one artifact required' })
|
|
49
|
+
// Bounded so a hostile schema cannot drive the cycle-detection DFS past the
|
|
50
|
+
// V8 stack limit and crash with an uncaught RangeError instead of a
|
|
51
|
+
// validation error.
|
|
52
|
+
.max(MAX_ARTIFACTS, {
|
|
53
|
+
error: `A schema may declare at most ${MAX_ARTIFACTS} artifacts`,
|
|
54
|
+
}),
|
|
44
55
|
// Optional apply phase configuration (for schema-aware apply instructions)
|
|
45
56
|
apply: ApplyPhaseSchema.optional(),
|
|
46
57
|
});
|
|
@@ -33,5 +33,19 @@ export interface ActionContextInput {
|
|
|
33
33
|
}
|
|
34
34
|
export declare function summarizePlanningHome(planningHome: PlanningHome | undefined): PlanningHomeSummary | undefined;
|
|
35
35
|
export declare function buildActionContext(input: ActionContextInput): ActionContext;
|
|
36
|
+
/**
|
|
37
|
+
* The one next action for a change, in both the forms the CLI needs.
|
|
38
|
+
*
|
|
39
|
+
* `sentence` is what the JSON `nextSteps` contract publishes; `command` is the
|
|
40
|
+
* bare command the text surface prints. Both are built here so the two
|
|
41
|
+
* surfaces can never name a different next step.
|
|
42
|
+
*/
|
|
43
|
+
export interface ChangeNextStep {
|
|
44
|
+
/** Ready-to-run command, including any `--store` flag. */
|
|
45
|
+
command: string;
|
|
46
|
+
/** Sentence form carried by the JSON `nextSteps` array. */
|
|
47
|
+
sentence: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function resolveNextStep(input: ChangeNextStepsInput): ChangeNextStep | undefined;
|
|
36
50
|
export declare function buildNextSteps(input: ChangeNextStepsInput): string[];
|
|
37
51
|
//# sourceMappingURL=change-status-policy.d.ts.map
|