@seanmars/tospec 0.19.0-beta.8 → 0.21.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/CHANGELOG.md +89 -296
- package/README.md +69 -82
- package/assets/dashboard/app.js +11 -0
- package/assets/dashboard/style.css +7 -0
- package/bin/tospec.js +1 -1
- package/dist/cli/index.d.ts +6 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +120 -112
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/config.d.ts +9 -17
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +300 -145
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/dashboard.d.ts +57 -99
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +252 -330
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/decision.d.ts +38 -27
- package/dist/commands/decision.d.ts.map +1 -1
- package/dist/commands/decision.js +301 -131
- package/dist/commands/decision.js.map +1 -1
- package/dist/commands/metrics.d.ts +28 -51
- package/dist/commands/metrics.d.ts.map +1 -1
- package/dist/commands/metrics.js +62 -93
- package/dist/commands/metrics.js.map +1 -1
- package/dist/commands/shared-output.d.ts +12 -27
- package/dist/commands/shared-output.d.ts.map +1 -1
- package/dist/commands/shared-output.js +22 -45
- package/dist/commands/shared-output.js.map +1 -1
- package/dist/commands/show.d.ts +4 -7
- package/dist/commands/show.d.ts.map +1 -1
- package/dist/commands/show.js +23 -11
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/validate.d.ts +34 -58
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +228 -142
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/workflow/index.d.ts +1 -5
- package/dist/commands/workflow/index.d.ts.map +1 -1
- package/dist/commands/workflow/index.js +1 -5
- package/dist/commands/workflow/index.js.map +1 -1
- package/dist/commands/workflow/instructions.d.ts +14 -24
- package/dist/commands/workflow/instructions.d.ts.map +1 -1
- package/dist/commands/workflow/instructions.js +230 -132
- package/dist/commands/workflow/instructions.js.map +1 -1
- package/dist/commands/workflow/new-change.d.ts +2 -5
- package/dist/commands/workflow/new-change.d.ts.map +1 -1
- package/dist/commands/workflow/new-change.js +75 -34
- package/dist/commands/workflow/new-change.js.map +1 -1
- package/dist/commands/workflow/schemas.d.ts +1 -5
- package/dist/commands/workflow/schemas.d.ts.map +1 -1
- package/dist/commands/workflow/schemas.js +6 -17
- package/dist/commands/workflow/schemas.js.map +1 -1
- package/dist/commands/workflow/shared.d.ts +37 -42
- package/dist/commands/workflow/shared.d.ts.map +1 -1
- package/dist/commands/workflow/shared.js +23 -54
- package/dist/commands/workflow/shared.js.map +1 -1
- package/dist/commands/workflow/status.d.ts +7 -17
- package/dist/commands/workflow/status.d.ts.map +1 -1
- package/dist/commands/workflow/status.js +57 -72
- package/dist/commands/workflow/status.js.map +1 -1
- package/dist/commands/workflow/templates.d.ts +8 -8
- package/dist/commands/workflow/templates.d.ts.map +1 -1
- package/dist/commands/workflow/templates.js +32 -46
- package/dist/commands/workflow/templates.js.map +1 -1
- package/dist/core/archive.d.ts +33 -31
- package/dist/core/archive.d.ts.map +1 -1
- package/dist/core/archive.js +322 -284
- package/dist/core/archive.js.map +1 -1
- package/dist/core/artifact-graph/graph.d.ts +25 -42
- package/dist/core/artifact-graph/graph.d.ts.map +1 -1
- package/dist/core/artifact-graph/graph.js +45 -63
- package/dist/core/artifact-graph/graph.js.map +1 -1
- package/dist/core/artifact-graph/index.d.ts +1 -1
- package/dist/core/artifact-graph/index.d.ts.map +1 -1
- package/dist/core/artifact-graph/index.js +1 -1
- package/dist/core/artifact-graph/index.js.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts +54 -120
- package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.js +129 -111
- package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
- package/dist/core/artifact-graph/outputs.d.ts +9 -23
- package/dist/core/artifact-graph/outputs.d.ts.map +1 -1
- package/dist/core/artifact-graph/outputs.js +45 -38
- package/dist/core/artifact-graph/outputs.js.map +1 -1
- package/dist/core/artifact-graph/resolver.d.ts +36 -81
- package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
- package/dist/core/artifact-graph/resolver.js +60 -101
- package/dist/core/artifact-graph/resolver.js.map +1 -1
- package/dist/core/artifact-graph/schema.d.ts +0 -6
- package/dist/core/artifact-graph/schema.d.ts.map +1 -1
- package/dist/core/artifact-graph/schema.js +7 -32
- package/dist/core/artifact-graph/schema.js.map +1 -1
- package/dist/core/artifact-graph/state.d.ts +1 -8
- package/dist/core/artifact-graph/state.d.ts.map +1 -1
- package/dist/core/artifact-graph/state.js +2 -17
- package/dist/core/artifact-graph/state.js.map +1 -1
- package/dist/core/artifact-graph/stub-detection.d.ts +6 -14
- package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -1
- package/dist/core/artifact-graph/stub-detection.js +13 -16
- package/dist/core/artifact-graph/stub-detection.js.map +1 -1
- package/dist/core/artifact-graph/types.d.ts +4 -0
- package/dist/core/artifact-graph/types.d.ts.map +1 -1
- package/dist/core/artifact-graph/types.js +46 -11
- package/dist/core/artifact-graph/types.js.map +1 -1
- package/dist/core/available-tools.d.ts +3 -12
- package/dist/core/available-tools.d.ts.map +1 -1
- package/dist/core/available-tools.js +4 -13
- package/dist/core/available-tools.js.map +1 -1
- package/dist/core/change-metadata/schema.d.ts +1 -1
- package/dist/core/change-metadata/schema.d.ts.map +1 -1
- package/dist/core/change-metadata/schema.js +10 -7
- package/dist/core/change-metadata/schema.js.map +1 -1
- package/dist/core/change-presenter.d.ts +16 -27
- package/dist/core/change-presenter.d.ts.map +1 -1
- package/dist/core/change-presenter.js +53 -53
- package/dist/core/change-presenter.js.map +1 -1
- package/dist/core/change-status-policy.d.ts +4 -8
- package/dist/core/change-status-policy.d.ts.map +1 -1
- package/dist/core/change-status-policy.js +9 -17
- package/dist/core/change-status-policy.js.map +1 -1
- package/dist/core/codex-metrics.d.ts +25 -45
- package/dist/core/codex-metrics.d.ts.map +1 -1
- package/dist/core/codex-metrics.js +44 -88
- package/dist/core/codex-metrics.js.map +1 -1
- package/dist/core/codex-residue.d.ts +14 -15
- package/dist/core/codex-residue.d.ts.map +1 -1
- package/dist/core/codex-residue.js +18 -22
- package/dist/core/codex-residue.js.map +1 -1
- package/dist/core/command-generation/adapters/claude.d.ts +2 -9
- package/dist/core/command-generation/adapters/claude.d.ts.map +1 -1
- package/dist/core/command-generation/adapters/claude.js +2 -12
- package/dist/core/command-generation/adapters/claude.js.map +1 -1
- package/dist/core/command-generation/adapters/index.d.ts +1 -9
- package/dist/core/command-generation/adapters/index.d.ts.map +1 -1
- package/dist/core/command-generation/adapters/index.js +1 -9
- package/dist/core/command-generation/adapters/index.js.map +1 -1
- package/dist/core/command-generation/generator.d.ts +0 -17
- package/dist/core/command-generation/generator.d.ts.map +1 -1
- package/dist/core/command-generation/generator.js +0 -17
- package/dist/core/command-generation/generator.js.map +1 -1
- package/dist/core/command-generation/index.d.ts +2 -5
- package/dist/core/command-generation/index.d.ts.map +1 -1
- package/dist/core/command-generation/index.js +0 -9
- package/dist/core/command-generation/index.js.map +1 -1
- package/dist/core/command-generation/types.d.ts +10 -36
- package/dist/core/command-generation/types.d.ts.map +1 -1
- package/dist/core/command-generation/types.js +0 -6
- package/dist/core/command-generation/types.js.map +1 -1
- package/dist/core/command-generation/yaml.d.ts +3 -18
- package/dist/core/command-generation/yaml.d.ts.map +1 -1
- package/dist/core/command-generation/yaml.js +5 -23
- package/dist/core/command-generation/yaml.js.map +1 -1
- package/dist/core/config-prompts.d.ts +2 -4
- package/dist/core/config-prompts.d.ts.map +1 -1
- package/dist/core/config-prompts.js +2 -7
- package/dist/core/config-prompts.js.map +1 -1
- package/dist/core/config-schema.d.ts +7 -41
- package/dist/core/config-schema.d.ts.map +1 -1
- package/dist/core/config-schema.js +35 -74
- package/dist/core/config-schema.js.map +1 -1
- package/dist/core/config.d.ts +25 -49
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +22 -45
- package/dist/core/config.js.map +1 -1
- package/dist/core/dashboard-activity.d.ts +7 -9
- package/dist/core/dashboard-activity.d.ts.map +1 -1
- package/dist/core/dashboard-activity.js +34 -25
- package/dist/core/dashboard-activity.js.map +1 -1
- package/dist/core/dashboard-data.d.ts +35 -22
- package/dist/core/dashboard-data.d.ts.map +1 -1
- package/dist/core/dashboard-data.js +57 -72
- package/dist/core/dashboard-data.js.map +1 -1
- package/dist/core/global-config.d.ts +24 -53
- package/dist/core/global-config.d.ts.map +1 -1
- package/dist/core/global-config.js +38 -67
- package/dist/core/global-config.js.map +1 -1
- package/dist/core/init.d.ts +12 -28
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +93 -169
- package/dist/core/init.js.map +1 -1
- package/dist/core/list.d.ts.map +1 -1
- package/dist/core/list.js +95 -41
- package/dist/core/list.js.map +1 -1
- package/dist/core/local-server.d.ts +41 -83
- package/dist/core/local-server.d.ts.map +1 -1
- package/dist/core/local-server.js +53 -98
- package/dist/core/local-server.js.map +1 -1
- package/dist/core/markdown-render.d.ts +15 -23
- package/dist/core/markdown-render.d.ts.map +1 -1
- package/dist/core/markdown-render.js +25 -34
- package/dist/core/markdown-render.js.map +1 -1
- package/dist/core/migrate.d.ts +19 -16
- package/dist/core/migrate.d.ts.map +1 -1
- package/dist/core/migrate.js +162 -136
- package/dist/core/migrate.js.map +1 -1
- package/dist/core/parsers/change-parser.d.ts +7 -10
- package/dist/core/parsers/change-parser.d.ts.map +1 -1
- package/dist/core/parsers/change-parser.js +48 -56
- package/dist/core/parsers/change-parser.js.map +1 -1
- package/dist/core/parsers/markdown-parser.d.ts +8 -9
- package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
- package/dist/core/parsers/markdown-parser.js +23 -30
- package/dist/core/parsers/markdown-parser.js.map +1 -1
- package/dist/core/parsers/requirement-blocks.d.ts +43 -15
- package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
- package/dist/core/parsers/requirement-blocks.js +142 -70
- package/dist/core/parsers/requirement-blocks.js.map +1 -1
- package/dist/core/parsers/requirement-text.d.ts +73 -79
- package/dist/core/parsers/requirement-text.d.ts.map +1 -1
- package/dist/core/parsers/requirement-text.js +137 -79
- package/dist/core/parsers/requirement-text.js.map +1 -1
- package/dist/core/parsers/spec-structure.d.ts.map +1 -1
- package/dist/core/parsers/spec-structure.js +10 -6
- package/dist/core/parsers/spec-structure.js.map +1 -1
- package/dist/core/profiles.d.ts +3 -10
- package/dist/core/profiles.d.ts.map +1 -1
- package/dist/core/profiles.js +5 -12
- package/dist/core/profiles.js.map +1 -1
- package/dist/core/project-config.d.ts +43 -44
- package/dist/core/project-config.d.ts.map +1 -1
- package/dist/core/project-config.js +107 -82
- package/dist/core/project-config.js.map +1 -1
- package/dist/core/project-layout.d.ts +9 -17
- package/dist/core/project-layout.d.ts.map +1 -1
- package/dist/core/project-layout.js +16 -26
- package/dist/core/project-layout.js.map +1 -1
- package/dist/core/root-selection.d.ts +8 -14
- package/dist/core/root-selection.d.ts.map +1 -1
- package/dist/core/root-selection.js +3 -6
- package/dist/core/root-selection.js.map +1 -1
- package/dist/core/rules.d.ts.map +1 -1
- package/dist/core/rules.js +2 -3
- package/dist/core/rules.js.map +1 -1
- package/dist/core/schema-names.d.ts +16 -0
- package/dist/core/schema-names.d.ts.map +1 -0
- package/dist/core/schema-names.js +16 -0
- package/dist/core/schema-names.js.map +1 -0
- package/dist/core/schemas/base.schema.d.ts.map +1 -1
- package/dist/core/schemas/base.schema.js +6 -12
- package/dist/core/schemas/base.schema.js.map +1 -1
- package/dist/core/schemas/change.schema.d.ts +8 -0
- package/dist/core/schemas/change.schema.d.ts.map +1 -1
- package/dist/core/schemas/change.schema.js +41 -10
- package/dist/core/schemas/change.schema.js.map +1 -1
- package/dist/core/shared/index.d.ts +2 -7
- package/dist/core/shared/index.d.ts.map +1 -1
- package/dist/core/shared/index.js +2 -7
- package/dist/core/shared/index.js.map +1 -1
- package/dist/core/shared/rules-generation.d.ts +5 -15
- package/dist/core/shared/rules-generation.d.ts.map +1 -1
- package/dist/core/shared/rules-generation.js +33 -37
- package/dist/core/shared/rules-generation.js.map +1 -1
- package/dist/core/shared/skill-generation.d.ts +28 -43
- package/dist/core/shared/skill-generation.d.ts.map +1 -1
- package/dist/core/shared/skill-generation.js +82 -51
- package/dist/core/shared/skill-generation.js.map +1 -1
- package/dist/core/shared/tool-detection.d.ts +35 -76
- package/dist/core/shared/tool-detection.d.ts.map +1 -1
- package/dist/core/shared/tool-detection.js +73 -93
- package/dist/core/shared/tool-detection.js.map +1 -1
- package/dist/core/skill-metrics.d.ts +36 -63
- package/dist/core/skill-metrics.d.ts.map +1 -1
- package/dist/core/skill-metrics.js +34 -73
- package/dist/core/skill-metrics.js.map +1 -1
- package/dist/core/spec-presenter.d.ts.map +1 -1
- package/dist/core/spec-presenter.js +5 -10
- package/dist/core/spec-presenter.js.map +1 -1
- package/dist/core/specs-apply.d.ts +16 -31
- package/dist/core/specs-apply.d.ts.map +1 -1
- package/dist/core/specs-apply.js +146 -195
- package/dist/core/specs-apply.js.map +1 -1
- package/dist/core/templates/fragments/interview.d.ts +2 -6
- package/dist/core/templates/fragments/interview.d.ts.map +1 -1
- package/dist/core/templates/fragments/interview.js +2 -6
- package/dist/core/templates/fragments/interview.js.map +1 -1
- package/dist/core/templates/fragments/next-step.d.ts +4 -8
- package/dist/core/templates/fragments/next-step.d.ts.map +1 -1
- package/dist/core/templates/fragments/next-step.js +4 -8
- package/dist/core/templates/fragments/next-step.js.map +1 -1
- package/dist/core/templates/fragments/validate.d.ts +13 -0
- package/dist/core/templates/fragments/validate.d.ts.map +1 -0
- package/dist/core/templates/fragments/validate.js +13 -0
- package/dist/core/templates/fragments/validate.js.map +1 -0
- package/dist/core/templates/fragments/verify.d.ts +9 -12
- package/dist/core/templates/fragments/verify.d.ts.map +1 -1
- package/dist/core/templates/fragments/verify.js +9 -12
- package/dist/core/templates/fragments/verify.js.map +1 -1
- package/dist/core/templates/index.d.ts +0 -6
- package/dist/core/templates/index.d.ts.map +1 -1
- package/dist/core/templates/index.js +0 -7
- package/dist/core/templates/index.js.map +1 -1
- package/dist/core/templates/skill-templates.d.ts +1 -5
- package/dist/core/templates/skill-templates.d.ts.map +1 -1
- package/dist/core/templates/skill-templates.js +0 -5
- package/dist/core/templates/skill-templates.js.map +1 -1
- package/dist/core/templates/types.d.ts +3 -7
- package/dist/core/templates/types.d.ts.map +1 -1
- package/dist/core/templates/types.js +0 -3
- package/dist/core/templates/types.js.map +1 -1
- package/dist/core/templates/workflows/apply.d.ts +3 -9
- package/dist/core/templates/workflows/apply.d.ts.map +1 -1
- package/dist/core/templates/workflows/apply.js +11 -13
- package/dist/core/templates/workflows/apply.js.map +1 -1
- package/dist/core/templates/workflows/archive.d.ts +0 -6
- package/dist/core/templates/workflows/archive.d.ts.map +1 -1
- package/dist/core/templates/workflows/archive.js +16 -5
- package/dist/core/templates/workflows/archive.js.map +1 -1
- package/dist/core/templates/workflows/decision.js +3 -3
- package/dist/core/templates/workflows/decision.js.map +1 -1
- package/dist/core/templates/workflows/explore.js +1 -1
- package/dist/core/templates/workflows/grill.d.ts.map +1 -1
- package/dist/core/templates/workflows/grill.js +0 -2
- package/dist/core/templates/workflows/grill.js.map +1 -1
- package/dist/core/templates/workflows/issue.d.ts +0 -6
- package/dist/core/templates/workflows/issue.d.ts.map +1 -1
- package/dist/core/templates/workflows/issue.js +3 -2
- package/dist/core/templates/workflows/issue.js.map +1 -1
- package/dist/core/templates/workflows/propose.d.ts +0 -6
- package/dist/core/templates/workflows/propose.d.ts.map +1 -1
- package/dist/core/templates/workflows/propose.js +2 -2
- package/dist/core/templates/workflows/propose.js.map +1 -1
- package/dist/core/templates/workflows/sync.d.ts +2 -8
- package/dist/core/templates/workflows/sync.d.ts.map +1 -1
- package/dist/core/templates/workflows/sync.js +4 -3
- package/dist/core/templates/workflows/sync.js.map +1 -1
- package/dist/core/templates/workflows/update.d.ts +0 -6
- package/dist/core/templates/workflows/update.d.ts.map +1 -1
- package/dist/core/templates/workflows/update.js +9 -2
- package/dist/core/templates/workflows/update.js.map +1 -1
- package/dist/core/update.d.ts +10 -36
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +59 -126
- package/dist/core/update.js.map +1 -1
- package/dist/core/user-state-migration.d.ts +13 -15
- package/dist/core/user-state-migration.d.ts.map +1 -1
- package/dist/core/user-state-migration.js +30 -23
- package/dist/core/user-state-migration.js.map +1 -1
- package/dist/core/validation/constants.d.ts +4 -10
- package/dist/core/validation/constants.d.ts.map +1 -1
- package/dist/core/validation/constants.js +25 -25
- package/dist/core/validation/constants.js.map +1 -1
- package/dist/core/validation/prose-length.d.ts +15 -0
- package/dist/core/validation/prose-length.d.ts.map +1 -0
- package/dist/core/validation/prose-length.js +29 -0
- package/dist/core/validation/prose-length.js.map +1 -0
- package/dist/core/validation/purpose-placeholder.d.ts +9 -16
- package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
- package/dist/core/validation/purpose-placeholder.js +30 -44
- package/dist/core/validation/purpose-placeholder.js.map +1 -1
- package/dist/core/validation/section-validator.d.ts +4 -4
- package/dist/core/validation/section-validator.d.ts.map +1 -1
- package/dist/core/validation/section-validator.js +30 -12
- package/dist/core/validation/section-validator.js.map +1 -1
- package/dist/core/validation/task-numbering.d.ts +6 -3
- package/dist/core/validation/task-numbering.d.ts.map +1 -1
- package/dist/core/validation/task-numbering.js +23 -11
- package/dist/core/validation/task-numbering.js.map +1 -1
- package/dist/core/validation/types.d.ts +18 -0
- package/dist/core/validation/types.d.ts.map +1 -1
- package/dist/core/validation/types.js +12 -1
- package/dist/core/validation/types.js.map +1 -1
- package/dist/core/validation/validator.d.ts +42 -68
- package/dist/core/validation/validator.d.ts.map +1 -1
- package/dist/core/validation/validator.js +474 -285
- package/dist/core/validation/validator.js.map +1 -1
- package/dist/prompts/searchable-multi-select.d.ts +3 -8
- package/dist/prompts/searchable-multi-select.d.ts.map +1 -1
- package/dist/prompts/searchable-multi-select.js +16 -39
- package/dist/prompts/searchable-multi-select.js.map +1 -1
- package/dist/utils/change-metadata.d.ts +11 -50
- package/dist/utils/change-metadata.d.ts.map +1 -1
- package/dist/utils/change-metadata.js +48 -67
- package/dist/utils/change-metadata.js.map +1 -1
- package/dist/utils/change-utils.d.ts +24 -76
- package/dist/utils/change-utils.d.ts.map +1 -1
- package/dist/utils/change-utils.js +95 -142
- package/dist/utils/change-utils.js.map +1 -1
- package/dist/utils/file-lock.d.ts +39 -0
- package/dist/utils/file-lock.d.ts.map +1 -0
- package/dist/utils/file-lock.js +149 -0
- package/dist/utils/file-lock.js.map +1 -0
- package/dist/utils/file-system.d.ts +12 -32
- package/dist/utils/file-system.d.ts.map +1 -1
- package/dist/utils/file-system.js +41 -45
- package/dist/utils/file-system.js.map +1 -1
- package/dist/utils/frontmatter.d.ts +7 -11
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +11 -11
- package/dist/utils/frontmatter.js.map +1 -1
- package/dist/utils/interactive.d.ts +4 -9
- package/dist/utils/interactive.d.ts.map +1 -1
- package/dist/utils/interactive.js +2 -4
- package/dist/utils/interactive.js.map +1 -1
- package/dist/utils/item-discovery.d.ts +10 -20
- package/dist/utils/item-discovery.d.ts.map +1 -1
- package/dist/utils/item-discovery.js +31 -55
- package/dist/utils/item-discovery.js.map +1 -1
- package/dist/utils/link.d.ts +9 -18
- package/dist/utils/link.d.ts.map +1 -1
- package/dist/utils/link.js +9 -18
- package/dist/utils/link.js.map +1 -1
- package/dist/utils/requirement-diff.d.ts +13 -23
- package/dist/utils/requirement-diff.d.ts.map +1 -1
- package/dist/utils/requirement-diff.js +13 -23
- package/dist/utils/requirement-diff.js.map +1 -1
- package/dist/utils/spec-files.d.ts +7 -20
- package/dist/utils/spec-files.d.ts.map +1 -1
- package/dist/utils/spec-files.js +26 -51
- package/dist/utils/spec-files.js.map +1 -1
- package/dist/utils/task-progress.d.ts +11 -9
- package/dist/utils/task-progress.d.ts.map +1 -1
- package/dist/utils/task-progress.js +53 -32
- package/dist/utils/task-progress.js.map +1 -1
- package/dist/utils/timestamp.d.ts +5 -8
- package/dist/utils/timestamp.d.ts.map +1 -1
- package/dist/utils/timestamp.js +5 -8
- package/dist/utils/timestamp.js.map +1 -1
- package/package.json +2 -3
- package/schemas/issue/schema.yaml +8 -1
- package/schemas/issue/templates/spec.md +20 -3
- package/schemas/sdd/schema.yaml +20 -1
- package/schemas/sdd/templates/spec.md +20 -3
package/dist/core/archive.js
CHANGED
|
@@ -2,10 +2,12 @@ import { promises as fs } from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
|
|
4
4
|
import { Validator } from './validation/validator.js';
|
|
5
|
+
import { issueSymbol } from './validation/types.js';
|
|
5
6
|
import { validateChangeArtifacts } from '../commands/validate.js';
|
|
6
7
|
import { isRootSelectionError, resolveRootForCommand, toRootOutput, } from './root-selection.js';
|
|
7
8
|
import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, } from './specs-apply.js';
|
|
8
9
|
import { loadChangeContext, formatChangeStatus } from './artifact-graph/index.js';
|
|
10
|
+
import { buildCodeFenceMask, normalizeDocument, stripBom } from './parsers/requirement-text.js';
|
|
9
11
|
import { formatTimestamp } from '../utils/timestamp.js';
|
|
10
12
|
import { findSpecFiles } from '../utils/spec-files.js';
|
|
11
13
|
import { FileSystemUtils, isMissingPathError } from '../utils/file-system.js';
|
|
@@ -13,34 +15,52 @@ import { isKebabId, KEBAB_ID_DESCRIPTION } from './id.js';
|
|
|
13
15
|
import { findTicketPaths, listActiveChangeDirs } from '../utils/item-discovery.js';
|
|
14
16
|
import { METADATA_FILENAME, readChangeMetadata } from '../utils/change-metadata.js';
|
|
15
17
|
import { emitSuccess, emitFailureStatus } from '../commands/shared-output.js';
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
* never assemble the timestamp themselves. Alias of the shared
|
|
19
|
-
* `formatTimestamp` (ticket filenames use the same helper).
|
|
20
|
-
*/
|
|
18
|
+
import { withFileLock } from '../utils/file-lock.js';
|
|
19
|
+
/** Alias of `formatTimestamp`: agents must never assemble archive stamps by hand. */
|
|
21
20
|
export const formatArchiveStamp = formatTimestamp;
|
|
21
|
+
/** `Conclusion: PASS` / `Conclusion: FAIL`, with an optional `(reason)` note. */
|
|
22
|
+
const SYNC_CONCLUSION_LINE = /^Conclusion:\s*(PASS|FAIL)(?:\s*\([^)]*\))?\s*$/;
|
|
22
23
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* Returns null when no conclusion is found, when the line is an unfilled
|
|
25
|
+
* template (`Conclusion: PASS | FAIL`), or when two conclusions disagree —
|
|
26
|
+
* each must leave the `--require-sync` gate shut.
|
|
27
|
+
*
|
|
28
|
+
* Fenced lines are masked because the `tospec-sync` skill documents this format
|
|
29
|
+
* *inside* a fence: a report quoting the template carries a `Conclusion: PASS`
|
|
30
|
+
* that was never a verdict.
|
|
26
31
|
*/
|
|
27
32
|
export function parseSyncReportConclusion(content) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
const lines = normalizeDocument(content).split('\n');
|
|
34
|
+
const fenced = buildCodeFenceMask(lines);
|
|
35
|
+
let verdict = null;
|
|
36
|
+
for (let i = 0; i < lines.length; i++) {
|
|
37
|
+
if (fenced[i])
|
|
38
|
+
continue;
|
|
39
|
+
const line = lines[i].trim();
|
|
40
|
+
if (!line.startsWith('Conclusion:'))
|
|
41
|
+
continue;
|
|
42
|
+
const match = line.match(SYNC_CONCLUSION_LINE);
|
|
43
|
+
// Not "keep looking": scanning past an unparseable conclusion would let a
|
|
44
|
+
// later, well-formed line answer for a verdict this code cannot read.
|
|
45
|
+
if (!match)
|
|
46
|
+
return null;
|
|
47
|
+
const found = match[1];
|
|
48
|
+
if (verdict === null)
|
|
49
|
+
verdict = found;
|
|
50
|
+
else if (verdict !== found)
|
|
51
|
+
return null; // two conclusions, no way to pick
|
|
52
|
+
}
|
|
53
|
+
return verdict;
|
|
54
|
+
}
|
|
55
|
+
/** Whether any unfenced line opens with `Conclusion:`, parseable or not. */
|
|
56
|
+
function hasConclusionLine(content) {
|
|
57
|
+
const lines = normalizeDocument(content).split('\n');
|
|
58
|
+
const fenced = buildCodeFenceMask(lines);
|
|
59
|
+
return lines.some((line, i) => !fenced[i] && line.trim().startsWith('Conclusion:'));
|
|
39
60
|
}
|
|
40
61
|
/**
|
|
41
|
-
* JSON mode is non-interactive:
|
|
42
|
-
*
|
|
43
|
-
* status entry with a non-zero exit code.
|
|
62
|
+
* JSON mode is non-interactive: wherever the human flow would prompt or print
|
|
63
|
+
* prose, this is thrown instead and becomes a machine-readable status entry.
|
|
44
64
|
*/
|
|
45
65
|
class ArchiveBlockedError extends Error {
|
|
46
66
|
diagnostic;
|
|
@@ -69,10 +89,27 @@ function toArchiveDiagnostic(error) {
|
|
|
69
89
|
};
|
|
70
90
|
}
|
|
71
91
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
92
|
+
* The copy fallback finished but the source could not be removed, so `dest` is
|
|
93
|
+
* complete and `src` may be partly deleted. Distinct from a failed copy because
|
|
94
|
+
* the recovery is the opposite: keep `dest`. A caller that rolled back by
|
|
95
|
+
* deleting `dest` would lose whatever the failed removal had already taken.
|
|
96
|
+
*/
|
|
97
|
+
export class MoveSourceNotRemovedError extends Error {
|
|
98
|
+
source;
|
|
99
|
+
destination;
|
|
100
|
+
constructor(source, destination, cause) {
|
|
101
|
+
super(`Copied ${source} to ${destination}, but could not remove the original ` +
|
|
102
|
+
`(${cause instanceof Error ? cause.message : String(cause)}). ` +
|
|
103
|
+
`${destination} is complete; delete what is left of ${source} by hand.`);
|
|
104
|
+
this.source = source;
|
|
105
|
+
this.destination = destination;
|
|
106
|
+
this.name = 'MoveSourceNotRemovedError';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* On Windows fs.rename() often fails with EPERM when the directory is non-empty
|
|
111
|
+
* or another process has it open (IDE, watcher, antivirus), so EPERM/EXDEV fall
|
|
112
|
+
* back to copy-then-remove. Exported for migrate's two-phase promotion.
|
|
76
113
|
*/
|
|
77
114
|
export async function moveDirectory(src, dest) {
|
|
78
115
|
try {
|
|
@@ -81,11 +118,17 @@ export async function moveDirectory(src, dest) {
|
|
|
81
118
|
catch (err) {
|
|
82
119
|
const code = err?.code;
|
|
83
120
|
if (code === 'EPERM' || code === 'EXDEV') {
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// leaves those copied links dangling (notably on Windows).
|
|
121
|
+
// verbatimSymlinks: otherwise Node rewrites links to absolute paths under
|
|
122
|
+
// `src`, and removing `src` below leaves the copies dangling.
|
|
87
123
|
await fs.cp(src, dest, { recursive: true, verbatimSymlinks: true });
|
|
88
|
-
|
|
124
|
+
// The same open handle that made rename fail with EPERM can fail this
|
|
125
|
+
// removal partway, after some files are already gone.
|
|
126
|
+
try {
|
|
127
|
+
await fs.rm(src, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
catch (rmError) {
|
|
130
|
+
throw new MoveSourceNotRemovedError(src, dest, rmError);
|
|
131
|
+
}
|
|
89
132
|
}
|
|
90
133
|
else {
|
|
91
134
|
throw err;
|
|
@@ -93,16 +136,8 @@ export async function moveDirectory(src, dest) {
|
|
|
93
136
|
}
|
|
94
137
|
}
|
|
95
138
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* archived change path (project-root-relative), keeping the referenced filename
|
|
99
|
-
* (proposal.md or task.md). No-op if the line is absent or has no path.
|
|
100
|
-
*/
|
|
101
|
-
/**
|
|
102
|
-
* Appends a `## Related Decisions` section to the archived ticket linking the ADRs the
|
|
103
|
-
* change's metadata records (`decisions`), so the permanent archive record can
|
|
104
|
-
* be traced back to the decisions that drove it. Best-effort: unreadable
|
|
105
|
-
* metadata or no decisions → no section, never blocks the archive.
|
|
139
|
+
* Links the ADRs in the change's metadata from the archived ticket. Best-effort:
|
|
140
|
+
* unreadable metadata or no decisions → no section, never blocks the archive.
|
|
106
141
|
*/
|
|
107
142
|
async function appendDecisionLinks(ticketFile, archivedChangeDir, projectRoot) {
|
|
108
143
|
let decisions;
|
|
@@ -116,35 +151,34 @@ async function appendDecisionLinks(ticketFile, archivedChangeDir, projectRoot) {
|
|
|
116
151
|
return;
|
|
117
152
|
// Ticket lives in tospec/tickets/archive/, decisions in tospec/decisions/.
|
|
118
153
|
const lines = decisions.map((d) => `- [${d}](../../decisions/${d})`);
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// A read failure falls back to '\n' rather than returning: `appendFile` needs
|
|
124
|
-
// no read permission, so bailing out here would drop the section on a
|
|
125
|
-
// write-only ticket that the pre-CRLF code appended to successfully — trading
|
|
126
|
-
// a cosmetic line-ending mismatch for silent, permanent data loss.
|
|
154
|
+
// `appendFile` never reads the file, so a hardcoded '\n' left a CRLF ticket
|
|
155
|
+
// mixed. A read failure falls back to '\n' rather than returning: `appendFile`
|
|
156
|
+
// needs no read permission, and bailing would drop the section.
|
|
127
157
|
let existing = '';
|
|
128
158
|
try {
|
|
129
159
|
existing = await fs.readFile(ticketFile, 'utf-8');
|
|
130
160
|
}
|
|
131
|
-
catch {
|
|
132
|
-
// Fall through with the default.
|
|
133
|
-
}
|
|
161
|
+
catch { }
|
|
134
162
|
const eol = existing.includes('\r\n') ? '\r\n' : '\n';
|
|
135
163
|
const block = ['', '## Related Decisions', '', ...lines, ''].join(eol);
|
|
136
164
|
await fs.appendFile(ticketFile, block);
|
|
137
165
|
}
|
|
138
166
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
167
|
+
* Repoints an archived ticket's `ref:` frontmatter at the change's new path,
|
|
168
|
+
* keeping the referenced filename. No-op when the line is absent.
|
|
169
|
+
*
|
|
170
|
+
* Line endings survive untouched because the pattern excludes line terminators
|
|
171
|
+
* (`[^/\r\n]+`, no `s` flag), so `replace` only rewrites within a line. Do not
|
|
142
172
|
* "simplify" this by splitting and rejoining the file.
|
|
143
173
|
*/
|
|
144
174
|
async function retargetTicketRef(ticketFile, archiveName) {
|
|
145
|
-
const
|
|
175
|
+
const raw = await fs.readFile(ticketFile, 'utf-8');
|
|
176
|
+
// Compared against `raw`, not the stripped text, so a ticket whose only
|
|
177
|
+
// difference is a BOM still gets written back without one: this rewrite is
|
|
178
|
+
// where tospec re-emits the file, and what it emits is UTF-8 without a BOM.
|
|
179
|
+
const content = stripBom(raw);
|
|
146
180
|
const updated = content.replace(/^(ref:[ \t]*).*\/([^/\r\n]+)[ \t]*$/m, `$1tospec/changes/archive/${archiveName}/$2`);
|
|
147
|
-
if (updated !==
|
|
181
|
+
if (updated !== raw) {
|
|
148
182
|
await fs.writeFile(ticketFile, updated);
|
|
149
183
|
}
|
|
150
184
|
}
|
|
@@ -164,16 +198,20 @@ export async function moveFile(src, dest) {
|
|
|
164
198
|
}
|
|
165
199
|
}
|
|
166
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Returned by the locked merge when a human-mode failure path already printed
|
|
203
|
+
* and set the exit code. Distinct from `null` so the caller cannot forget to
|
|
204
|
+
* handle it.
|
|
205
|
+
*/
|
|
206
|
+
const writeTotalsSentinel = Symbol('archive-merge-aborted');
|
|
167
207
|
export class ArchiveCommand {
|
|
168
208
|
async execute(changeName, options = {}) {
|
|
169
209
|
const json = !!options.json;
|
|
170
|
-
// Same root resolution adapter as every other command (report 4.3).
|
|
171
210
|
const root = await resolveRootForCommand(options, {
|
|
172
211
|
json,
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
// diagnostic its sibling commands report for the same condition.
|
|
212
|
+
// An implicit root means no ancestor holds `tospec/`, so archiving can
|
|
213
|
+
// only fail — and with a vaguer message than the shared no_tospec_root
|
|
214
|
+
// diagnostic.
|
|
177
215
|
allowImplicitRoot: false,
|
|
178
216
|
failurePayload: { archive: null, root: null },
|
|
179
217
|
});
|
|
@@ -181,9 +219,8 @@ export class ArchiveCommand {
|
|
|
181
219
|
return;
|
|
182
220
|
}
|
|
183
221
|
if (json) {
|
|
184
|
-
// A gate overridden by --yes
|
|
185
|
-
//
|
|
186
|
-
// dropped, since prose on stdout would corrupt the JSON document.
|
|
222
|
+
// A gate overridden by --yes did not fail, so exit 0 stands and its
|
|
223
|
+
// warning rides on `status` — prose on stdout would corrupt the JSON.
|
|
187
224
|
const jsonWarnings = [];
|
|
188
225
|
try {
|
|
189
226
|
const result = await this.run(changeName, options, root, true, jsonWarnings);
|
|
@@ -193,10 +230,8 @@ export class ArchiveCommand {
|
|
|
193
230
|
emitSuccess({ archive: result, ...(jsonWarnings.length ? { status: jsonWarnings } : {}) }, toRootOutput(root));
|
|
194
231
|
}
|
|
195
232
|
catch (error) {
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// the reader needs to know. Dropping them here reproduced, on the
|
|
199
|
-
// failure path, the discard this collection exists to prevent.
|
|
233
|
+
// Gates overridden before the failure still happened, and a run that
|
|
234
|
+
// skipped validation *and* then failed is when the reader needs to know.
|
|
200
235
|
this.printJsonFailure(root, toArchiveDiagnostic(error), jsonWarnings);
|
|
201
236
|
}
|
|
202
237
|
return;
|
|
@@ -213,21 +248,15 @@ export class ArchiveCommand {
|
|
|
213
248
|
}, diagnostic);
|
|
214
249
|
}
|
|
215
250
|
/**
|
|
216
|
-
* One archive gate
|
|
217
|
-
* human mode prompts; `--yes` downgrades
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* The gates used to be written out longhand at each site and had to stay in
|
|
221
|
-
* step by hand for the "every blocked path throws in JSON mode" invariant.
|
|
251
|
+
* One archive gate, so the "every blocked path throws in JSON mode" invariant
|
|
252
|
+
* lives in one place. JSON mode throws; human mode prompts; `--yes` downgrades
|
|
253
|
+
* either to a warning. False means the user declined.
|
|
222
254
|
*/
|
|
223
255
|
async gate(opts) {
|
|
224
256
|
if (opts.yes) {
|
|
225
257
|
if (opts.json) {
|
|
226
|
-
// No `fix
|
|
227
|
-
//
|
|
228
|
-
// nonsense in the output of a run that already passed `--yes`, telling
|
|
229
|
-
// the reader to do the thing they just did. The archive has happened;
|
|
230
|
-
// there is nothing left to act on, so the message stands alone.
|
|
258
|
+
// No `fix`: every gate's fix ends in "or rerun with --yes", which tells
|
|
259
|
+
// a reader who already passed `--yes` to do what they just did.
|
|
231
260
|
opts.warnings.push({ severity: 'warning', code: opts.code, message: opts.message });
|
|
232
261
|
}
|
|
233
262
|
else {
|
|
@@ -247,29 +276,23 @@ export class ArchiveCommand {
|
|
|
247
276
|
return true;
|
|
248
277
|
}
|
|
249
278
|
/**
|
|
250
|
-
* Required artifacts the change never produced, in schema order.
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* about what "required" means. `skipped` covers the legitimate opt-out
|
|
254
|
-
* (`skip_specs: true`), and optional artifacts are the schema's own statement
|
|
255
|
-
* that their absence is fine — neither is reported here.
|
|
279
|
+
* Required artifacts the change never produced, in schema order. Reads the
|
|
280
|
+
* same graph `tospec status` does, so the two cannot disagree about what
|
|
281
|
+
* "required" means; `skipped` and `optional` are legitimate absences.
|
|
256
282
|
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* broke the load is already validation's to report.
|
|
283
|
+
* An unloadable graph returns nothing rather than throwing: this gate must not
|
|
284
|
+
* become a second way for archive to fail. Whatever broke the load is
|
|
285
|
+
* validation's to report.
|
|
261
286
|
*/
|
|
262
287
|
findMissingRequiredArtifacts(projectRoot, changeName, changeDir) {
|
|
263
288
|
try {
|
|
264
|
-
// Schema left undefined
|
|
265
|
-
//
|
|
289
|
+
// Schema left undefined: loadChangeContext reads it from the change's own
|
|
290
|
+
// metadata, as `status` does.
|
|
266
291
|
const status = formatChangeStatus(loadChangeContext(projectRoot, changeName, undefined, { changeDir }));
|
|
267
292
|
return status.artifacts
|
|
268
293
|
.filter((artifact) => !artifact.optional && artifact.status !== 'done' && artifact.status !== 'skipped')
|
|
269
|
-
// A stub's file is on disk
|
|
270
|
-
//
|
|
271
|
-
// was right there. The status is what distinguishes the two, and it is
|
|
272
|
-
// already in hand here.
|
|
294
|
+
// A stub's file is on disk holding the template, so reporting it like an
|
|
295
|
+
// absent one sends the reader looking for a file that is right there.
|
|
273
296
|
.map((artifact) => (artifact.status === 'stub' ? `${artifact.id} (stub)` : artifact.id));
|
|
274
297
|
}
|
|
275
298
|
catch {
|
|
@@ -277,54 +300,54 @@ export class ArchiveCommand {
|
|
|
277
300
|
}
|
|
278
301
|
}
|
|
279
302
|
/**
|
|
280
|
-
* Shared archive flow.
|
|
281
|
-
*
|
|
282
|
-
* prose reaches stdout and every blocked path throws.
|
|
303
|
+
* Shared archive flow. Human mode prompts and returns null on cancellation;
|
|
304
|
+
* JSON mode keeps stdout clean and throws from every blocked path.
|
|
283
305
|
*/
|
|
284
306
|
async run(changeName, options, root, json, jsonWarnings) {
|
|
285
307
|
const changesDir = root.changesDir;
|
|
286
308
|
const archiveDir = root.archiveDir;
|
|
287
309
|
const mainSpecsDir = root.specsDir;
|
|
288
|
-
// Check if changes directory exists
|
|
289
310
|
try {
|
|
290
311
|
await fs.access(changesDir);
|
|
291
312
|
}
|
|
292
|
-
catch {
|
|
313
|
+
catch (error) {
|
|
314
|
+
// Only an absent directory is "run init"; a directory that exists but
|
|
315
|
+
// cannot be entered (EACCES) is a different problem with a different fix.
|
|
316
|
+
if (!isMissingPathError(error))
|
|
317
|
+
throw error;
|
|
293
318
|
throw new ArchiveBlockedError('changes_dir_missing', 'No Tospec changes directory found.', "Run 'tospec init' first.");
|
|
294
319
|
}
|
|
295
|
-
// Captured before the interactive branch
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
// leave a reader to infer that from position.
|
|
299
|
-
// An empty string is not a name the user supplied, it is the absence of one:
|
|
300
|
-
// `!changeName` below sends it to the picker, so treating it as an argument
|
|
301
|
-
// would apply the kebab guard to whatever the picker returned — the one case
|
|
302
|
-
// this flag exists to exclude.
|
|
320
|
+
// Captured before the interactive branch overwrites `changeName`: the kebab
|
|
321
|
+
// guard below applies to the argument only, never to what the picker
|
|
322
|
+
// returned. An empty string is the absence of a name.
|
|
303
323
|
const nameFromArgument = typeof changeName === 'string' && changeName.length > 0;
|
|
304
|
-
// Get change name interactively if not provided
|
|
305
324
|
if (!changeName) {
|
|
306
325
|
if (json) {
|
|
307
326
|
throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required: archive --json is non-interactive.', 'tospec archive <change-name> --json');
|
|
308
327
|
}
|
|
328
|
+
// Without a terminal the picker cannot be answered: it would paint its
|
|
329
|
+
// menu into the pipe and then read EOF as "nothing chosen".
|
|
330
|
+
if (!process.stdin.isTTY) {
|
|
331
|
+
throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required when stdin is not a terminal.', 'tospec archive <change-name>');
|
|
332
|
+
}
|
|
309
333
|
const selectedChange = await this.selectChange(changesDir, root.path);
|
|
310
334
|
if (!selectedChange) {
|
|
335
|
+
// The same outcome as Ctrl-C on the confirmation prompt, so the same
|
|
336
|
+
// exit code: an abort the user chose is not a successful run.
|
|
311
337
|
console.error('No change selected. Aborting.');
|
|
338
|
+
process.exitCode = 130;
|
|
312
339
|
return null;
|
|
313
340
|
}
|
|
314
341
|
changeName = selectedChange;
|
|
315
342
|
}
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
// constraint in existing projects — rejecting one the CLI just offered would
|
|
319
|
-
// make an archivable change unarchivable.
|
|
343
|
+
// The picker lists real directories, some predating this constraint;
|
|
344
|
+
// rejecting one the CLI just offered would make it unarchivable.
|
|
320
345
|
if (nameFromArgument && !isKebabId(changeName)) {
|
|
321
346
|
throw new ArchiveBlockedError('archive_change_name_invalid', `Change name '${changeName}' is not a valid change id — it ${KEBAB_ID_DESCRIPTION}.`);
|
|
322
347
|
}
|
|
323
348
|
const changeDir = path.join(changesDir, changeName);
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
// branch was dead code, and the bare catch also rewrote genuine IO failures
|
|
327
|
-
// (EACCES, EIO) as "not found". Only a missing path may answer that.
|
|
349
|
+
// Only a missing path may answer "not found" — a bare catch here rewrote
|
|
350
|
+
// genuine IO failures (EACCES, EIO) as a nonexistent change.
|
|
328
351
|
const stat = await fs.stat(changeDir).catch((error) => {
|
|
329
352
|
if (isMissingPathError(error))
|
|
330
353
|
return null;
|
|
@@ -336,14 +359,10 @@ export class ArchiveCommand {
|
|
|
336
359
|
? `Change '${changeName}' not found. Available changes: ${available.join(', ')}`
|
|
337
360
|
: `Change '${changeName}' not found. No active changes exist in this root.`);
|
|
338
361
|
}
|
|
339
|
-
// Reserve the archive name before anything is written.
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// deltas, and REMOVED/RENAMED then took their "already synced" branch with
|
|
344
|
-
// counts that no longer described reality. The stamp is computed once here
|
|
345
|
-
// and reused for the move below, so the directory that was checked is the
|
|
346
|
-
// directory that gets created.
|
|
362
|
+
// Reserve the archive name before anything is written. Checking after the
|
|
363
|
+
// merge left specs applied with the change still in tospec/changes/, so a
|
|
364
|
+
// retry re-applied the same deltas. The stamp is computed once and reused
|
|
365
|
+
// below, so the checked directory is the one made.
|
|
347
366
|
const archiveName = `${formatArchiveStamp(new Date())}-${changeName}`;
|
|
348
367
|
const archivePath = path.join(archiveDir, archiveName);
|
|
349
368
|
const archiveTargetStat = await fs.stat(archivePath).catch((error) => {
|
|
@@ -354,33 +373,38 @@ export class ArchiveCommand {
|
|
|
354
373
|
if (archiveTargetStat) {
|
|
355
374
|
throw new ArchiveBlockedError('archive_target_exists', `Archive '${archiveName}' already exists.`);
|
|
356
375
|
}
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
// actually reflect reality — is the sync agent's job, not ours).
|
|
376
|
+
// The machine-checkable half of tospec-sync; whether the report's content
|
|
377
|
+
// reflects reality is the sync agent's job, not ours.
|
|
360
378
|
if (options.requireSync) {
|
|
361
379
|
const syncReportPath = path.join(changeDir, 'sync-report.md');
|
|
362
380
|
let syncReportContent;
|
|
363
381
|
try {
|
|
364
382
|
syncReportContent = await fs.readFile(syncReportPath, 'utf-8');
|
|
365
383
|
}
|
|
366
|
-
catch {
|
|
384
|
+
catch (error) {
|
|
385
|
+
// "Run tospec-sync" is the fix for an absent report only; an unreadable
|
|
386
|
+
// one (EACCES) would be re-written by sync and still not be readable.
|
|
387
|
+
if (!isMissingPathError(error))
|
|
388
|
+
throw error;
|
|
367
389
|
throw new ArchiveBlockedError('sync_report_missing', `sync-report.md not found for change '${changeName}'.`, 'Run tospec-sync first, or omit --require-sync.');
|
|
368
390
|
}
|
|
369
391
|
const conclusion = parseSyncReportConclusion(syncReportContent);
|
|
370
392
|
if (conclusion !== 'PASS') {
|
|
371
|
-
|
|
393
|
+
// Three states, three fixes: FAIL means requirements to resolve; no
|
|
394
|
+
// `Conclusion:` line means the sync report was never finished; a line
|
|
395
|
+
// that is there but unreadable (an unfilled `PASS | FAIL` template, or
|
|
396
|
+
// two verdicts that disagree) means the report needs editing, not
|
|
397
|
+
// another apply pass.
|
|
398
|
+
const found = conclusion ?? (hasConclusionLine(syncReportContent) ? 'unparseable Conclusion line' : 'no Conclusion line');
|
|
399
|
+
throw new ArchiveBlockedError('sync_report_failed', `sync-report.md for change '${changeName}' does not report a PASS conclusion (found: ${found}).`, conclusion === 'FAIL'
|
|
400
|
+
? 'Resolve the flagged Requirement(s) via tospec-apply, then re-run tospec-sync.'
|
|
401
|
+
: 'Finish the report with a single `Conclusion: PASS` or `Conclusion: FAIL` line outside any code fence, or re-run tospec-sync.');
|
|
372
402
|
}
|
|
373
403
|
}
|
|
374
404
|
const skipValidation = options.validate === false || options.noValidate === true;
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
// Minus the archive preflight, which is a dry run of the merge this function
|
|
379
|
-
// performs a few lines below. It rebuilds every updated spec to report at
|
|
380
|
-
// INFO what the merge would refuse; here that is the same work twice for a
|
|
381
|
-
// finding that cannot gate anything, while the real merge turns the same
|
|
382
|
-
// precondition into archive_spec_update_failed and exits non-zero. The
|
|
383
|
-
// equivalence above is about the verdict, and INFO never moved it.
|
|
405
|
+
// The exact pipeline `tospec validate` runs, minus the archive preflight —
|
|
406
|
+
// that is a dry run of the merge performed below, which raises
|
|
407
|
+
// archive_spec_update_failed itself.
|
|
384
408
|
if (!skipValidation) {
|
|
385
409
|
const report = await validateChangeArtifacts(root.path, changeDir, {
|
|
386
410
|
archivePreflight: false,
|
|
@@ -388,15 +412,14 @@ export class ArchiveCommand {
|
|
|
388
412
|
if (!json && report.issues.length > 0) {
|
|
389
413
|
console.error(`\nValidation issues for change '${changeName}':`);
|
|
390
414
|
for (const issue of report.issues) {
|
|
391
|
-
const symbol = issue.level
|
|
415
|
+
const symbol = issueSymbol(issue.level);
|
|
392
416
|
console.error(` ${symbol} ${issue.path}: ${issue.message}`);
|
|
393
417
|
}
|
|
394
418
|
}
|
|
395
419
|
if (!report.valid) {
|
|
396
|
-
// --skip-specs is honoured
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
// which only buries the question.
|
|
420
|
+
// --skip-specs is honoured long after this gate, so a change with no
|
|
421
|
+
// deltas fails here regardless. Point at the marker that resolves it,
|
|
422
|
+
// not at --no-validate.
|
|
400
423
|
const noDeltas = report.issues.some((i) => /at least one delta/i.test(i.message));
|
|
401
424
|
const deltaFix = noDeltas
|
|
402
425
|
? ` If this change genuinely has no behavioral effect, declare it once by setting \`skip_specs: true\` in the change's ${METADATA_FILENAME} — do not invent a requirement to satisfy validation.`
|
|
@@ -417,13 +440,9 @@ export class ArchiveCommand {
|
|
|
417
440
|
json,
|
|
418
441
|
yes: !!options.yes,
|
|
419
442
|
code: 'archive_confirmation_required',
|
|
420
|
-
// States the condition, not the remedy
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
// that removing `fix` from overridden gates was meant to end. The other
|
|
424
|
-
// two gates were already written this way ("N incomplete task(s) found"),
|
|
425
|
-
// which is why only this one carried it. Remedies belong in `fix`, which
|
|
426
|
-
// an overridden gate drops.
|
|
443
|
+
// States the condition, not the remedy: `message` is reused verbatim
|
|
444
|
+
// when `--yes` overrides the gate. Remedies belong in `fix`, which an
|
|
445
|
+
// overridden gate drops.
|
|
427
446
|
message: `Validation is disabled for change '${changeName}' (--no-validate).`,
|
|
428
447
|
fix: 'tospec archive <change-name> --json --no-validate --yes',
|
|
429
448
|
prompt: '⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)',
|
|
@@ -438,23 +457,16 @@ export class ArchiveCommand {
|
|
|
438
457
|
console.error(`Affected files: ${changeDir}`);
|
|
439
458
|
}
|
|
440
459
|
}
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
// of files that exist, and these do not exist.
|
|
446
|
-
//
|
|
447
|
-
// Archive is the last moment this is recoverable. Afterwards the change is
|
|
448
|
-
// in archive/ and its deltas are merged, so the why and the how the schema
|
|
449
|
-
// asked for are gone for good — and the person who had the answers has
|
|
450
|
-
// moved on. Overridable like every other gate, because a schema artifact is
|
|
451
|
-
// a planning obligation rather than a correctness one.
|
|
460
|
+
// Validation checks the content of files that exist, so it cannot catch a
|
|
461
|
+
// change archiving with no proposal and no design. Archive is the last
|
|
462
|
+
// recoverable moment. Overridable, because a schema artifact is a planning
|
|
463
|
+
// obligation rather than a correctness one.
|
|
452
464
|
const missingArtifacts = this.findMissingRequiredArtifacts(root.path, changeName, changeDir);
|
|
453
465
|
if (missingArtifacts.length > 0) {
|
|
454
466
|
const names = missingArtifacts.join(', ');
|
|
455
|
-
// "missing or incomplete"
|
|
456
|
-
//
|
|
457
|
-
//
|
|
467
|
+
// "missing or incomplete": the list holds both absent files and unfilled
|
|
468
|
+
// templates, and calling the second "missing" sends readers hunting for a
|
|
469
|
+
// file already on disk.
|
|
458
470
|
const proceed = await this.gate({
|
|
459
471
|
json,
|
|
460
472
|
yes: !!options.yes,
|
|
@@ -468,9 +480,8 @@ export class ArchiveCommand {
|
|
|
468
480
|
if (!proceed)
|
|
469
481
|
return null;
|
|
470
482
|
}
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
// the '..','..' hop silently encodes the tospec/changes layout.
|
|
483
|
+
// Pass the resolved root rather than re-deriving it from changesDir: the
|
|
484
|
+
// '..','..' hop silently encodes the tospec/changes layout.
|
|
474
485
|
const progress = await getTaskProgressForChange(changesDir, changeName, root.path);
|
|
475
486
|
if (!json) {
|
|
476
487
|
const status = formatTaskStatus(progress);
|
|
@@ -505,22 +516,15 @@ export class ArchiveCommand {
|
|
|
505
516
|
if (!proceed)
|
|
506
517
|
return null;
|
|
507
518
|
}
|
|
508
|
-
// Handle spec updates unless skipSpecs flag is set
|
|
509
519
|
let specsUpdated = false;
|
|
510
520
|
let totals = { added: 0, modified: 0, removed: 0, renamed: 0, retired: 0 };
|
|
511
521
|
if (options.skipSpecs) {
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
// the capability never reached the main specs, with nothing in the output
|
|
516
|
-
// saying so. The metadata spelling of the same intent (`skip_specs: true`)
|
|
517
|
-
// is an ERROR under exactly this shape; this at least names what was left
|
|
518
|
-
// behind. It stays a warning rather than a block because the flag is an
|
|
519
|
-
// explicit instruction and `--yes` already means "I confirm".
|
|
522
|
+
// `--skip-specs` is for changes with no deltas, so pointing it at one that
|
|
523
|
+
// has them is user error — and archiving in silence made it unrecoverable.
|
|
524
|
+
// A warning, not a block, because the flag is an explicit instruction.
|
|
520
525
|
const discarded = await findSpecUpdates(changeDir, mainSpecsDir);
|
|
521
526
|
if (discarded.length > 0) {
|
|
522
|
-
// source is <changeDir>/specs/<capability>/spec.md
|
|
523
|
-
// the directory that names it, the same level findSpecUpdates walks.
|
|
527
|
+
// source is <changeDir>/specs/<capability>/spec.md.
|
|
524
528
|
const listed = discarded
|
|
525
529
|
.map((update) => path.basename(path.dirname(update.source)))
|
|
526
530
|
.join(', ');
|
|
@@ -537,15 +541,12 @@ export class ArchiveCommand {
|
|
|
537
541
|
}
|
|
538
542
|
}
|
|
539
543
|
else {
|
|
540
|
-
// Find specs to update
|
|
541
544
|
const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);
|
|
542
545
|
if (specUpdates.length === 0) {
|
|
543
|
-
// findSpecUpdates only reads specs/<capability>/spec.md
|
|
544
|
-
// rejects anything else
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
// this guard exists to make impossible. Match on spec.md only: a stray
|
|
548
|
-
// NOTES.md under specs/ is not a delta and must not block an archive.
|
|
546
|
+
// findSpecUpdates only reads specs/<capability>/spec.md, and validation
|
|
547
|
+
// rejects anything else — so delta files here mean --no-validate, and
|
|
548
|
+
// reporting success would drop their requirements silently. Matches
|
|
549
|
+
// spec.md only: a stray NOTES.md is not a delta.
|
|
549
550
|
const orphans = await findSpecFiles(path.join(changeDir, 'specs'));
|
|
550
551
|
if (orphans.length > 0) {
|
|
551
552
|
const listed = orphans
|
|
@@ -564,10 +565,14 @@ export class ArchiveCommand {
|
|
|
564
565
|
}
|
|
565
566
|
}
|
|
566
567
|
let shouldUpdateSpecs = true;
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
568
|
+
// JSON mode merges without asking. The merge is what archive is for,
|
|
569
|
+
// and `--skip-specs` is the explicit way to decline it; the human
|
|
570
|
+
// prompt below defaults to yes for the same reason. Demanding `--yes`
|
|
571
|
+
// here made the flag mandatory on every sdd archive, and `--yes` is
|
|
572
|
+
// also the blanket override for the task, artifact and `--no-validate`
|
|
573
|
+
// gates, so a caller who only wanted the merge had to disarm every
|
|
574
|
+
// gate to get it.
|
|
575
|
+
if (!options.yes && !json) {
|
|
571
576
|
const { confirm } = await import('@inquirer/prompts');
|
|
572
577
|
shouldUpdateSpecs = await confirm({
|
|
573
578
|
message: 'Proceed with spec updates?',
|
|
@@ -578,104 +583,117 @@ export class ArchiveCommand {
|
|
|
578
583
|
}
|
|
579
584
|
}
|
|
580
585
|
if (shouldUpdateSpecs) {
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
586
|
+
// Everything to the last write is one critical section: the merge
|
|
587
|
+
// reads each main spec, builds the new text in memory and only then
|
|
588
|
+
// writes, so two concurrent archives both read the pre-merge text and
|
|
589
|
+
// the second erases the first one's requirement.
|
|
590
|
+
//
|
|
591
|
+
// The lock starts here, not at the top of `run`: a lock held across
|
|
592
|
+
// the picker or the confirmation prompt above would block every other
|
|
593
|
+
// process until someone returns to the terminal.
|
|
594
|
+
const locked = await withFileLock(path.join(root.path, 'tospec', '.archive.lock'), async () => {
|
|
595
|
+
// Prepare every update before writing any of them.
|
|
596
|
+
const prepared = [];
|
|
597
|
+
// Held back until every spec is written. A notice describes what the
|
|
598
|
+
// merge did ("wrote a TBD placeholder"), so surfacing it from a run
|
|
599
|
+
// that then aborted with "No files were changed" contradicts itself.
|
|
600
|
+
const mergeNotices = [];
|
|
601
|
+
try {
|
|
602
|
+
for (const update of specUpdates) {
|
|
603
|
+
const built = await buildUpdatedSpec(update, changeName, { silent: true });
|
|
604
|
+
mergeNotices.push(...built.notices);
|
|
605
|
+
prepared.push({ update, rebuilt: built.rebuilt, crlf: built.crlf, retired: built.retired, counts: built.counts });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
590
609
|
if (json) {
|
|
591
|
-
|
|
592
|
-
jsonWarnings.push({ severity: 'warning', code: n.code, message: n.message });
|
|
593
|
-
}
|
|
610
|
+
throw new ArchiveBlockedError('archive_spec_update_failed', String(err.message || err), 'Fix the change delta specs and rerun. No files were changed.');
|
|
594
611
|
}
|
|
595
|
-
|
|
612
|
+
console.error(String(err.message || err));
|
|
613
|
+
console.error('Aborted. No files were changed.');
|
|
614
|
+
process.exitCode = 1;
|
|
615
|
+
return writeTotalsSentinel;
|
|
596
616
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
if (
|
|
600
|
-
|
|
617
|
+
// Validate every rebuilt spec before writing any of them, so a
|
|
618
|
+
// late validation failure really does leave all targets unchanged.
|
|
619
|
+
if (!skipValidation) {
|
|
620
|
+
for (const p of prepared) {
|
|
621
|
+
// A retired capability has no requirements left, so validating it
|
|
622
|
+
// would always fail on "at least one requirement". It is deleted,
|
|
623
|
+
// not written.
|
|
624
|
+
if (p.retired)
|
|
625
|
+
continue;
|
|
626
|
+
const specName = path.basename(path.dirname(p.update.target));
|
|
627
|
+
const report = await new Validator().validateSpecContent(specName, p.rebuilt);
|
|
628
|
+
if (!report.valid) {
|
|
629
|
+
if (json) {
|
|
630
|
+
throw new ArchiveBlockedError('archive_spec_validation_failed', `Rebuilt spec for '${specName}' failed validation. No files were changed.`, `Run ${`tospec validate ${specName}`} after fixing the change deltas.`);
|
|
631
|
+
}
|
|
632
|
+
console.error(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`);
|
|
633
|
+
for (const issue of report.issues) {
|
|
634
|
+
if (issue.level === 'ERROR')
|
|
635
|
+
console.error(` ✗ ${issue.message}`);
|
|
636
|
+
else if (issue.level === 'WARNING')
|
|
637
|
+
console.error(` ⚠ ${issue.message}`);
|
|
638
|
+
}
|
|
639
|
+
console.error('Aborted. No files were changed.');
|
|
640
|
+
process.exitCode = 1;
|
|
641
|
+
return writeTotalsSentinel;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
601
644
|
}
|
|
602
|
-
|
|
603
|
-
console.error('Aborted. No files were changed.');
|
|
604
|
-
process.exitCode = 1;
|
|
605
|
-
return null;
|
|
606
|
-
}
|
|
607
|
-
// Validate every rebuilt spec before writing any of them, so a
|
|
608
|
-
// late validation failure really does leave all targets unchanged.
|
|
609
|
-
if (!skipValidation) {
|
|
645
|
+
const writeTotals = { added: 0, modified: 0, removed: 0, renamed: 0, retired: 0 };
|
|
610
646
|
for (const p of prepared) {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
// written, so there is no content to hold to that rule.
|
|
615
|
-
if (p.retired)
|
|
616
|
-
continue;
|
|
617
|
-
const specName = path.basename(path.dirname(p.update.target));
|
|
618
|
-
const report = await new Validator().validateSpecContent(specName, p.rebuilt);
|
|
619
|
-
if (!report.valid) {
|
|
620
|
-
if (json) {
|
|
621
|
-
throw new ArchiveBlockedError('archive_spec_validation_failed', `Rebuilt spec for '${specName}' failed validation. No files were changed.`, `Run ${`tospec validate ${specName}`} after fixing the change deltas.`);
|
|
622
|
-
}
|
|
623
|
-
console.error(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`);
|
|
624
|
-
for (const issue of report.issues) {
|
|
625
|
-
if (issue.level === 'ERROR')
|
|
626
|
-
console.error(` ✗ ${issue.message}`);
|
|
627
|
-
else if (issue.level === 'WARNING')
|
|
628
|
-
console.error(` ⚠ ${issue.message}`);
|
|
629
|
-
}
|
|
630
|
-
console.error('Aborted. No files were changed.');
|
|
631
|
-
process.exitCode = 1;
|
|
632
|
-
return null;
|
|
647
|
+
if (p.retired) {
|
|
648
|
+
await retireSpec(p.update, mainSpecsDir, { silent: json });
|
|
649
|
+
writeTotals.retired += 1;
|
|
633
650
|
}
|
|
651
|
+
else {
|
|
652
|
+
await writeUpdatedSpec(p.update, p.rebuilt, p.counts, {
|
|
653
|
+
silent: json,
|
|
654
|
+
crlf: p.crlf,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
writeTotals.added += p.counts.added;
|
|
658
|
+
writeTotals.modified += p.counts.modified;
|
|
659
|
+
writeTotals.removed += p.counts.removed;
|
|
660
|
+
writeTotals.renamed += p.counts.renamed;
|
|
634
661
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
662
|
+
// Now true: the files the notices talk about exist on disk. In JSON
|
|
663
|
+
// mode a dangling REMOVED used to be an invisible no-op, hence the
|
|
664
|
+
// envelope entry rather than silence.
|
|
665
|
+
for (const n of mergeNotices) {
|
|
666
|
+
if (json) {
|
|
667
|
+
jsonWarnings.push({ severity: 'warning', code: n.code, message: n.message });
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
console.log(`Warning: ${n.message}`);
|
|
671
|
+
}
|
|
642
672
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
});
|
|
673
|
+
if (!json) {
|
|
674
|
+
console.log(`Totals: + ${writeTotals.added}, ~ ${writeTotals.modified}, - ${writeTotals.removed}, → ${writeTotals.renamed}` +
|
|
675
|
+
(writeTotals.retired ? `, retired ${writeTotals.retired}` : ''));
|
|
676
|
+
console.log('Specs updated successfully.');
|
|
648
677
|
}
|
|
649
|
-
writeTotals
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
678
|
+
return { totals: writeTotals };
|
|
679
|
+
}, { operation: `archive ${changeName}` });
|
|
680
|
+
// The human-mode failure paths above already printed and set the exit code.
|
|
681
|
+
if (locked === writeTotalsSentinel)
|
|
682
|
+
return null;
|
|
654
683
|
specsUpdated = true;
|
|
655
|
-
totals =
|
|
656
|
-
if (!json) {
|
|
657
|
-
console.log(`Totals: + ${writeTotals.added}, ~ ${writeTotals.modified}, - ${writeTotals.removed}, → ${writeTotals.renamed}` +
|
|
658
|
-
(writeTotals.retired ? `, retired ${writeTotals.retired}` : ''));
|
|
659
|
-
console.log('Specs updated successfully.');
|
|
660
|
-
}
|
|
684
|
+
totals = locked.totals;
|
|
661
685
|
}
|
|
662
686
|
}
|
|
663
687
|
}
|
|
664
|
-
//
|
|
665
|
-
// before any spec was written; see the reservation above.
|
|
688
|
+
// Name and collision were settled at the reservation above.
|
|
666
689
|
await fs.mkdir(archiveDir, { recursive: true });
|
|
667
|
-
// Move change to archive (uses copy+remove on EPERM/EXDEV, e.g. Windows)
|
|
668
690
|
await moveDirectory(changeDir, archivePath);
|
|
669
|
-
// The
|
|
670
|
-
//
|
|
671
|
-
// index limited to in-flight changes.
|
|
691
|
+
// The ticket lives in the global tospec/tickets/ index, outside the change
|
|
692
|
+
// dir, so it moves alongside to keep the active index to in-flight changes.
|
|
672
693
|
const ticketPaths = await findTicketPaths(root.path, changeName);
|
|
673
|
-
// Newest wins
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
// attempt's ticket and stranded the real one (round 4 report 6). Reported
|
|
677
|
-
// rather than cleaned up: the leftovers are the user's to keep or delete,
|
|
678
|
-
// and archive must not silently remove a record.
|
|
694
|
+
// Newest wins: a second ticket means the change was recreated, so the later
|
|
695
|
+
// stamp describes the change being archived. The rest are reported, not
|
|
696
|
+
// cleaned up — archive must not silently remove a record.
|
|
679
697
|
const ticketPath = ticketPaths[ticketPaths.length - 1];
|
|
680
698
|
if (ticketPaths.length > 1) {
|
|
681
699
|
const stranded = ticketPaths
|
|
@@ -694,9 +712,33 @@ export class ArchiveCommand {
|
|
|
694
712
|
const ticketArchiveDir = path.join(root.path, 'tospec', 'tickets', 'archive');
|
|
695
713
|
await fs.mkdir(ticketArchiveDir, { recursive: true });
|
|
696
714
|
const archivedTicketPath = path.join(ticketArchiveDir, path.basename(ticketPath));
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
715
|
+
// `moveFile` overwrites, and two tickets share a filename when the same
|
|
716
|
+
// change name is created twice in one second with an archive between.
|
|
717
|
+
// Left in place and reported rather than renamed around: inventing a
|
|
718
|
+
// second filename would make the index disagree with its frontmatter.
|
|
719
|
+
const archivedTicketExists = await fs
|
|
720
|
+
.stat(archivedTicketPath)
|
|
721
|
+
.then(() => true)
|
|
722
|
+
.catch((error) => {
|
|
723
|
+
if (isMissingPathError(error))
|
|
724
|
+
return false;
|
|
725
|
+
throw error;
|
|
726
|
+
});
|
|
727
|
+
if (archivedTicketExists) {
|
|
728
|
+
const message = `tospec/tickets/archive/${path.basename(archivedTicketPath)} already exists, so this change's ticket ` +
|
|
729
|
+
`was left in tospec/tickets/ rather than overwriting it. Rename one of the two — they describe different changes.`;
|
|
730
|
+
if (json) {
|
|
731
|
+
jsonWarnings.push({ severity: 'warning', code: 'ticket_archive_collision', message });
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
console.log(`Warning: ${message}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
await moveFile(ticketPath, archivedTicketPath);
|
|
739
|
+
await retargetTicketRef(archivedTicketPath, archiveName);
|
|
740
|
+
await appendDecisionLinks(archivedTicketPath, archivePath, root.path);
|
|
741
|
+
}
|
|
700
742
|
}
|
|
701
743
|
if (!json) {
|
|
702
744
|
console.log(`Change '${changeName}' archived as '${archiveName}'.`);
|
|
@@ -716,7 +758,7 @@ export class ArchiveCommand {
|
|
|
716
758
|
console.log('No active changes found.');
|
|
717
759
|
return null;
|
|
718
760
|
}
|
|
719
|
-
//
|
|
761
|
+
// Task progress is decoration; on any failure the plain names still work.
|
|
720
762
|
let choices = changeDirs.map(name => ({ name, value: name }));
|
|
721
763
|
try {
|
|
722
764
|
const progressList = [];
|
|
@@ -731,19 +773,15 @@ export class ArchiveCommand {
|
|
|
731
773
|
value: p.id
|
|
732
774
|
}));
|
|
733
775
|
}
|
|
734
|
-
catch {
|
|
735
|
-
// If anything fails, fall back to simple names
|
|
736
|
-
choices = changeDirs.map(name => ({ name, value: name }));
|
|
737
|
-
}
|
|
776
|
+
catch { }
|
|
738
777
|
try {
|
|
739
|
-
|
|
778
|
+
return await select({
|
|
740
779
|
message: 'Select a change to archive',
|
|
741
780
|
choices
|
|
742
781
|
});
|
|
743
|
-
return answer;
|
|
744
782
|
}
|
|
745
|
-
catch
|
|
746
|
-
//
|
|
783
|
+
catch {
|
|
784
|
+
// Ctrl+C.
|
|
747
785
|
return null;
|
|
748
786
|
}
|
|
749
787
|
}
|