@fission-ai/openspec 1.8.0 → 1.9.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/dist/cli/index.d.ts +15 -0
- package/dist/cli/index.js +33 -4
- package/dist/commands/change.js +1 -0
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +5 -1
- package/dist/commands/schema.js +193 -28
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +148 -4
- package/dist/commands/workflow/schemas.d.ts +2 -0
- package/dist/commands/workflow/schemas.js +9 -2
- package/dist/core/archive.js +10 -3
- package/dist/core/artifact-graph/resolver.d.ts +0 -12
- package/dist/core/artifact-graph/resolver.js +14 -0
- package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
- package/dist/core/command-generation/adapters/command-code.js +34 -0
- package/dist/core/command-generation/adapters/index.d.ts +1 -0
- package/dist/core/command-generation/adapters/index.js +1 -0
- package/dist/core/command-generation/registry.js +2 -0
- package/dist/core/completions/command-registry.js +5 -0
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.js +16 -15
- package/dist/core/init.js +19 -7
- package/dist/core/legacy-cleanup.d.ts +10 -0
- package/dist/core/legacy-cleanup.js +48 -26
- package/dist/core/parsers/requirement-blocks.js +41 -9
- package/dist/core/parsers/requirement-text.d.ts +8 -0
- package/dist/core/parsers/requirement-text.js +4 -3
- package/dist/core/root-selection.d.ts +1 -1
- package/dist/core/shared-skill-target.d.ts +15 -0
- package/dist/core/shared-skill-target.js +30 -0
- package/dist/core/specs-apply.js +11 -4
- package/dist/core/templates/workflows/apply-change.js +3 -0
- package/dist/core/templates/workflows/propose.js +2 -2
- package/dist/core/templates/workflows/store-selection.d.ts +1 -1
- package/dist/core/templates/workflows/store-selection.js +1 -1
- package/dist/core/templates/workflows/update-change.js +4 -4
- package/dist/core/update.js +34 -7
- package/dist/core/validation/task-numbering.d.ts +16 -0
- package/dist/core/validation/task-numbering.js +57 -0
- package/dist/core/validation/validator.d.ts +5 -2
- package/dist/core/validation/validator.js +57 -3
- package/dist/telemetry/index.d.ts +3 -1
- package/dist/telemetry/index.js +7 -1
- package/dist/utils/interactive.d.ts +30 -1
- package/dist/utils/interactive.js +105 -2
- package/dist/utils/task-progress.d.ts +34 -1
- package/dist/utils/task-progress.js +52 -28
- package/package.json +1 -1
package/dist/cli/index.d.ts
CHANGED
|
@@ -5,6 +5,21 @@ declare const program: Command;
|
|
|
5
5
|
* For example: 'change show' -> 'change:show'
|
|
6
6
|
*/
|
|
7
7
|
export declare function getCommandPath(command: Command): string;
|
|
8
|
+
/**
|
|
9
|
+
* True when the executing command asked for JSON output — used to suppress the
|
|
10
|
+
* first-run telemetry notice so stdout stays a single valid JSON document.
|
|
11
|
+
*
|
|
12
|
+
* `--json` reaches commands three ways, so a single parsed option is not enough:
|
|
13
|
+
* - declared on the leaf (`openspec status --json`) → `opts().json`
|
|
14
|
+
* - declared on a parent group and read via globals (`openspec workset --json list`)
|
|
15
|
+
* → `optsWithGlobals().json`
|
|
16
|
+
* - a residual arg on a permissive group that never declares the option
|
|
17
|
+
* (`openspec store --json`, which detects it from `command.args`) → `args`
|
|
18
|
+
*
|
|
19
|
+
* Suppressing is always safe: the disclosure is only deferred to the next
|
|
20
|
+
* non-JSON run, never lost, whereas printing it on a JSON run corrupts stdout.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isJsonRun(command: Command): boolean;
|
|
8
23
|
export { program };
|
|
9
24
|
export declare function runCli(argv?: string[]): void;
|
|
10
25
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/cli/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createRequire } from 'module';
|
|
|
4
4
|
import ora from 'ora';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
|
-
import { promises as fs } from 'fs';
|
|
7
|
+
import { existsSync, promises as fs } from 'fs';
|
|
8
8
|
import { AI_TOOLS, TOOL_ID_ALIASES } from '../core/config.js';
|
|
9
9
|
import { UpdateCommand } from '../core/update.js';
|
|
10
10
|
import { getAvailableCliUpdate, displayCliUpdateNote, shouldOfferUpgrade, getInstallDir, offerCliUpgrade, rerunUpdateWithUpgradedCli, displayUpgradeCommand, isSourceCheckout, } from '../core/version-check.js';
|
|
@@ -72,6 +72,24 @@ export function getCommandPath(command) {
|
|
|
72
72
|
}
|
|
73
73
|
return names.join(':') || 'openspec';
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* True when the executing command asked for JSON output — used to suppress the
|
|
77
|
+
* first-run telemetry notice so stdout stays a single valid JSON document.
|
|
78
|
+
*
|
|
79
|
+
* `--json` reaches commands three ways, so a single parsed option is not enough:
|
|
80
|
+
* - declared on the leaf (`openspec status --json`) → `opts().json`
|
|
81
|
+
* - declared on a parent group and read via globals (`openspec workset --json list`)
|
|
82
|
+
* → `optsWithGlobals().json`
|
|
83
|
+
* - a residual arg on a permissive group that never declares the option
|
|
84
|
+
* (`openspec store --json`, which detects it from `command.args`) → `args`
|
|
85
|
+
*
|
|
86
|
+
* Suppressing is always safe: the disclosure is only deferred to the next
|
|
87
|
+
* non-JSON run, never lost, whereas printing it on a JSON run corrupts stdout.
|
|
88
|
+
*/
|
|
89
|
+
export function isJsonRun(command) {
|
|
90
|
+
return (command.optsWithGlobals().json === true ||
|
|
91
|
+
command.args.includes('--json'));
|
|
92
|
+
}
|
|
75
93
|
program
|
|
76
94
|
.name('openspec')
|
|
77
95
|
.description('AI-native system for spec-driven development')
|
|
@@ -87,8 +105,9 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
|
87
105
|
if (opts.color === false) {
|
|
88
106
|
process.env.NO_COLOR = '1';
|
|
89
107
|
}
|
|
90
|
-
// Show first-run telemetry notice (if not seen)
|
|
91
|
-
|
|
108
|
+
// Show first-run telemetry notice (if not seen). Suppress it whenever the run
|
|
109
|
+
// asked for JSON so stdout stays a single valid JSON document (see isJsonRun).
|
|
110
|
+
await maybeShowTelemetryNotice({ silent: isJsonRun(actionCommand) });
|
|
92
111
|
// Track command execution (use actionCommand to get the actual subcommand)
|
|
93
112
|
const commandPath = getCommandPath(actionCommand);
|
|
94
113
|
await trackCommand(commandPath, version);
|
|
@@ -246,6 +265,9 @@ program
|
|
|
246
265
|
const root = await resolveRootForCommand(options ?? {}, {
|
|
247
266
|
json: options?.json,
|
|
248
267
|
failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
|
|
268
|
+
// Preserve the cwd fallback for pre-config.yaml projects. The resolver
|
|
269
|
+
// still lets a registered/default store take precedence over it.
|
|
270
|
+
allowImplicitRoot: existsSync(path.join(process.cwd(), 'openspec', 'project.md')),
|
|
249
271
|
});
|
|
250
272
|
if (!root) {
|
|
251
273
|
return;
|
|
@@ -383,6 +405,7 @@ program
|
|
|
383
405
|
.option('--all', 'Validate all changes and specs')
|
|
384
406
|
.option('--changes', 'Validate all changes')
|
|
385
407
|
.option('--specs', 'Validate all specs')
|
|
408
|
+
.option('--archived', 'Validate that archived changes have all tasks completed (for pre-commit linting)')
|
|
386
409
|
.option('--type <type>', 'Specify item type when ambiguous: change|spec')
|
|
387
410
|
.option('--strict', 'Enable strict validation mode')
|
|
388
411
|
.option('--json', 'Output validation results as JSON')
|
|
@@ -572,12 +595,18 @@ program
|
|
|
572
595
|
.command('schemas')
|
|
573
596
|
.description('List available workflow schemas with descriptions')
|
|
574
597
|
.option('--json', 'Output as JSON (for agent use)')
|
|
598
|
+
.option('--store <id>', STORE_OPTION_DESCRIPTION)
|
|
599
|
+
.addOption(hiddenStorePathOption())
|
|
575
600
|
.action(async (options) => {
|
|
576
601
|
try {
|
|
577
602
|
await schemasCommand(options);
|
|
578
603
|
}
|
|
579
604
|
catch (error) {
|
|
580
|
-
failWithError(error
|
|
605
|
+
failWithError(error, {
|
|
606
|
+
enabled: options.json,
|
|
607
|
+
payload: { schemas: [], root: null },
|
|
608
|
+
fallbackCode: 'schemas_error',
|
|
609
|
+
});
|
|
581
610
|
process.exit(1);
|
|
582
611
|
}
|
|
583
612
|
});
|
package/dist/commands/change.js
CHANGED
|
@@ -246,6 +246,7 @@ export class ChangeCommand {
|
|
|
246
246
|
// Derived from changesPath so the main specs come from the same root the
|
|
247
247
|
// change itself was resolved against.
|
|
248
248
|
mainSpecsDir: path.join(path.dirname(changesPath), 'specs'),
|
|
249
|
+
projectRoot: path.dirname(path.dirname(changesPath)),
|
|
249
250
|
});
|
|
250
251
|
if (options?.json) {
|
|
251
252
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -10,6 +10,11 @@ interface ProfileStateDiff {
|
|
|
10
10
|
hasChanges: boolean;
|
|
11
11
|
lines: string[];
|
|
12
12
|
}
|
|
13
|
+
interface WorkflowPromptMeta {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
}
|
|
17
|
+
export declare const WORKFLOW_PROMPT_META: Record<string, WorkflowPromptMeta>;
|
|
13
18
|
/**
|
|
14
19
|
* Resolve the effective current profile state from global config defaults.
|
|
15
20
|
*/
|
package/dist/commands/config.js
CHANGED
|
@@ -8,7 +8,7 @@ import { OPENSPEC_DIR_NAME } from '../core/config.js';
|
|
|
8
8
|
import { hasProjectConfigDrift } from '../core/profile-sync-drift.js';
|
|
9
9
|
import { UpdateCommand } from '../core/update.js';
|
|
10
10
|
import { asErrorMessage, isPromptCancellationError } from './shared-output.js';
|
|
11
|
-
const WORKFLOW_PROMPT_META = {
|
|
11
|
+
export const WORKFLOW_PROMPT_META = {
|
|
12
12
|
propose: {
|
|
13
13
|
name: 'Propose change',
|
|
14
14
|
description: 'Create proposal, design, and tasks from a request',
|
|
@@ -29,6 +29,10 @@ const WORKFLOW_PROMPT_META = {
|
|
|
29
29
|
name: 'Apply tasks',
|
|
30
30
|
description: 'Implement tasks from the current change',
|
|
31
31
|
},
|
|
32
|
+
update: {
|
|
33
|
+
name: 'Update change',
|
|
34
|
+
description: 'Revise the planning artifacts of an existing change',
|
|
35
|
+
},
|
|
32
36
|
ff: {
|
|
33
37
|
name: 'Fast-forward',
|
|
34
38
|
description: 'Run a faster implementation workflow',
|
package/dist/commands/schema.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
3
4
|
import ora from 'ora';
|
|
4
|
-
import { stringify as stringifyYaml } from 'yaml';
|
|
5
|
+
import { stringify as stringifyYaml, parseDocument } from 'yaml';
|
|
5
6
|
import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js';
|
|
6
7
|
import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
|
|
7
8
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
@@ -240,6 +241,54 @@ function assertSchemaTreeCanBeCopied(src, allowedRoot = src, ancestors = new Set
|
|
|
240
241
|
ancestors.delete(canonicalSrc);
|
|
241
242
|
}
|
|
242
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Produces a stable content fingerprint of a directory: a SHA-256 over every
|
|
246
|
+
* file's relative path AND its bytes (plus directory paths), walked in sorted
|
|
247
|
+
* order. Two directories with byte-identical trees produce the same digest, and
|
|
248
|
+
* ANY change to a file's contents, size, or the set of paths changes it. Used to
|
|
249
|
+
* detect a concurrent modification of a fork destination between the moment the
|
|
250
|
+
* overwrite is authorized and the moment it is actually moved/deleted, so those
|
|
251
|
+
* changes are never silently destroyed.
|
|
252
|
+
*/
|
|
253
|
+
function fingerprintDir(dir) {
|
|
254
|
+
const hash = createHash('sha256');
|
|
255
|
+
const walk = (current, rel) => {
|
|
256
|
+
const entries = fs
|
|
257
|
+
.readdirSync(current, { withFileTypes: true })
|
|
258
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
const abs = path.join(current, entry.name);
|
|
261
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
262
|
+
// Use the entry type from readdir (no separate lstat), then read the file
|
|
263
|
+
// directly — avoiding a stat-then-read check/use gap. Size is derived from
|
|
264
|
+
// the bytes actually read, so the digest still covers content and length.
|
|
265
|
+
if (entry.isDirectory()) {
|
|
266
|
+
hash.update(`D:${relPath}\n`);
|
|
267
|
+
walk(abs, relPath);
|
|
268
|
+
}
|
|
269
|
+
else if (entry.isFile()) {
|
|
270
|
+
const contents = fs.readFileSync(abs);
|
|
271
|
+
hash.update(`F:${relPath}:${contents.length}:`);
|
|
272
|
+
hash.update(contents);
|
|
273
|
+
hash.update('\n');
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// Symlinks / other entry types: record the type + path (and the link
|
|
277
|
+
// target when readable) so a swap of one for another is still detected.
|
|
278
|
+
let target = '';
|
|
279
|
+
try {
|
|
280
|
+
target = fs.readlinkSync(abs);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// Non-symlink or unreadable target; the type marker below suffices.
|
|
284
|
+
}
|
|
285
|
+
hash.update(`O:${relPath}:${target}\n`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
walk(dir, '');
|
|
290
|
+
return hash.digest('hex');
|
|
291
|
+
}
|
|
243
292
|
/**
|
|
244
293
|
* Default artifacts with descriptions for schema init.
|
|
245
294
|
*/
|
|
@@ -556,39 +605,155 @@ export function registerSchemaCommand(program) {
|
|
|
556
605
|
// Validate the complete source before a forced fork removes anything.
|
|
557
606
|
const trustedSourceDir = fs.realpathSync(sourceDir);
|
|
558
607
|
assertSchemaTreeCanBeCopied(trustedSourceDir);
|
|
608
|
+
// Validate the source's schema content up front too, so a structurally
|
|
609
|
+
// invalid source is rejected before the --force path can remove an
|
|
610
|
+
// existing destination. This keeps `fork --force` atomic — an unusable
|
|
611
|
+
// source never destroys a valid destination — matching `schema init`,
|
|
612
|
+
// which likewise validates before it overwrites.
|
|
613
|
+
parseSchema(fs.readFileSync(path.join(trustedSourceDir, 'schema.yaml'), 'utf-8'));
|
|
559
614
|
// Check destination
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
615
|
+
const schemasDir = getProjectSchemasDir(projectRoot);
|
|
616
|
+
const destinationDir = path.join(schemasDir, destinationName);
|
|
617
|
+
// Reject a self-fork. Forking a schema onto itself with --force would
|
|
618
|
+
// otherwise remove the source at the replacement step below and then
|
|
619
|
+
// fail the copy, destroying the only copy of the schema. Resolve both
|
|
620
|
+
// sides to their real paths (realpathSync follows symlinks; path.resolve
|
|
621
|
+
// is a fallback only for a destination that does not exist yet) so a
|
|
622
|
+
// symlink or a `.`/`..` spelling of the same directory is still caught.
|
|
623
|
+
const resolvedDestination = fs.existsSync(destinationDir)
|
|
624
|
+
? fs.realpathSync(destinationDir)
|
|
625
|
+
: path.resolve(destinationDir);
|
|
626
|
+
if (resolvedDestination === trustedSourceDir) {
|
|
627
|
+
throw new Error(`Cannot fork schema '${source}' onto itself; choose a different destination name`);
|
|
628
|
+
}
|
|
629
|
+
const destinationExists = fs.existsSync(destinationDir);
|
|
630
|
+
if (destinationExists && !options?.force) {
|
|
631
|
+
if (options?.json) {
|
|
632
|
+
console.log(JSON.stringify({
|
|
633
|
+
forked: false,
|
|
634
|
+
error: `Schema '${destinationName}' already exists`,
|
|
635
|
+
suggestion: 'Use --force to overwrite',
|
|
636
|
+
}, null, 2));
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
console.error(`Error: Schema '${destinationName}' already exists at ${destinationDir}`);
|
|
640
|
+
console.error('Use --force to overwrite');
|
|
641
|
+
}
|
|
642
|
+
process.exitCode = 1;
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
// Fingerprint the destination the user authorized us to overwrite, BEFORE
|
|
646
|
+
// we spend time staging. Staging can take a while, and a concurrent
|
|
647
|
+
// process may edit the destination in that window; the fingerprint lets
|
|
648
|
+
// us detect such a change and abort rather than clobber it.
|
|
649
|
+
const authorizedDestinationFingerprint = destinationExists
|
|
650
|
+
? fingerprintDir(destinationDir)
|
|
651
|
+
: null;
|
|
652
|
+
// Stage the complete fork in a temporary sibling directory first, then
|
|
653
|
+
// swap it into place. This keeps `fork --force` atomic: an existing
|
|
654
|
+
// destination is only removed once the new fork has been fully copied,
|
|
655
|
+
// name-updated, and (via the up-front parseSchema above) validated. Any
|
|
656
|
+
// failure while staging leaves both the source and the existing
|
|
657
|
+
// destination exactly as they were.
|
|
658
|
+
if (spinner)
|
|
659
|
+
spinner.start(`Forking '${source}' to '${destinationName}'...`);
|
|
660
|
+
fs.mkdirSync(schemasDir, { recursive: true });
|
|
661
|
+
const stagingDir = fs.mkdtempSync(path.join(schemasDir, '.fork-staging-'));
|
|
662
|
+
try {
|
|
663
|
+
copyDirRecursive(trustedSourceDir, stagingDir);
|
|
664
|
+
// Update name in the staged schema.yaml via yaml's Document API
|
|
665
|
+
// instead of re-serializing the parsed object, so block scalars,
|
|
666
|
+
// comments, and key order in the source schema.yaml survive the fork.
|
|
667
|
+
const stagedSchemaPath = path.join(stagingDir, 'schema.yaml');
|
|
668
|
+
const schemaContent = fs.readFileSync(stagedSchemaPath, 'utf-8');
|
|
669
|
+
const doc = parseDocument(schemaContent);
|
|
670
|
+
doc.set('name', destinationName);
|
|
671
|
+
fs.writeFileSync(stagedSchemaPath, doc.toString());
|
|
672
|
+
// Authoritative gate: validate the COMPLETED staged schema — the exact
|
|
673
|
+
// bytes we are about to install — not just the source at the pre-check.
|
|
674
|
+
// The source files copyDirRecursive reads can change mid-copy, so a
|
|
675
|
+
// source that was valid up front can still produce an invalid staged
|
|
676
|
+
// fork. Validating here, before ANY destructive step, guarantees we
|
|
677
|
+
// never install an invalid fork or delete a valid destination for one.
|
|
678
|
+
try {
|
|
679
|
+
parseSchema(fs.readFileSync(stagedSchemaPath, 'utf-8'));
|
|
680
|
+
}
|
|
681
|
+
catch (validationError) {
|
|
682
|
+
throw new Error(`The staged fork of '${source}' is not a valid schema (the source may have changed during copy); ` +
|
|
683
|
+
`aborted, '${destinationName}' was not modified.`, { cause: validationError });
|
|
684
|
+
}
|
|
685
|
+
// Swap the staged fork into place. When a destination already exists,
|
|
686
|
+
// move it aside to a sibling backup FIRST, then install the staged
|
|
687
|
+
// fork; only once the install succeeds is the backup discarded. If the
|
|
688
|
+
// install rename itself fails (e.g. a Windows lock), the backup is
|
|
689
|
+
// moved back so the user's original destination is never lost.
|
|
690
|
+
if (destinationExists) {
|
|
691
|
+
if (spinner)
|
|
692
|
+
spinner.text = `Replacing existing schema '${destinationName}'...`;
|
|
693
|
+
// Revalidate immediately before the destructive move: if the
|
|
694
|
+
// destination changed on disk while we were staging (or was removed),
|
|
695
|
+
// its fingerprint no longer matches what the user authorized. Abort
|
|
696
|
+
// WITHOUT touching it, so the concurrent changes are preserved. The
|
|
697
|
+
// outer catch cleans up staging.
|
|
698
|
+
const currentFingerprint = fs.existsSync(destinationDir)
|
|
699
|
+
? fingerprintDir(destinationDir)
|
|
700
|
+
: null;
|
|
701
|
+
if (currentFingerprint !== authorizedDestinationFingerprint) {
|
|
702
|
+
throw new Error(`Schema '${destinationName}' at ${destinationDir} changed on disk while the fork was being prepared. ` +
|
|
703
|
+
`Aborted to preserve those concurrent changes; nothing was overwritten. Re-run the fork to overwrite the current contents.`);
|
|
704
|
+
}
|
|
705
|
+
const backupDir = `${destinationDir}.fork-backup-${process.pid}-${Date.now()}`;
|
|
706
|
+
fs.renameSync(destinationDir, backupDir);
|
|
707
|
+
try {
|
|
708
|
+
fs.renameSync(stagingDir, destinationDir);
|
|
709
|
+
}
|
|
710
|
+
catch (installError) {
|
|
711
|
+
// Install failed after the original was moved aside. Try to move
|
|
712
|
+
// it back. If that restore ALSO fails, the original is stranded in
|
|
713
|
+
// the backup dir — surface an error naming both the backup and the
|
|
714
|
+
// destination so the user can recover manually, and attach the
|
|
715
|
+
// original install error as the cause. Never swallow this.
|
|
716
|
+
try {
|
|
717
|
+
fs.renameSync(backupDir, destinationDir);
|
|
718
|
+
}
|
|
719
|
+
catch (restoreError) {
|
|
720
|
+
throw new Error(`Failed to install the forked schema and could not restore the previous '${destinationName}'. ` +
|
|
721
|
+
`Your previous schema is preserved at ${backupDir}; move it back to ${destinationDir} to restore. ` +
|
|
722
|
+
`Restore error: ${restoreError.message}`, { cause: installError });
|
|
723
|
+
}
|
|
724
|
+
throw installError;
|
|
725
|
+
}
|
|
726
|
+
// Revalidate before discarding the backup: only delete it if it is
|
|
727
|
+
// still byte-for-byte the original destination we moved aside. If it
|
|
728
|
+
// changed during the install window (a concurrent write to the
|
|
729
|
+
// moved-aside directory), do NOT delete it — leave it in place and
|
|
730
|
+
// surface where it is so nothing is lost.
|
|
731
|
+
if (fingerprintDir(backupDir) === authorizedDestinationFingerprint) {
|
|
732
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
569
733
|
}
|
|
570
734
|
else {
|
|
571
|
-
console.error(`
|
|
572
|
-
|
|
735
|
+
console.error(`Warning: the previous '${destinationName}' changed during the fork and was NOT deleted; ` +
|
|
736
|
+
`its pre-fork copy is preserved at ${backupDir}.`);
|
|
573
737
|
}
|
|
574
|
-
process.exitCode = 1;
|
|
575
|
-
return;
|
|
576
738
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
739
|
+
else {
|
|
740
|
+
fs.renameSync(stagingDir, destinationDir);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
// Remove only the staging directory we created this run; the source
|
|
745
|
+
// and any existing destination are left exactly as we found them.
|
|
746
|
+
// Guard the cleanup in its own try/catch so a failed removal (e.g. a
|
|
747
|
+
// locked file on Windows) can never mask the original error, then
|
|
748
|
+
// rethrow so the real failure still drives the JSON/exit-code report.
|
|
749
|
+
try {
|
|
750
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
// Best-effort cleanup; the original error below is what matters.
|
|
754
|
+
}
|
|
755
|
+
throw error;
|
|
581
756
|
}
|
|
582
|
-
// Copy schema
|
|
583
|
-
if (spinner)
|
|
584
|
-
spinner.start(`Forking '${source}' to '${destinationName}'...`);
|
|
585
|
-
copyDirRecursive(trustedSourceDir, destinationDir);
|
|
586
|
-
// Update name in schema.yaml
|
|
587
|
-
const destSchemaPath = path.join(destinationDir, 'schema.yaml');
|
|
588
|
-
const schemaContent = fs.readFileSync(destSchemaPath, 'utf-8');
|
|
589
|
-
const schema = parseSchema(schemaContent);
|
|
590
|
-
schema.name = destinationName;
|
|
591
|
-
fs.writeFileSync(destSchemaPath, stringifyYaml(schema));
|
|
592
757
|
if (spinner)
|
|
593
758
|
spinner.succeed(`Forked '${source}' to '${destinationName}'`);
|
|
594
759
|
if (options?.json) {
|
|
@@ -2,6 +2,7 @@ interface ExecuteOptions {
|
|
|
2
2
|
all?: boolean;
|
|
3
3
|
changes?: boolean;
|
|
4
4
|
specs?: boolean;
|
|
5
|
+
archived?: boolean;
|
|
5
6
|
type?: string;
|
|
6
7
|
strict?: boolean;
|
|
7
8
|
json?: boolean;
|
|
@@ -29,6 +30,28 @@ export declare class ValidateCommand {
|
|
|
29
30
|
private printReport;
|
|
30
31
|
private printNextSteps;
|
|
31
32
|
private runBulkValidation;
|
|
33
|
+
/**
|
|
34
|
+
* Lists archived change ids from the resolved root's archive directory,
|
|
35
|
+
* mirroring `getArchivedChangeIds` but store-aware (uses `root.archiveDir`
|
|
36
|
+
* rather than a cwd-relative path). Directories only, hidden entries skipped.
|
|
37
|
+
*
|
|
38
|
+
* Only a missing archive directory (ENOENT) is an empty list; a permission
|
|
39
|
+
* error, an I/O error, or an `archive` path that is a file (ENOTDIR) is a real
|
|
40
|
+
* failure and must not read as "no archived changes" — that would let a
|
|
41
|
+
* pre-commit lint pass without inspecting anything (#205).
|
|
42
|
+
*/
|
|
43
|
+
private listArchivedChangeIds;
|
|
44
|
+
/**
|
|
45
|
+
* Validates that every archived change has all of its tasks completed.
|
|
46
|
+
*
|
|
47
|
+
* An archived change is expected to be finished; an archived change with
|
|
48
|
+
* unchecked tasks is a real integrity problem the normal validate flow never
|
|
49
|
+
* surfaces, because active-change discovery excludes the archive directory
|
|
50
|
+
* (#205). Reuses the same task-progress counting `status`, `list`, and
|
|
51
|
+
* `archive` rely on, so what counts as a task never forks. Changes with no
|
|
52
|
+
* tasks pass (nothing to complete).
|
|
53
|
+
*/
|
|
54
|
+
private runArchivedTaskValidation;
|
|
32
55
|
}
|
|
33
56
|
export {};
|
|
34
57
|
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -7,15 +7,33 @@ import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
|
|
|
7
7
|
import { getSpecIds } from '../utils/item-discovery.js';
|
|
8
8
|
import { getAvailableChanges } from './workflow/shared.js';
|
|
9
9
|
import { nearestMatches } from '../utils/match.js';
|
|
10
|
+
import { promises as fs } from 'fs';
|
|
11
|
+
import { getTaskProgressDetailForChange } from '../utils/task-progress.js';
|
|
12
|
+
import { FileSystemUtils } from '../utils/file-system.js';
|
|
10
13
|
export class ValidateCommand {
|
|
11
14
|
async execute(itemName, options = {}) {
|
|
12
|
-
const
|
|
15
|
+
const bulk = options.all || options.changes || options.specs;
|
|
16
|
+
const root = await resolveRootForCommand(options, {
|
|
17
|
+
json: options.json,
|
|
18
|
+
...(bulk ? { allowImplicitRoot: false } : {}),
|
|
19
|
+
});
|
|
13
20
|
if (!root) {
|
|
14
21
|
return;
|
|
15
22
|
}
|
|
16
23
|
const interactive = isInteractive(options);
|
|
24
|
+
// Archived-task linting is its own scope: it checks task completion of
|
|
25
|
+
// already-archived changes, not delta specs (whose operations are already
|
|
26
|
+
// applied). Handled before the other bulk flags so `--archived` is explicit
|
|
27
|
+
// and never alters an existing invocation's behavior (#205).
|
|
28
|
+
if (options.archived) {
|
|
29
|
+
await this.runArchivedTaskValidation(root, {
|
|
30
|
+
json: !!options.json,
|
|
31
|
+
noInteractive: resolveNoInteractive(options),
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
17
35
|
// Handle bulk flags first
|
|
18
|
-
if (
|
|
36
|
+
if (bulk) {
|
|
19
37
|
await this.runBulkValidation(root, {
|
|
20
38
|
changes: !!options.all || !!options.changes,
|
|
21
39
|
specs: !!options.all || !!options.specs,
|
|
@@ -145,7 +163,10 @@ export class ValidateCommand {
|
|
|
145
163
|
if (type === 'change') {
|
|
146
164
|
const changeDir = path.join(root.changesDir, id);
|
|
147
165
|
const start = Date.now();
|
|
148
|
-
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
|
166
|
+
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
|
167
|
+
mainSpecsDir: root.specsDir,
|
|
168
|
+
projectRoot: root.path,
|
|
169
|
+
});
|
|
149
170
|
const durationMs = Date.now() - start;
|
|
150
171
|
this.printReport('change', id, report, durationMs, opts.json, root);
|
|
151
172
|
// Non-zero exit if invalid (keeps enriched output test semantics)
|
|
@@ -222,7 +243,10 @@ export class ValidateCommand {
|
|
|
222
243
|
queue.push(async () => {
|
|
223
244
|
const start = Date.now();
|
|
224
245
|
const changeDir = path.join(root.changesDir, id);
|
|
225
|
-
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
|
246
|
+
const report = await validator.validateChangeDeltaSpecs(changeDir, {
|
|
247
|
+
mainSpecsDir: root.specsDir,
|
|
248
|
+
projectRoot: root.path,
|
|
249
|
+
});
|
|
226
250
|
const durationMs = Date.now() - start;
|
|
227
251
|
return { id, type: 'change', valid: report.valid, issues: report.issues, durationMs };
|
|
228
252
|
});
|
|
@@ -322,6 +346,126 @@ export class ValidateCommand {
|
|
|
322
346
|
}
|
|
323
347
|
process.exitCode = failed > 0 ? 1 : 0;
|
|
324
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Lists archived change ids from the resolved root's archive directory,
|
|
351
|
+
* mirroring `getArchivedChangeIds` but store-aware (uses `root.archiveDir`
|
|
352
|
+
* rather than a cwd-relative path). Directories only, hidden entries skipped.
|
|
353
|
+
*
|
|
354
|
+
* Only a missing archive directory (ENOENT) is an empty list; a permission
|
|
355
|
+
* error, an I/O error, or an `archive` path that is a file (ENOTDIR) is a real
|
|
356
|
+
* failure and must not read as "no archived changes" — that would let a
|
|
357
|
+
* pre-commit lint pass without inspecting anything (#205).
|
|
358
|
+
*/
|
|
359
|
+
async listArchivedChangeIds(root) {
|
|
360
|
+
try {
|
|
361
|
+
const entries = await fs.readdir(root.archiveDir, { withFileTypes: true });
|
|
362
|
+
return entries
|
|
363
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
364
|
+
.map((entry) => entry.name)
|
|
365
|
+
.sort();
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (error?.code === 'ENOENT')
|
|
369
|
+
return [];
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Validates that every archived change has all of its tasks completed.
|
|
375
|
+
*
|
|
376
|
+
* An archived change is expected to be finished; an archived change with
|
|
377
|
+
* unchecked tasks is a real integrity problem the normal validate flow never
|
|
378
|
+
* surfaces, because active-change discovery excludes the archive directory
|
|
379
|
+
* (#205). Reuses the same task-progress counting `status`, `list`, and
|
|
380
|
+
* `archive` rely on, so what counts as a task never forks. Changes with no
|
|
381
|
+
* tasks pass (nothing to complete).
|
|
382
|
+
*/
|
|
383
|
+
async runArchivedTaskValidation(root, opts) {
|
|
384
|
+
// List first (may throw on a real archive-read failure), then start the
|
|
385
|
+
// spinner so a thrown error never leaves a spinner spinning.
|
|
386
|
+
const ids = await this.listArchivedChangeIds(root);
|
|
387
|
+
const spinner = !opts.json && !opts.noInteractive ? ora('Validating archived changes...').start() : undefined;
|
|
388
|
+
// The archive is append-only and can hold thousands of changes; a single
|
|
389
|
+
// run resolves them all under one constant projectRoot (root.path), so
|
|
390
|
+
// memoize the schema→glob lookup to avoid re-parsing the same schema.yaml
|
|
391
|
+
// once per change. The loop is intentionally sequential: the per-change work
|
|
392
|
+
// is dominated by synchronous schema/config resolution, which a promise pool
|
|
393
|
+
// cannot overlap on Node's single thread — a pool would add complexity for
|
|
394
|
+
// no real gain here.
|
|
395
|
+
const schemaGlobCache = new Map();
|
|
396
|
+
const results = [];
|
|
397
|
+
let passed = 0;
|
|
398
|
+
let failed = 0;
|
|
399
|
+
for (const id of ids) {
|
|
400
|
+
const start = Date.now();
|
|
401
|
+
const issues = [];
|
|
402
|
+
try {
|
|
403
|
+
// The explicit root.path override is load-bearing: an archived change
|
|
404
|
+
// lives one directory deeper (changes/archive/<id>), so the default
|
|
405
|
+
// "../../.." projectRoot derivation would be wrong without it.
|
|
406
|
+
const progress = await getTaskProgressDetailForChange(root.archiveDir, id, root.path, schemaGlobCache);
|
|
407
|
+
// A tasks file that exists but cannot be read must fail loudly, not be
|
|
408
|
+
// silently counted as "no tasks" and pass. Report one issue per file,
|
|
409
|
+
// pathed like every other validate issue (POSIX, root-relative).
|
|
410
|
+
for (const file of progress.unreadable) {
|
|
411
|
+
issues.push({
|
|
412
|
+
level: 'ERROR',
|
|
413
|
+
path: FileSystemUtils.toPosixPath(path.relative(root.path, file)),
|
|
414
|
+
message: 'could not read task file',
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
const incomplete = Math.max(progress.total - progress.completed, 0);
|
|
418
|
+
if (incomplete > 0) {
|
|
419
|
+
issues.push({
|
|
420
|
+
level: 'ERROR',
|
|
421
|
+
path: 'tasks.md',
|
|
422
|
+
message: `${incomplete} incomplete task${incomplete === 1 ? '' : 's'} (${progress.completed}/${progress.total} completed)`,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
issues.push({ level: 'ERROR', path: 'tasks.md', message: error?.message || 'Unknown error' });
|
|
428
|
+
}
|
|
429
|
+
const valid = issues.length === 0;
|
|
430
|
+
if (valid)
|
|
431
|
+
passed++;
|
|
432
|
+
else
|
|
433
|
+
failed++;
|
|
434
|
+
results.push({ id, type: 'change', valid, issues, durationMs: Date.now() - start });
|
|
435
|
+
}
|
|
436
|
+
spinner?.stop();
|
|
437
|
+
const summary = {
|
|
438
|
+
totals: { items: results.length, passed, failed },
|
|
439
|
+
byType: { change: summarizeType(results, 'change') },
|
|
440
|
+
};
|
|
441
|
+
if (opts.json) {
|
|
442
|
+
const out = { items: results, summary, version: '1.0', root: toRootOutput(root) };
|
|
443
|
+
console.log(JSON.stringify(out, null, 2));
|
|
444
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (results.length === 0) {
|
|
448
|
+
console.log('No archived changes found.');
|
|
449
|
+
process.exitCode = 0;
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
// Use the same `<type>/<id>` prefix bulk validation prints, so the plain
|
|
453
|
+
// output maps to the JSON `type` ('change') and stays greppable the same way.
|
|
454
|
+
for (const res of results) {
|
|
455
|
+
if (res.valid) {
|
|
456
|
+
console.log(`✓ change/${res.id}`);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
console.error(`✗ change/${res.id}`);
|
|
460
|
+
for (const issue of res.issues) {
|
|
461
|
+
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
|
|
462
|
+
console.error(` ${prefix} ${issue.message}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
console.log(`Totals: ${summary.totals.passed} passed, ${summary.totals.failed} failed (${summary.totals.items} items)`);
|
|
467
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
468
|
+
}
|
|
325
469
|
}
|
|
326
470
|
function summarizeType(results, type) {
|
|
327
471
|
const filtered = results.filter(r => r.type === type);
|
|
@@ -5,12 +5,19 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { listSchemasWithInfo } from '../../core/artifact-graph/index.js';
|
|
8
|
+
import { resolveRootForCommand } from '../../core/root-selection.js';
|
|
8
9
|
// -----------------------------------------------------------------------------
|
|
9
10
|
// Command Implementation
|
|
10
11
|
// -----------------------------------------------------------------------------
|
|
11
12
|
export async function schemasCommand(options) {
|
|
12
|
-
const
|
|
13
|
-
|
|
13
|
+
const root = await resolveRootForCommand(options, {
|
|
14
|
+
json: options.json,
|
|
15
|
+
failurePayload: { schemas: [], root: null },
|
|
16
|
+
});
|
|
17
|
+
if (!root) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const schemas = listSchemasWithInfo(root.path);
|
|
14
21
|
if (options.json) {
|
|
15
22
|
console.log(JSON.stringify(schemas, null, 2));
|
|
16
23
|
return;
|