@fission-ai/openspec 1.6.0 → 1.8.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 +31 -2
- package/dist/cli/index.js +79 -8
- package/dist/commands/change.d.ts +0 -1
- package/dist/commands/change.js +105 -51
- package/dist/commands/config.js +14 -8
- package/dist/commands/doctor.js +9 -4
- package/dist/commands/feedback.js +74 -23
- package/dist/commands/schema.js +110 -31
- package/dist/commands/show.js +5 -2
- package/dist/commands/spec.js +49 -29
- package/dist/commands/validate.js +20 -5
- package/dist/commands/workflow/index.d.ts +1 -1
- package/dist/commands/workflow/index.js +1 -1
- package/dist/commands/workflow/instructions.d.ts +7 -1
- package/dist/commands/workflow/instructions.js +121 -33
- package/dist/commands/workflow/shared.d.ts +13 -2
- package/dist/commands/workflow/shared.js +34 -4
- package/dist/commands/workflow/status.js +9 -4
- package/dist/commands/workflow/templates.js +15 -5
- package/dist/core/archive.d.ts +14 -1
- package/dist/core/archive.js +1332 -155
- package/dist/core/artifact-graph/graph.d.ts +16 -0
- package/dist/core/artifact-graph/graph.js +30 -6
- package/dist/core/artifact-graph/index.d.ts +1 -1
- package/dist/core/artifact-graph/index.js +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts +30 -3
- package/dist/core/artifact-graph/instruction-loader.js +70 -12
- package/dist/core/artifact-graph/outputs.d.ts +1 -0
- package/dist/core/artifact-graph/outputs.js +67 -5
- package/dist/core/artifact-graph/resolver.d.ts +14 -0
- package/dist/core/artifact-graph/resolver.js +69 -15
- package/dist/core/artifact-graph/types.js +21 -3
- package/dist/core/available-tools.d.ts +2 -2
- package/dist/core/available-tools.js +14 -3
- package/dist/core/change-metadata/schema.d.ts +2 -0
- package/dist/core/change-metadata/schema.js +13 -0
- package/dist/core/change-status-policy.d.ts +1 -1
- package/dist/core/change-status-policy.js +2 -2
- package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
- package/dist/core/command-generation/adapters/amazon-q.js +7 -1
- package/dist/core/command-generation/adapters/antigravity.js +2 -1
- package/dist/core/command-generation/adapters/auggie.js +2 -1
- package/dist/core/command-generation/adapters/bob.d.ts +5 -1
- package/dist/core/command-generation/adapters/bob.js +6 -5
- package/dist/core/command-generation/adapters/claude.js +1 -8
- package/dist/core/command-generation/adapters/codebuddy.js +3 -2
- package/dist/core/command-generation/adapters/continue.js +3 -2
- package/dist/core/command-generation/adapters/costrict.js +2 -1
- package/dist/core/command-generation/adapters/crush.js +5 -5
- package/dist/core/command-generation/adapters/cursor.js +2 -2
- package/dist/core/command-generation/adapters/devin.d.ts +19 -0
- package/dist/core/command-generation/adapters/devin.js +36 -0
- package/dist/core/command-generation/adapters/factory.js +2 -1
- package/dist/core/command-generation/adapters/gemini.js +37 -2
- package/dist/core/command-generation/adapters/github-copilot.js +2 -1
- package/dist/core/command-generation/adapters/iflow.js +5 -4
- package/dist/core/command-generation/adapters/index.d.ts +2 -2
- package/dist/core/command-generation/adapters/index.js +2 -2
- package/dist/core/command-generation/adapters/junie.js +2 -1
- package/dist/core/command-generation/adapters/kiro.js +2 -1
- package/dist/core/command-generation/adapters/lingma.js +5 -5
- package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
- package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
- package/dist/core/command-generation/adapters/opencode.js +3 -5
- package/dist/core/command-generation/adapters/pi.d.ts +2 -2
- package/dist/core/command-generation/adapters/pi.js +3 -6
- package/dist/core/command-generation/adapters/qoder.js +5 -5
- package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
- package/dist/core/command-generation/adapters/qwen.js +12 -7
- package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
- package/dist/core/command-generation/adapters/roocode.js +4 -4
- package/dist/core/command-generation/adapters/trae.js +1 -21
- package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
- package/dist/core/command-generation/adapters/zcode.js +33 -0
- package/dist/core/command-generation/generator.d.ts +8 -0
- package/dist/core/command-generation/generator.js +15 -1
- package/dist/core/command-generation/index.d.ts +1 -1
- package/dist/core/command-generation/index.js +1 -1
- package/dist/core/command-generation/invocation.d.ts +73 -0
- package/dist/core/command-generation/invocation.js +77 -0
- package/dist/core/command-generation/registry.js +4 -4
- package/dist/core/command-generation/types.d.ts +9 -2
- package/dist/core/command-generation/yaml.d.ts +20 -6
- package/dist/core/command-generation/yaml.js +31 -21
- package/dist/core/command-surface.d.ts +15 -0
- package/dist/core/command-surface.js +33 -0
- package/dist/core/completions/command-registry.js +16 -2
- package/dist/core/completions/generators/powershell-generator.js +15 -8
- package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
- package/dist/core/completions/installers/zsh-installer.js +20 -4
- package/dist/core/config-prompts.d.ts +1 -1
- package/dist/core/config-prompts.js +14 -1
- package/dist/core/config-schema.d.ts +10 -1
- package/dist/core/config-schema.js +67 -1
- package/dist/core/config.d.ts +15 -0
- package/dist/core/config.js +45 -5
- package/dist/core/file-state.js +56 -24
- package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
- package/dist/core/github-copilot/cloud-agent.js +497 -0
- package/dist/core/global-config.d.ts +16 -0
- package/dist/core/init.d.ts +58 -0
- package/dist/core/init.js +450 -69
- package/dist/core/legacy-cleanup.d.ts +59 -0
- package/dist/core/legacy-cleanup.js +253 -24
- package/dist/core/list.js +5 -7
- package/dist/core/migration.d.ts +73 -1
- package/dist/core/migration.js +360 -11
- package/dist/core/onboarding-commands.d.ts +30 -0
- package/dist/core/onboarding-commands.js +38 -0
- package/dist/core/parsers/change-parser.d.ts +17 -2
- package/dist/core/parsers/change-parser.js +33 -20
- package/dist/core/parsers/code-fence.d.ts +15 -0
- package/dist/core/parsers/code-fence.js +49 -0
- package/dist/core/parsers/markdown-parser.js +2 -1
- package/dist/core/parsers/requirement-blocks.d.ts +18 -0
- package/dist/core/parsers/requirement-blocks.js +120 -35
- package/dist/core/parsers/requirement-text.d.ts +1 -8
- package/dist/core/parsers/requirement-text.js +4 -42
- package/dist/core/parsers/spec-structure.d.ts +1 -1
- package/dist/core/parsers/spec-structure.js +20 -30
- package/dist/core/profile-sync-drift.d.ts +0 -8
- package/dist/core/profile-sync-drift.js +48 -49
- package/dist/core/project-config.d.ts +28 -6
- package/dist/core/project-config.js +108 -8
- package/dist/core/references.js +42 -4
- package/dist/core/relationship-health.d.ts +8 -0
- package/dist/core/relationship-health.js +13 -0
- package/dist/core/root-selection.d.ts +9 -6
- package/dist/core/root-selection.js +42 -5
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
- package/dist/core/shared/skill-content-equivalence.js +41 -0
- package/dist/core/shared/skill-paths.d.ts +13 -0
- package/dist/core/shared/skill-paths.js +23 -0
- package/dist/core/shared/tool-detection.d.ts +25 -5
- package/dist/core/shared/tool-detection.js +166 -36
- package/dist/core/shared-skill-target.d.ts +16 -0
- package/dist/core/shared-skill-target.js +156 -0
- package/dist/core/specs-apply.d.ts +80 -33
- package/dist/core/specs-apply.js +659 -171
- package/dist/core/store/git.d.ts +13 -0
- package/dist/core/store/git.js +24 -0
- package/dist/core/templates/skill-templates.d.ts +1 -1
- package/dist/core/templates/skill-templates.js +1 -1
- package/dist/core/templates/workflows/apply-change.d.ts +10 -0
- package/dist/core/templates/workflows/apply-change.js +48 -158
- package/dist/core/templates/workflows/archive-change.js +170 -42
- package/dist/core/templates/workflows/bulk-archive-change.js +256 -75
- package/dist/core/templates/workflows/continue-change.js +38 -46
- package/dist/core/templates/workflows/explore.js +54 -18
- package/dist/core/templates/workflows/ff-change.js +46 -30
- package/dist/core/templates/workflows/new-change.js +2 -2
- package/dist/core/templates/workflows/onboard.js +13 -7
- package/dist/core/templates/workflows/propose.js +116 -46
- 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/sync-specs.js +260 -32
- package/dist/core/templates/workflows/update-change.js +26 -16
- package/dist/core/templates/workflows/verify-change.js +16 -10
- package/dist/core/update.d.ts +40 -5
- package/dist/core/update.js +389 -93
- package/dist/core/validation/constants.d.ts +4 -1
- package/dist/core/validation/constants.js +4 -1
- package/dist/core/validation/validator.d.ts +22 -8
- package/dist/core/validation/validator.js +222 -50
- package/dist/core/version-check.d.ts +152 -0
- package/dist/core/version-check.js +731 -0
- package/dist/core/view.js +12 -17
- package/dist/prompts/searchable-multi-select.js +1 -1
- package/dist/telemetry/config.d.ts +3 -4
- package/dist/telemetry/index.d.ts +10 -5
- package/dist/telemetry/index.js +75 -41
- package/dist/ui/welcome-screen.d.ts +9 -1
- package/dist/ui/welcome-screen.js +91 -38
- package/dist/utils/change-metadata.d.ts +43 -0
- package/dist/utils/change-metadata.js +123 -7
- package/dist/utils/change-utils.d.ts +10 -4
- package/dist/utils/change-utils.js +20 -13
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +16 -0
- package/dist/utils/command-references.d.ts +92 -6
- package/dist/utils/command-references.js +180 -7
- package/dist/utils/date.d.ts +8 -0
- package/dist/utils/date.js +13 -0
- package/dist/utils/file-system.d.ts +10 -0
- package/dist/utils/file-system.js +70 -0
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/interactive.d.ts +23 -0
- package/dist/utils/interactive.js +32 -0
- package/dist/utils/item-discovery.d.ts +19 -0
- package/dist/utils/item-discovery.js +30 -48
- package/dist/utils/shell-detection.d.ts +1 -1
- package/dist/utils/shell-detection.js +64 -11
- package/dist/utils/spec-discovery.d.ts +40 -0
- package/dist/utils/spec-discovery.js +115 -0
- package/dist/utils/task-progress.d.ts +17 -0
- package/dist/utils/task-progress.js +41 -13
- package/package.json +14 -12
- package/schemas/spec-driven/schema.yaml +67 -10
- package/schemas/spec-driven/templates/design.md +2 -2
- package/schemas/spec-driven/templates/proposal.md +10 -4
- package/schemas/spec-driven/templates/spec.md +3 -0
- package/dist/core/command-generation/adapters/codex.d.ts +0 -16
- package/dist/core/command-generation/adapters/codex.js +0 -39
- package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
- package/dist/core/command-generation/adapters/windsurf.js +0 -38
|
@@ -19,6 +19,9 @@ export declare const VALIDATION_MESSAGES: {
|
|
|
19
19
|
readonly CHANGE_WHY_TOO_LONG: "Why section should not exceed 1000 characters";
|
|
20
20
|
readonly CHANGE_WHAT_EMPTY: "What Changes section cannot be empty";
|
|
21
21
|
readonly CHANGE_NO_DELTAS: "Change must have at least one delta";
|
|
22
|
+
readonly CHANGE_SKIP_SPECS_CONFLICT: "skip_specs is set in .openspec.yaml but spec files exist under specs/. Remove skip_specs or delete the delta spec files";
|
|
23
|
+
readonly CHANGE_SKIP_SPECS_ACCEPTED: "skip_specs is set in .openspec.yaml: change declares no spec-level behavior changes, zero deltas accepted";
|
|
24
|
+
readonly CHANGE_SKIP_SPECS_INVALID_METADATA: "skip_specs is set but .openspec.yaml is not valid change metadata, so the marker is not honored. Fix the metadata";
|
|
22
25
|
readonly CHANGE_TOO_MANY_DELTAS: "Consider splitting changes with more than 10 deltas";
|
|
23
26
|
readonly DELTA_SPEC_EMPTY: "Spec name cannot be empty";
|
|
24
27
|
readonly DELTA_DESCRIPTION_EMPTY: "Delta description cannot be empty";
|
|
@@ -26,7 +29,7 @@ export declare const VALIDATION_MESSAGES: {
|
|
|
26
29
|
readonly REQUIREMENT_TOO_LONG: "Requirement text is very long (>500 characters). Consider breaking it down.";
|
|
27
30
|
readonly DELTA_DESCRIPTION_TOO_BRIEF: "Delta description is too brief";
|
|
28
31
|
readonly DELTA_MISSING_REQUIREMENTS: "Delta should include requirements";
|
|
29
|
-
readonly GUIDE_NO_DELTAS: "No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one \"#### Scenario:\" block. Tip: run \"openspec change show <change-id> --json --deltas-only\" to inspect parsed deltas.";
|
|
32
|
+
readonly GUIDE_NO_DELTAS: "No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one \"#### Scenario:\" block. If this change intentionally modifies no specs (pure refactor, tooling, docs), set \"skip_specs: true\" in the change's .openspec.yaml instead. Tip: run \"openspec change show <change-id> --json --deltas-only\" to inspect parsed deltas.";
|
|
30
33
|
readonly GUIDE_MISSING_SPEC_SECTIONS: "Missing required sections. Expected headers: \"## Purpose\" and \"## Requirements\". Example:\n## Purpose\n[brief purpose]\n\n## Requirements\n### Requirement: Clear requirement statement\nUsers SHALL ...\n\n#### Scenario: Descriptive name\n- **WHEN** ...\n- **THEN** ...";
|
|
31
34
|
readonly GUIDE_MISSING_CHANGE_SECTIONS: "Missing required sections. Expected headers: \"## Why\" and \"## What Changes\". Ensure deltas are documented in specs/ using delta headers.";
|
|
32
35
|
readonly GUIDE_SCENARIO_FORMAT: "Scenarios must use level-4 headers. Convert bullet lists into:\n#### Scenario: Short name\n- **WHEN** ...\n- **THEN** ...\n- **AND** ...";
|
|
@@ -23,6 +23,9 @@ export const VALIDATION_MESSAGES = {
|
|
|
23
23
|
CHANGE_WHY_TOO_LONG: `Why section should not exceed ${MAX_WHY_SECTION_LENGTH} characters`,
|
|
24
24
|
CHANGE_WHAT_EMPTY: 'What Changes section cannot be empty',
|
|
25
25
|
CHANGE_NO_DELTAS: 'Change must have at least one delta',
|
|
26
|
+
CHANGE_SKIP_SPECS_CONFLICT: 'skip_specs is set in .openspec.yaml but spec files exist under specs/. Remove skip_specs or delete the delta spec files',
|
|
27
|
+
CHANGE_SKIP_SPECS_ACCEPTED: 'skip_specs is set in .openspec.yaml: change declares no spec-level behavior changes, zero deltas accepted',
|
|
28
|
+
CHANGE_SKIP_SPECS_INVALID_METADATA: 'skip_specs is set but .openspec.yaml is not valid change metadata, so the marker is not honored. Fix the metadata',
|
|
26
29
|
CHANGE_TOO_MANY_DELTAS: `Consider splitting changes with more than ${MAX_DELTAS_PER_CHANGE} deltas`,
|
|
27
30
|
DELTA_SPEC_EMPTY: 'Spec name cannot be empty',
|
|
28
31
|
DELTA_DESCRIPTION_EMPTY: 'Delta description cannot be empty',
|
|
@@ -32,7 +35,7 @@ export const VALIDATION_MESSAGES = {
|
|
|
32
35
|
DELTA_DESCRIPTION_TOO_BRIEF: 'Delta description is too brief',
|
|
33
36
|
DELTA_MISSING_REQUIREMENTS: 'Delta should include requirements',
|
|
34
37
|
// Guidance snippets (appended to primary messages for remediation)
|
|
35
|
-
GUIDE_NO_DELTAS: 'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. Tip: run "openspec change show <change-id> --json --deltas-only" to inspect parsed deltas.',
|
|
38
|
+
GUIDE_NO_DELTAS: 'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. If this change intentionally modifies no specs (pure refactor, tooling, docs), set "skip_specs: true" in the change\'s .openspec.yaml instead. Tip: run "openspec change show <change-id> --json --deltas-only" to inspect parsed deltas.',
|
|
36
39
|
GUIDE_MISSING_SPEC_SECTIONS: 'Missing required sections. Expected headers: "## Purpose" and "## Requirements". Example:\n## Purpose\n[brief purpose]\n\n## Requirements\n### Requirement: Clear requirement statement\nUsers SHALL ...\n\n#### Scenario: Descriptive name\n- **WHEN** ...\n- **THEN** ...',
|
|
37
40
|
GUIDE_MISSING_CHANGE_SECTIONS: 'Missing required sections. Expected headers: "## Why" and "## What Changes". Ensure deltas are documented in specs/ using delta headers.',
|
|
38
41
|
GUIDE_SCENARIO_FORMAT: 'Scenarios must use level-4 headers. Convert bullet lists into:\n#### Scenario: Short name\n- **WHEN** ...\n- **THEN** ...\n- **AND** ...',
|
|
@@ -12,19 +12,33 @@ export declare class Validator {
|
|
|
12
12
|
* Validate delta-formatted spec files under a change directory.
|
|
13
13
|
* Enforces:
|
|
14
14
|
* - At least one delta across all files
|
|
15
|
-
* - ADDED/MODIFIED: each requirement has
|
|
15
|
+
* - ADDED/MODIFIED: each requirement has at least one scenario; missing
|
|
16
|
+
* English SHALL/MUST keywords are guidance unless strict mode is enabled
|
|
16
17
|
* - REMOVED: names only; no scenario/description required
|
|
17
18
|
* - RENAMED: pairs well-formed
|
|
18
19
|
* - No duplicates within sections; no cross-section conflicts per spec
|
|
20
|
+
*
|
|
21
|
+
* When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
|
|
22
|
+
* against the current main specs for the scenario loss archive refuses to
|
|
23
|
+
* apply (#1477). Omitting it keeps the change-only checks, so callers with
|
|
24
|
+
* no main specs root (and existing library callers) behave as before.
|
|
19
25
|
*/
|
|
20
|
-
validateChangeDeltaSpecs(changeDir: string
|
|
26
|
+
validateChangeDeltaSpecs(changeDir: string, options?: {
|
|
27
|
+
mainSpecsDir?: string;
|
|
28
|
+
}): Promise<ValidationReport>;
|
|
21
29
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
30
|
+
* Report MODIFIED requirements whose block omits a scenario the main spec
|
|
31
|
+
* still carries. Uses the same comparison archive applies, so validate can
|
|
32
|
+
* only report what archive would refuse.
|
|
33
|
+
*
|
|
34
|
+
* Silent when the main spec or the requirement header is absent: applying a
|
|
35
|
+
* MODIFIED against a base that is not there yet is a different failure (a
|
|
36
|
+
* sister change still in flight is the legitimate case), and archive is the
|
|
37
|
+
* gate for it. A spec that exists but cannot be read is not absent, though —
|
|
38
|
+
* archive aborts on it, so reporting it beats calling the change valid.
|
|
26
39
|
*/
|
|
27
|
-
private
|
|
40
|
+
private findScenarioLossIssues;
|
|
41
|
+
private formatInvalidMarkerMessage;
|
|
28
42
|
private convertZodErrors;
|
|
29
43
|
private applySpecRules;
|
|
30
44
|
private applyChangeRules;
|
|
@@ -35,7 +49,7 @@ export declare class Validator {
|
|
|
35
49
|
private extractRequirementText;
|
|
36
50
|
private containsShallOrMust;
|
|
37
51
|
/**
|
|
38
|
-
* Build
|
|
52
|
+
* Build a message for a requirement block whose body lacks SHALL/MUST.
|
|
39
53
|
*
|
|
40
54
|
* When the SHALL/MUST keyword already appears in the requirement header (e.g.
|
|
41
55
|
* `### Requirement: The system SHALL ...`) the original generic error
|
|
@@ -4,10 +4,12 @@ import { SpecSchema, ChangeSchema } from '../schemas/index.js';
|
|
|
4
4
|
import { MarkdownParser } from '../parsers/markdown-parser.js';
|
|
5
5
|
import { ChangeParser } from '../parsers/change-parser.js';
|
|
6
6
|
import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
|
|
7
|
-
import { parseDeltaSpec, normalizeRequirementName, extractRequirementsSection } from '../parsers/requirement-blocks.js';
|
|
7
|
+
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, findMissingCurrentScenarios, } from '../parsers/requirement-blocks.js';
|
|
8
8
|
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, } from '../parsers/requirement-text.js';
|
|
9
9
|
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
|
10
10
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
11
|
+
import { discoverSpecFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
|
|
12
|
+
import { METADATA_FILENAME, readSkipSpecsMarker } from '../../utils/change-metadata.js';
|
|
11
13
|
export class Validator {
|
|
12
14
|
strictMode;
|
|
13
15
|
constructor(strictMode = false) {
|
|
@@ -67,8 +69,20 @@ export class Validator {
|
|
|
67
69
|
const parser = new ChangeParser(content, changeDir);
|
|
68
70
|
const change = await parser.parseChangeWithDeltas(changeName);
|
|
69
71
|
const result = ChangeSchema.safeParse(change);
|
|
72
|
+
const marker = readSkipSpecsMarker(changeDir);
|
|
73
|
+
if (marker.invalidReason) {
|
|
74
|
+
issues.push({ level: 'ERROR', path: METADATA_FILENAME, message: this.formatInvalidMarkerMessage(marker.invalidReason) });
|
|
75
|
+
}
|
|
70
76
|
if (!result.success) {
|
|
71
|
-
|
|
77
|
+
let zodIssues = this.convertZodErrors(result.error);
|
|
78
|
+
// Only the no-deltas error is marker-aware here: the marker+files
|
|
79
|
+
// conflict is validateChangeDeltaSpecs's job, and every caller of
|
|
80
|
+
// this proposal-level pass (archive's non-blocking warnings) pairs
|
|
81
|
+
// it with that gate.
|
|
82
|
+
if (marker.declared) {
|
|
83
|
+
zodIssues = zodIssues.filter(issue => !issue.message.startsWith(VALIDATION_MESSAGES.CHANGE_NO_DELTAS));
|
|
84
|
+
}
|
|
85
|
+
issues.push(...zodIssues);
|
|
72
86
|
}
|
|
73
87
|
issues.push(...this.applyChangeRules(change, content));
|
|
74
88
|
}
|
|
@@ -87,24 +101,46 @@ export class Validator {
|
|
|
87
101
|
* Validate delta-formatted spec files under a change directory.
|
|
88
102
|
* Enforces:
|
|
89
103
|
* - At least one delta across all files
|
|
90
|
-
* - ADDED/MODIFIED: each requirement has
|
|
104
|
+
* - ADDED/MODIFIED: each requirement has at least one scenario; missing
|
|
105
|
+
* English SHALL/MUST keywords are guidance unless strict mode is enabled
|
|
91
106
|
* - REMOVED: names only; no scenario/description required
|
|
92
107
|
* - RENAMED: pairs well-formed
|
|
93
108
|
* - No duplicates within sections; no cross-section conflicts per spec
|
|
109
|
+
*
|
|
110
|
+
* When `options.mainSpecsDir` is given, MODIFIED blocks are also checked
|
|
111
|
+
* against the current main specs for the scenario loss archive refuses to
|
|
112
|
+
* apply (#1477). Omitting it keeps the change-only checks, so callers with
|
|
113
|
+
* no main specs root (and existing library callers) behave as before.
|
|
94
114
|
*/
|
|
95
|
-
async validateChangeDeltaSpecs(changeDir) {
|
|
115
|
+
async validateChangeDeltaSpecs(changeDir, options = {}) {
|
|
96
116
|
const issues = [];
|
|
97
117
|
const specsDir = path.join(changeDir, 'specs');
|
|
98
118
|
let totalDeltas = 0;
|
|
119
|
+
let hasRootLevelSpec = false;
|
|
99
120
|
const missingHeaderSpecs = [];
|
|
100
121
|
const emptySectionSpecs = [];
|
|
101
122
|
try {
|
|
102
|
-
// Discover delta specs
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
123
|
+
// Discover delta specs through the same helper the change parser, show,
|
|
124
|
+
// apply, and archive use, so validate never accepts a layout the merge
|
|
125
|
+
// path silently skips (#1385). It finds spec.md at any depth, covering
|
|
126
|
+
// both specs/<capability>/spec.md and the nested multi-area
|
|
127
|
+
// specs/<area>/<capability>/spec.md layout (#1182b).
|
|
128
|
+
const discoveredSpecs = await discoverSpecFiles(specsDir);
|
|
129
|
+
// A spec.md directly at the specs/ root has no capability folder, so the
|
|
130
|
+
// merge path drops it: without this error the change validates clean and
|
|
131
|
+
// archives while its requirements never reach openspec/specs/ (#1385).
|
|
132
|
+
// Only a regular file counts — a *directory* named spec.md is a capability
|
|
133
|
+
// folder like any other, and discoverSpecFiles reads it normally.
|
|
134
|
+
const rootSpecStat = await fs.stat(path.join(specsDir, 'spec.md')).catch(() => null);
|
|
135
|
+
hasRootLevelSpec = rootSpecStat?.isFile() === true;
|
|
136
|
+
if (hasRootLevelSpec) {
|
|
137
|
+
issues.push({
|
|
138
|
+
level: 'ERROR',
|
|
139
|
+
path: 'spec.md',
|
|
140
|
+
message: 'Delta spec found at specs/spec.md. Delta specs must live under a capability path (e.g. specs/<capability-path>/spec.md) — a file at the specs/ root is ignored when the change is applied or archived.',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
for (const { id: specId, specFile } of discoveredSpecs) {
|
|
108
144
|
let content;
|
|
109
145
|
try {
|
|
110
146
|
content = await fs.readFile(specFile, 'utf-8');
|
|
@@ -174,7 +210,11 @@ export class Validator {
|
|
|
174
210
|
});
|
|
175
211
|
}
|
|
176
212
|
else if (!this.containsShallOrMust(requirementText)) {
|
|
177
|
-
issues.push({
|
|
213
|
+
issues.push({
|
|
214
|
+
level: 'WARNING',
|
|
215
|
+
path: entryPath,
|
|
216
|
+
message: this.buildMissingShallOrMustMessage(`ADDED "${block.name}"`, block.name, true),
|
|
217
|
+
});
|
|
178
218
|
}
|
|
179
219
|
const scenarioCount = this.countScenarios(block.raw);
|
|
180
220
|
if (scenarioCount < 1) {
|
|
@@ -202,13 +242,24 @@ export class Validator {
|
|
|
202
242
|
});
|
|
203
243
|
}
|
|
204
244
|
else if (!this.containsShallOrMust(requirementText)) {
|
|
205
|
-
issues.push({
|
|
245
|
+
issues.push({
|
|
246
|
+
level: 'WARNING',
|
|
247
|
+
path: entryPath,
|
|
248
|
+
message: this.buildMissingShallOrMustMessage(`MODIFIED "${block.name}"`, block.name, true),
|
|
249
|
+
});
|
|
206
250
|
}
|
|
207
251
|
const scenarioCount = this.countScenarios(block.raw);
|
|
208
252
|
if (scenarioCount < 1) {
|
|
209
253
|
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario` });
|
|
210
254
|
}
|
|
211
255
|
}
|
|
256
|
+
// Run archive's scenario-loss check here too, so the change fails at
|
|
257
|
+
// authoring time instead of days later at archive time (#1477).
|
|
258
|
+
if (options.mainSpecsDir && plan.modified.length > 0) {
|
|
259
|
+
const mainSpecFile = path.join(options.mainSpecsDir, ...specId.split('/'), 'spec.md');
|
|
260
|
+
FileSystemUtils.assertPathWithin(path.dirname(mainSpecFile), mainSpecFile);
|
|
261
|
+
issues.push(...(await this.findScenarioLossIssues(plan.modified, plan.renamed, mainSpecFile, entryPath, path.dirname(mainSpecFile))));
|
|
262
|
+
}
|
|
212
263
|
// Validate REMOVED (names only)
|
|
213
264
|
for (const name of plan.removed) {
|
|
214
265
|
const key = normalizeRequirementName(name);
|
|
@@ -261,11 +312,30 @@ export class Validator {
|
|
|
261
312
|
if (addedNames.has(toKey)) {
|
|
262
313
|
issues.push({ level: 'ERROR', path: entryPath, message: `RENAMED TO collides with ADDED for "${to}"` });
|
|
263
314
|
}
|
|
315
|
+
// Folded comparison: a case/whitespace variant of the FROM header
|
|
316
|
+
// in REMOVED is the same contradiction, not a different name.
|
|
317
|
+
const removedFoldMatch = [...removedNames].find((r) => foldRequirementName(r) === foldRequirementName(fromKey));
|
|
318
|
+
if (removedFoldMatch !== undefined) {
|
|
319
|
+
issues.push({
|
|
320
|
+
level: 'ERROR',
|
|
321
|
+
path: entryPath,
|
|
322
|
+
message: `Requirement present in both RENAMED and REMOVED: "${from}"` +
|
|
323
|
+
(removedFoldMatch === fromKey ? '' : ` (REMOVED spells it "${removedFoldMatch}")`),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
264
326
|
}
|
|
265
327
|
}
|
|
266
328
|
}
|
|
267
|
-
catch {
|
|
268
|
-
//
|
|
329
|
+
catch (error) {
|
|
330
|
+
// A missing specs dir (or a stray `specs` file) means no deltas;
|
|
331
|
+
// anything else (EACCES, EIO) must stay loud — discoverSpecFiles
|
|
332
|
+
// documents that silently dropping an unreadable capability recreates
|
|
333
|
+
// the data-loss class it prevents, and archive lets the same error
|
|
334
|
+
// propagate.
|
|
335
|
+
const code = error?.code;
|
|
336
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
269
339
|
}
|
|
270
340
|
for (const { path: specPath, sections } of emptySectionSpecs) {
|
|
271
341
|
issues.push({
|
|
@@ -281,39 +351,134 @@ export class Validator {
|
|
|
281
351
|
message: 'No delta sections found. Add headers such as "## ADDED Requirements" or move non-delta notes outside specs/.',
|
|
282
352
|
});
|
|
283
353
|
}
|
|
284
|
-
|
|
285
|
-
|
|
354
|
+
const marker = readSkipSpecsMarker(changeDir);
|
|
355
|
+
if (marker.invalidReason) {
|
|
356
|
+
issues.push({ level: 'ERROR', path: METADATA_FILENAME, message: this.formatInvalidMarkerMessage(marker.invalidReason) });
|
|
286
357
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
let entries;
|
|
358
|
+
// ANY file under specs/ contradicts the marker - not just parsed deltas.
|
|
359
|
+
// Headerless or stray files would be silently dropped at archive time (and
|
|
360
|
+
// some still satisfy the artifact graph's specs/** glob) while the change
|
|
361
|
+
// claims to have nothing, so they must surface as an explicit conflict.
|
|
362
|
+
// Probed only when the marker is declared, and unreadable specs/ (a stray
|
|
363
|
+
// `specs` file, permission errors) fails closed as a conflict: the marker
|
|
364
|
+
// claims nothing is there, and validate must not crash where the
|
|
365
|
+
// historical path degraded to "no deltas".
|
|
366
|
+
const skipSpecs = marker.declared;
|
|
367
|
+
let specsDirHasFiles = false;
|
|
368
|
+
if (skipSpecs) {
|
|
299
369
|
try {
|
|
300
|
-
|
|
370
|
+
specsDirHasFiles = await hasAnyFileUnder(specsDir);
|
|
301
371
|
}
|
|
302
372
|
catch {
|
|
303
|
-
|
|
373
|
+
specsDirHasFiles = true;
|
|
304
374
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
375
|
+
}
|
|
376
|
+
if (skipSpecs && specsDirHasFiles) {
|
|
377
|
+
issues.push({ level: 'ERROR', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT });
|
|
378
|
+
}
|
|
379
|
+
// The root-level error already names the file and the fix; adding "No
|
|
380
|
+
// deltas found" on top would contradict it, since the deltas are sitting in
|
|
381
|
+
// the file just reported.
|
|
382
|
+
if (totalDeltas === 0 && !hasRootLevelSpec) {
|
|
383
|
+
if (skipSpecs && !specsDirHasFiles) {
|
|
384
|
+
issues.push({ level: 'INFO', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_ACCEPTED });
|
|
385
|
+
}
|
|
386
|
+
else if (!skipSpecs) {
|
|
387
|
+
issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return this.createReport(issues);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Report MODIFIED requirements whose block omits a scenario the main spec
|
|
394
|
+
* still carries. Uses the same comparison archive applies, so validate can
|
|
395
|
+
* only report what archive would refuse.
|
|
396
|
+
*
|
|
397
|
+
* Silent when the main spec or the requirement header is absent: applying a
|
|
398
|
+
* MODIFIED against a base that is not there yet is a different failure (a
|
|
399
|
+
* sister change still in flight is the legitimate case), and archive is the
|
|
400
|
+
* gate for it. A spec that exists but cannot be read is not absent, though —
|
|
401
|
+
* archive aborts on it, so reporting it beats calling the change valid.
|
|
402
|
+
*/
|
|
403
|
+
async findScenarioLossIssues(modified, renamed, mainSpecFile, entryPath, mainSpecRoot) {
|
|
404
|
+
let mainContent;
|
|
405
|
+
FileSystemUtils.assertPathWithin(mainSpecRoot, mainSpecFile);
|
|
406
|
+
try {
|
|
407
|
+
mainContent = await fs.readFile(mainSpecFile, 'utf-8');
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
const code = error?.code;
|
|
411
|
+
// Reported only for the codes that mean the file itself is unusable, and
|
|
412
|
+
// will be just as unusable when archive reads it. Everything else -
|
|
413
|
+
// ENOENT/ENOTDIR ("no main spec"), and transient resource errors like
|
|
414
|
+
// EMFILE that say nothing about the file - stays silent rather than
|
|
415
|
+
// failing a change that is fine. `validate --all` reads six changes at
|
|
416
|
+
// once, so a resource error must never become a verdict.
|
|
417
|
+
const UNUSABLE = new Set(['EACCES', 'EPERM', 'EISDIR', 'ELOOP', 'ENAMETOOLONG']);
|
|
418
|
+
if (!code || !UNUSABLE.has(code))
|
|
419
|
+
return [];
|
|
420
|
+
return [
|
|
421
|
+
{
|
|
422
|
+
level: 'ERROR',
|
|
423
|
+
path: entryPath,
|
|
424
|
+
message: `Could not read ${FileSystemUtils.toPosixPath(mainSpecFile)} to check the MODIFIED requirements against it ` +
|
|
425
|
+
`(${code}). Archive reads the same file, so fix the file before archiving.`,
|
|
426
|
+
},
|
|
427
|
+
];
|
|
428
|
+
}
|
|
429
|
+
const currentBlocks = new Map();
|
|
430
|
+
for (const block of extractRequirementsSection(mainContent).bodyBlocks) {
|
|
431
|
+
currentBlocks.set(normalizeRequirementName(block.name), block);
|
|
432
|
+
}
|
|
433
|
+
// Archive applies RENAMED before MODIFIED, so a MODIFIED naming the new
|
|
434
|
+
// header is compared against the renamed block's scenarios. Fall back to
|
|
435
|
+
// the old header, or a rename-plus-modify pair would skip the check.
|
|
436
|
+
const renamedFrom = new Map(renamed.map(({ from, to }) => [normalizeRequirementName(to), normalizeRequirementName(from)]));
|
|
437
|
+
// Walked, not looked up once: renames chain (A→B then B→C leaves C holding
|
|
438
|
+
// A's block), and the visited set stops a cycle from looping forever. Every
|
|
439
|
+
// name in a rename cycle is also a rename FROM, so the skip above already
|
|
440
|
+
// keeps the walk out of one; the guard stays because the cost of being
|
|
441
|
+
// wrong about that is a hung CLI, not a wrong message.
|
|
442
|
+
const currentBlockFor = (name) => {
|
|
443
|
+
const visited = new Set();
|
|
444
|
+
let key = name;
|
|
445
|
+
while (key !== undefined && !visited.has(key)) {
|
|
446
|
+
const block = currentBlocks.get(key);
|
|
447
|
+
if (block)
|
|
448
|
+
return block;
|
|
449
|
+
visited.add(key);
|
|
450
|
+
key = renamedFrom.get(key);
|
|
313
451
|
}
|
|
452
|
+
return undefined;
|
|
314
453
|
};
|
|
315
|
-
|
|
316
|
-
|
|
454
|
+
// A MODIFIED naming a header the same delta renames away is already
|
|
455
|
+
// reported ("MODIFIED references old name from RENAMED"), and the block it
|
|
456
|
+
// would land on is not the one it names — so any scenario named here would
|
|
457
|
+
// send the author after the wrong requirement.
|
|
458
|
+
const renamedAway = new Set(renamed.map(({ from }) => normalizeRequirementName(from)));
|
|
459
|
+
const issues = [];
|
|
460
|
+
for (const block of modified) {
|
|
461
|
+
const key = normalizeRequirementName(block.name);
|
|
462
|
+
if (renamedAway.has(key))
|
|
463
|
+
continue;
|
|
464
|
+
const current = currentBlockFor(key);
|
|
465
|
+
if (!current)
|
|
466
|
+
continue;
|
|
467
|
+
const missing = findMissingCurrentScenarios(current, block);
|
|
468
|
+
if (missing.length === 0)
|
|
469
|
+
continue;
|
|
470
|
+
issues.push({
|
|
471
|
+
level: 'ERROR',
|
|
472
|
+
path: entryPath,
|
|
473
|
+
message: `MODIFIED "${block.name}" omits scenario(s) the current spec still has: ` +
|
|
474
|
+
`${missing.map(name => `"${name}"`).join(', ')}. ` +
|
|
475
|
+
'Copy them into the MODIFIED block (a MODIFIED requirement replaces the whole block, so archive refuses to drop them).',
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return issues;
|
|
479
|
+
}
|
|
480
|
+
formatInvalidMarkerMessage(invalidReason) {
|
|
481
|
+
return `${VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA} (${invalidReason})`;
|
|
317
482
|
}
|
|
318
483
|
convertZodErrors(error) {
|
|
319
484
|
return error.issues.map(err => {
|
|
@@ -361,22 +526,28 @@ export class Validator {
|
|
|
361
526
|
});
|
|
362
527
|
}
|
|
363
528
|
});
|
|
364
|
-
// SHALL/MUST body-keyword
|
|
529
|
+
// SHALL/MUST body-keyword guidance for main specs (#1156, #243). The main-spec
|
|
365
530
|
// parser collapses the requirement header into `text`, so we recover the
|
|
366
531
|
// header+body pairs here (the same source the delta path trusts) and reuse
|
|
367
|
-
// the delta detection
|
|
368
|
-
//
|
|
369
|
-
// only and the generic message otherwise. Emitted exactly once per
|
|
532
|
+
// the delta detection. A non-empty body that omits the English keyword gets
|
|
533
|
+
// guidance, while a missing body remains an error. Emitted exactly once per
|
|
370
534
|
// requirement (the Zod refine that used to emit a generic error is removed).
|
|
371
535
|
extractRequirementsSection(content).bodyBlocks.forEach((block, index) => {
|
|
372
536
|
const requirementText = this.extractRequirementText(block.raw);
|
|
373
|
-
if (!requirementText
|
|
537
|
+
if (!requirementText) {
|
|
374
538
|
issues.push({
|
|
375
539
|
level: 'ERROR',
|
|
376
540
|
path: `requirements[${index}]`,
|
|
377
541
|
message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name),
|
|
378
542
|
});
|
|
379
543
|
}
|
|
544
|
+
else if (!this.containsShallOrMust(requirementText)) {
|
|
545
|
+
issues.push({
|
|
546
|
+
level: 'WARNING',
|
|
547
|
+
path: `requirements[${index}]`,
|
|
548
|
+
message: this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name, true),
|
|
549
|
+
});
|
|
550
|
+
}
|
|
380
551
|
});
|
|
381
552
|
return issues;
|
|
382
553
|
}
|
|
@@ -464,7 +635,7 @@ export class Validator {
|
|
|
464
635
|
return containsShallOrMustShared(text);
|
|
465
636
|
}
|
|
466
637
|
/**
|
|
467
|
-
* Build
|
|
638
|
+
* Build a message for a requirement block whose body lacks SHALL/MUST.
|
|
468
639
|
*
|
|
469
640
|
* When the SHALL/MUST keyword already appears in the requirement header (e.g.
|
|
470
641
|
* `### Requirement: The system SHALL ...`) the original generic error
|
|
@@ -473,12 +644,13 @@ export class Validator {
|
|
|
473
644
|
* on the requirement body line (the line right after the header), so we point
|
|
474
645
|
* the author at that exact fix when the keyword is found in the header only.
|
|
475
646
|
*/
|
|
476
|
-
buildMissingShallOrMustMessage(prefix, blockName) {
|
|
477
|
-
const base = `${prefix} must contain SHALL or MUST`;
|
|
647
|
+
buildMissingShallOrMustMessage(prefix, blockName, guidanceOnly = false) {
|
|
648
|
+
const base = `${prefix} ${guidanceOnly ? 'should' : 'must'} contain SHALL or MUST`;
|
|
649
|
+
const suffix = guidanceOnly ? ' (RFC 2119 best practice for English specs)' : '';
|
|
478
650
|
if (this.containsShallOrMust(blockName)) {
|
|
479
|
-
return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header
|
|
651
|
+
return `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.${suffix}`;
|
|
480
652
|
}
|
|
481
|
-
return base
|
|
653
|
+
return `${base}${suffix}`;
|
|
482
654
|
}
|
|
483
655
|
countScenarios(blockRaw) {
|
|
484
656
|
// Fence-aware count via the shared reader: a `#### Scenario:` inside a fenced
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry to ask: only the environment variable npm exports (under
|
|
3
|
+
* `npm run`, or an explicit export). Deliberately not a `registry=` line from
|
|
4
|
+
* any .npmrc — letting file contents choose the destination of an outbound
|
|
5
|
+
* request is a flow worth avoiding for a convenience this small, and a project
|
|
6
|
+
* file would travel with a cloned repository. Anyone on a private mirror can
|
|
7
|
+
* export `npm_config_registry`, or turn the check off entirely.
|
|
8
|
+
*/
|
|
9
|
+
export declare function registryUrl(): string;
|
|
10
|
+
/**
|
|
11
|
+
* Compares two semver-ish versions. Returns 1 when a > b, -1 when a < b, 0
|
|
12
|
+
* otherwise. Prereleases sort below their release (1.7.0-beta.1 < 1.7.0).
|
|
13
|
+
*/
|
|
14
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
15
|
+
/**
|
|
16
|
+
* Returns the published version when the installed CLI is behind it, otherwise
|
|
17
|
+
* null. Never throws and never blocks for longer than the request timeout.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getAvailableCliUpdate(): Promise<string | null>;
|
|
20
|
+
/**
|
|
21
|
+
* Directory the running CLI was loaded from, or null when it cannot be
|
|
22
|
+
* resolved. Shown in the upgrade hint so anyone who upgraded but still runs an
|
|
23
|
+
* old binary — a stale pnpm/volta/npx shim, or two installs on PATH — can see
|
|
24
|
+
* which copy is actually answering.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getInstallDir(): string | null;
|
|
27
|
+
/**
|
|
28
|
+
* True when the running CLI resolves from a `node_modules` belonging to the
|
|
29
|
+
* project being updated or any ancestor of it — the hoisted-root layout npm and
|
|
30
|
+
* pnpm workspaces produce. Anchored on the target path rather than the working
|
|
31
|
+
* directory, since `openspec update <path>` and running from a sub-package are
|
|
32
|
+
* both normal. Never throws: process.cwd() fails when the directory has been
|
|
33
|
+
* deleted, and a wrong upgrade hint must not take down a successful update.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isProjectLocalInstall(installDir: string | null, projectPath?: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* True for the throwaway caches npx/pnpm dlx/bunx unpack into. Telling those
|
|
38
|
+
* users to install globally would create the second copy on PATH they were
|
|
39
|
+
* deliberately avoiding.
|
|
40
|
+
*/
|
|
41
|
+
export declare function isEphemeralRunnerInstall(installDir: string | null): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Directories npm installs global packages into. Derived from the running node
|
|
44
|
+
* rather than by shelling out to `npm prefix -g`, which would cost more than
|
|
45
|
+
* the version check itself. Only a hint: `process.execPath` is realpath'd, so
|
|
46
|
+
* on Homebrew it lands in the Cellar rather than the brew prefix — which is
|
|
47
|
+
* why the install's own layout is the primary signal below.
|
|
48
|
+
*/
|
|
49
|
+
export declare function npmGlobalRoots(): string[];
|
|
50
|
+
/**
|
|
51
|
+
* The prefix of an npm global install, read from the install's own shape:
|
|
52
|
+
* `<prefix>/lib/node_modules/<pkg>` on POSIX, `<prefix>/node_modules/<pkg>` on
|
|
53
|
+
* Windows. Self-describing, so it holds for Homebrew, nvm, Debian and anywhere
|
|
54
|
+
* else npm's prefix is not derivable from the node binary. Null when the
|
|
55
|
+
* layout does not match.
|
|
56
|
+
*/
|
|
57
|
+
export declare function npmPrefixFromInstallDir(installDir: string | null): string | null;
|
|
58
|
+
/**
|
|
59
|
+
* True only when npm itself owns this copy. Everything else — a pnpm, bun,
|
|
60
|
+
* yarn or volta global — would be made worse by `npm install -g`, which adds a
|
|
61
|
+
* second copy that may not even be the one on PATH.
|
|
62
|
+
*/
|
|
63
|
+
export declare function isNpmGlobalInstall(installDir: string | null, roots?: string[]): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* True when the CLI is running from a clone rather than an install. Upgrade
|
|
66
|
+
* advice is meaningless there: the version is whatever the branch says.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isSourceCheckout(installDir: string | null): boolean;
|
|
69
|
+
export type PackageManager = 'npm' | 'pnpm' | 'bun' | 'yarn' | 'volta';
|
|
70
|
+
/**
|
|
71
|
+
* The package manager that owns this copy, so the printed command is one the
|
|
72
|
+
* user's setup will actually honor.
|
|
73
|
+
*/
|
|
74
|
+
export declare function detectPackageManager(installDir: string | null): PackageManager;
|
|
75
|
+
/**
|
|
76
|
+
* Builds the hint, with the upgrade command chosen for how this copy of the CLI
|
|
77
|
+
* was installed. Pure so every branch is assertable.
|
|
78
|
+
*/
|
|
79
|
+
export declare function buildCliUpdateLines(latestVersion: string, installDir: string | null, projectPath: string, options?: {
|
|
80
|
+
withCommand?: boolean;
|
|
81
|
+
}): string[];
|
|
82
|
+
/**
|
|
83
|
+
* The upgrade command for however this copy was installed, plus the reminder
|
|
84
|
+
* that instruction files come from the CLI and so need a second pass.
|
|
85
|
+
*/
|
|
86
|
+
export declare function buildUpgradeCommandLines(installDir: string | null, projectPath: string): string[];
|
|
87
|
+
/**
|
|
88
|
+
* Whether we can run the upgrade for the user instead of only printing it.
|
|
89
|
+
*
|
|
90
|
+
* Only an npm-owned global install qualifies, because `npm install -g` is the
|
|
91
|
+
* only command we run: a pnpm/bun/yarn/volta global would get a second copy
|
|
92
|
+
* that may not be the one on PATH, a project dependency belongs to that
|
|
93
|
+
* project's package manager, an npx/dlx cache has nothing to upgrade, and a
|
|
94
|
+
* source checkout is not an install at all.
|
|
95
|
+
*/
|
|
96
|
+
export declare function canSelfUpgrade(installDir: string | null, projectPath: string): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Whether to offer the upgrade rather than just print the command. Kept here,
|
|
99
|
+
* as a pure function of the environment, because the interesting mistakes live
|
|
100
|
+
* in this decision: offering where `npm install -g` cannot help, or asking a
|
|
101
|
+
* question no one can answer.
|
|
102
|
+
*/
|
|
103
|
+
export declare function shouldOfferUpgrade(params: {
|
|
104
|
+
installDir: string | null;
|
|
105
|
+
projectPath: string;
|
|
106
|
+
interactive: boolean;
|
|
107
|
+
stdoutIsTty: boolean;
|
|
108
|
+
}): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* The `openspec` npm installs alongside its global package, so the upgrade can
|
|
111
|
+
* be handed to the copy npm just wrote rather than to whatever PATH resolves.
|
|
112
|
+
* Null when it cannot be found, in which case PATH is the only option left.
|
|
113
|
+
*/
|
|
114
|
+
export declare function upgradedBinPath(roots?: string[], installDir?: string | null): string | null;
|
|
115
|
+
/**
|
|
116
|
+
* Asks a CLI binary its version. Used to confirm an upgrade actually landed:
|
|
117
|
+
* `npm install -g` exits 0 even when it installed nothing, so its exit code
|
|
118
|
+
* alone cannot justify telling the user they are on a new version.
|
|
119
|
+
*/
|
|
120
|
+
export declare function readCliVersion(binPath: string): Promise<string | null>;
|
|
121
|
+
export type UpgradeOutcome = 'upgraded' | 'declined' | 'failed' | 'cancelled' | 'not-on-path';
|
|
122
|
+
/**
|
|
123
|
+
* Offers to run the upgrade and reports what actually happened. The version is
|
|
124
|
+
* read back from the installed binary rather than assumed, so "upgraded" is a
|
|
125
|
+
* fact and a PATH that still answers with the old copy is caught here instead
|
|
126
|
+
* of silently doing nothing.
|
|
127
|
+
*/
|
|
128
|
+
export declare function offerCliUpgrade(latestVersion: string): Promise<UpgradeOutcome>;
|
|
129
|
+
/**
|
|
130
|
+
* Runs `openspec update` again with the CLI that was just installed — this
|
|
131
|
+
* process is still the old code, so it cannot write the new workflows itself.
|
|
132
|
+
* Resolves the exit code to pass along; when no `openspec` is on PATH the
|
|
133
|
+
* upgrade still landed but nothing was regenerated, so it says so and
|
|
134
|
+
* resolves 0 rather than reporting a failure the upgrade did not have.
|
|
135
|
+
*/
|
|
136
|
+
export declare function rerunUpdateWithUpgradedCli(projectPath: string, options?: {
|
|
137
|
+
force?: boolean;
|
|
138
|
+
binPath?: string;
|
|
139
|
+
}): Promise<number>;
|
|
140
|
+
/**
|
|
141
|
+
* Prints the upgrade hint. Instruction files are generated by the installed
|
|
142
|
+
* CLI, so "up to date" only ever means "matches this CLI" — without this note
|
|
143
|
+
* a stale install looks like a successful update.
|
|
144
|
+
*/
|
|
145
|
+
export declare function displayCliUpdateNote(latestVersion: string, projectPath?: string, options?: {
|
|
146
|
+
withCommand?: boolean;
|
|
147
|
+
}): void;
|
|
148
|
+
/**
|
|
149
|
+
* Prints just the manual command, for when the offer was declined or failed.
|
|
150
|
+
*/
|
|
151
|
+
export declare function displayUpgradeCommand(projectPath?: string): void;
|
|
152
|
+
//# sourceMappingURL=version-check.d.ts.map
|