@seanmars/tospec 0.19.0-beta.8 → 0.20.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 +60 -310
- package/README.md +69 -82
- package/assets/dashboard/app.js +11 -0
- package/assets/dashboard/style.css +7 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +89 -106
- 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 +293 -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 +190 -313
- 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 +296 -128
- 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 +225 -141
- 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 +219 -128
- 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 +69 -32
- 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 +22 -31
- package/dist/core/archive.d.ts.map +1 -1
- package/dist/core/archive.js +296 -283
- 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 +30 -10
- 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 +26 -24
- 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 +90 -168
- package/dist/core/init.js.map +1 -1
- package/dist/core/list.d.ts.map +1 -1
- package/dist/core/list.js +80 -38
- 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 +151 -135
- 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 +16 -20
- 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 +468 -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 +16 -40
- 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
|
@@ -1,16 +1,68 @@
|
|
|
1
1
|
import { readFileSync, promises as fs } from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { SpecSchema
|
|
3
|
+
import { SpecSchema } from '../schemas/index.js';
|
|
4
4
|
import { MarkdownParser } from '../parsers/markdown-parser.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, MAX_DELTAS_PER_CHANGE, VALIDATION_MESSAGES } from './constants.js';
|
|
6
|
+
import { proseLength } from './prose-length.js';
|
|
7
7
|
import { parseDeltaSpec, normalizeRequirementName, extractRequirementsSection } from '../parsers/requirement-blocks.js';
|
|
8
|
-
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust
|
|
8
|
+
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust, analyzeScenarios, scanCodeFences, findMissingScenarios, findDroppedBullets, withoutScenarios, normalizeDocument, } from '../parsers/requirement-text.js';
|
|
9
9
|
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
|
10
10
|
import { findPurposePlaceholderIssue } from './purpose-placeholder.js';
|
|
11
11
|
import { FileSystemUtils, extractNameFromPath, isMissingPathError } from '../../utils/file-system.js';
|
|
12
12
|
import { findAllMarkdownFiles } from '../../utils/spec-files.js';
|
|
13
|
-
import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
|
|
13
|
+
import { findSpecUpdates, buildUpdatedSpec, extractDeltaPurpose } from '../specs-apply.js';
|
|
14
|
+
/**
|
|
15
|
+
* The one place the `## REMOVED Scenarios` grammar is spelled out in a
|
|
16
|
+
* message. Both the "omits scenario(s)" error and the empty-section error
|
|
17
|
+
* point here, so an author who reached either sees the exact lines to write
|
|
18
|
+
* instead of being sent to the template.
|
|
19
|
+
*/
|
|
20
|
+
const REMOVED_SCENARIOS_GRAMMAR = 'Each entry is three bullets: "- Requirement: `<name>`" / "- Scenario: `<name>`" / "- Reason: <why>".';
|
|
21
|
+
/**
|
|
22
|
+
* Merge notices the archive dry run forwards as validation warnings: a REMOVED
|
|
23
|
+
* block naming something the target spec does not have, so archiving deletes
|
|
24
|
+
* nothing. Matched by code, not message text, so rewording a notice cannot
|
|
25
|
+
* switch the check off.
|
|
26
|
+
*/
|
|
27
|
+
const REMOVED_NOOP_NOTICE_CODES = new Set([
|
|
28
|
+
'archive_removed_requirement_absent',
|
|
29
|
+
'archive_removed_ignored_new_spec',
|
|
30
|
+
]);
|
|
31
|
+
/**
|
|
32
|
+
* Merge failures the structural pass already reports under its own wording:
|
|
33
|
+
* duplicate or cross-section names (`validation failed - ...`), a section that
|
|
34
|
+
* parsed to nothing, and a MODIFIED that drops a scenario. Matched by the
|
|
35
|
+
* fixed prefix each message opens with; the rest of a message carries names
|
|
36
|
+
* that vary per delta.
|
|
37
|
+
*/
|
|
38
|
+
const STRUCTURAL_MERGE_FAILURE = /( validation failed - |^Delta parsing found no operations|current spec contains scenario\(s\) not present)/;
|
|
39
|
+
/**
|
|
40
|
+
* A name the template shipped and nobody replaced: `[name]`, `[old name]`,
|
|
41
|
+
* `[scenario name]`. Bracketed end to end, which is how every placeholder in
|
|
42
|
+
* the shipped templates is spelled and how no real name is.
|
|
43
|
+
*
|
|
44
|
+
* ERROR, unlike the whole-artifact stub check, because a placeholder that
|
|
45
|
+
* reaches archive is merged into the main spec as a requirement literally
|
|
46
|
+
* named "[name]" — and the stub check cannot catch it once any other line of
|
|
47
|
+
* the file has been edited. This is what makes the template's promise that a
|
|
48
|
+
* placeholder section "left as-is does not validate" true.
|
|
49
|
+
*/
|
|
50
|
+
function isTemplatePlaceholder(name) {
|
|
51
|
+
return /^\[.*\]$/.test(name.trim());
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* One `path` notation across the report. Zod hands back a segment array that
|
|
55
|
+
* `join('.')` renders as `requirements.0.scenarios`, while every hand-written
|
|
56
|
+
* rule here writes `requirements[0]` — so the same report used two spellings
|
|
57
|
+
* for the same address and neither was safely parseable.
|
|
58
|
+
*/
|
|
59
|
+
function formatIssuePath(segments) {
|
|
60
|
+
return segments.reduce((acc, segment) => {
|
|
61
|
+
if (typeof segment === 'number')
|
|
62
|
+
return `${acc}[${segment}]`;
|
|
63
|
+
return acc ? `${acc}.${String(segment)}` : String(segment);
|
|
64
|
+
}, '');
|
|
65
|
+
}
|
|
14
66
|
export class Validator {
|
|
15
67
|
strictMode;
|
|
16
68
|
constructor(strictMode = false) {
|
|
@@ -28,14 +80,10 @@ export class Validator {
|
|
|
28
80
|
{ level: 'ERROR', path: 'file', message: this.enrichTopLevelError(specName, baseMessage) },
|
|
29
81
|
]);
|
|
30
82
|
}
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// differently.
|
|
83
|
+
// One body past the read, so `validate` and `archive` cannot grade the same
|
|
84
|
+
// spec differently.
|
|
34
85
|
return this.validateSpecContent(specName, content);
|
|
35
86
|
}
|
|
36
|
-
/**
|
|
37
|
-
* Validate spec content from a string (used for pre-write validation of rebuilt specs)
|
|
38
|
-
*/
|
|
39
87
|
async validateSpecContent(specName, content) {
|
|
40
88
|
const issues = [];
|
|
41
89
|
try {
|
|
@@ -54,56 +102,19 @@ export class Validator {
|
|
|
54
102
|
}
|
|
55
103
|
return this.createReport(issues);
|
|
56
104
|
}
|
|
57
|
-
async validateChange(filePath) {
|
|
58
|
-
const issues = [];
|
|
59
|
-
const changeName = extractNameFromPath(filePath);
|
|
60
|
-
try {
|
|
61
|
-
const content = readFileSync(filePath, 'utf-8');
|
|
62
|
-
const changeDir = path.dirname(filePath);
|
|
63
|
-
const parser = new ChangeParser(content, changeDir);
|
|
64
|
-
const change = await parser.parseChangeWithDeltas(changeName);
|
|
65
|
-
const result = ChangeSchema.safeParse(change);
|
|
66
|
-
if (!result.success) {
|
|
67
|
-
issues.push(...this.convertZodErrors(result.error));
|
|
68
|
-
}
|
|
69
|
-
issues.push(...this.applyChangeRules(change, content));
|
|
70
|
-
}
|
|
71
|
-
catch (error) {
|
|
72
|
-
const baseMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
73
|
-
const enriched = this.enrichTopLevelError(changeName, baseMessage);
|
|
74
|
-
issues.push({
|
|
75
|
-
level: 'ERROR',
|
|
76
|
-
path: 'file',
|
|
77
|
-
message: enriched,
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
return this.createReport(issues);
|
|
81
|
-
}
|
|
82
105
|
/**
|
|
83
|
-
* Validate delta-formatted spec files under a change directory
|
|
84
|
-
*
|
|
85
|
-
* -
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* - RENAMED: pairs well-formed
|
|
89
|
-
* - No duplicates within sections; no cross-section conflicts per spec
|
|
90
|
-
* - MODIFIED: no scenario the current main spec still has is dropped
|
|
106
|
+
* Validate delta-formatted spec files under a change directory: at least one
|
|
107
|
+
* delta overall, ADDED/MODIFIED requirements carrying SHALL/MUST and a
|
|
108
|
+
* scenario, well-formed REMOVED (names only) and RENAMED (FROM:/TO: pairs)
|
|
109
|
+
* entries, no duplicate or cross-section names, and no scenario the current
|
|
110
|
+
* main spec still has dropped by a MODIFIED.
|
|
91
111
|
*
|
|
92
|
-
* `mainSpecsDir` enables that last rule; without it
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* `archivePreflight` is opt-out because the dry run below is worth its cost to
|
|
100
|
-
* exactly one caller. It rebuilds every updated spec — re-reading and
|
|
101
|
-
* re-parsing the delta and the whole main spec per capability — to report,
|
|
102
|
-
* at INFO, what the merge would refuse. For `tospec validate` that is the
|
|
103
|
-
* point: the author learns before they try. For `archive` it is pure
|
|
104
|
-
* duplication, because archive runs the real merge moments later and already
|
|
105
|
-
* turns the same precondition into `archive_spec_update_failed` with a
|
|
106
|
-
* non-zero exit, which the INFO never had the level to do.
|
|
112
|
+
* `mainSpecsDir` enables that last rule; without it there is nothing to
|
|
113
|
+
* compare against, so it is skipped. Archive refuses such deltas either way.
|
|
114
|
+
*
|
|
115
|
+
* `archivePreflight` is opt-out because the dry run rebuilds every updated
|
|
116
|
+
* spec. For `tospec validate` that is the point; for `archive` it is
|
|
117
|
+
* duplication, since the real merge follows moments later.
|
|
107
118
|
*/
|
|
108
119
|
async validateChangeDeltaSpecs(changeDir, mainSpecsDir, options = {}) {
|
|
109
120
|
const archivePreflight = options.archivePreflight ?? true;
|
|
@@ -114,20 +125,14 @@ export class Validator {
|
|
|
114
125
|
const missingHeaderSpecs = [];
|
|
115
126
|
const emptySections = [];
|
|
116
127
|
try {
|
|
117
|
-
// The walk below is recursive
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// ever went recursive — the merge path (findSpecUpdates) never did — so a
|
|
123
|
-
// misplaced file that validates clean is a file that archives into
|
|
124
|
-
// nothing. Validation is the only place that can catch it.
|
|
128
|
+
// The walk below is recursive in order to *reject* anything outside
|
|
129
|
+
// specs/<capability>/spec.md: the layout is fixed at exactly one directory
|
|
130
|
+
// level (tospec/decisions/20260730_014309-delta-spec-layout-one-level.md).
|
|
131
|
+
// findSpecUpdates never went recursive, so a misplaced file that validates
|
|
132
|
+
// clean is a file that archives into nothing.
|
|
125
133
|
const rootSpecPath = path.join(specsDir, 'spec.md');
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// the change validates clean and archives while its requirements never
|
|
129
|
-
// reach the main specs. Only a regular file counts — a *directory* named
|
|
130
|
-
// spec.md is a capability folder like any other.
|
|
134
|
+
// Only a regular file counts — a *directory* named spec.md is a capability
|
|
135
|
+
// folder like any other.
|
|
131
136
|
const rootSpecStat = await fs.stat(rootSpecPath).catch(() => null);
|
|
132
137
|
if (rootSpecStat?.isFile() === true) {
|
|
133
138
|
hasMisplacedSpec = true;
|
|
@@ -137,19 +142,17 @@ export class Validator {
|
|
|
137
142
|
message: 'Delta spec found at specs/spec.md. Delta specs must live in a capability folder (e.g. specs/<capability>/spec.md) — a file at the specs/ root is ignored when the change is applied or archived.',
|
|
138
143
|
});
|
|
139
144
|
}
|
|
140
|
-
// Report each misplaced file once
|
|
141
|
-
//
|
|
142
|
-
//
|
|
145
|
+
// Report each misplaced file once and drop it from the delta pass below —
|
|
146
|
+
// validating it as a real delta would contradict the error that just said
|
|
147
|
+
// it will never be applied.
|
|
143
148
|
const specFiles = [];
|
|
144
149
|
for (const specFile of await findAllMarkdownFiles(specsDir)) {
|
|
145
150
|
if (specFile === rootSpecPath)
|
|
146
151
|
continue; // already reported above
|
|
147
152
|
const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile));
|
|
148
153
|
const segments = entryPath.split('/');
|
|
149
|
-
// A stray .md directly at specs/
|
|
150
|
-
//
|
|
151
|
-
// (src/core/archive.ts, "orphans") already treats a non-spec.md file
|
|
152
|
-
// at this depth as harmless clutter rather than a lost delta, and the
|
|
154
|
+
// A stray .md directly at specs/ is not this guard's concern: archive's
|
|
155
|
+
// orphan backstop treats it as clutter rather than a lost delta, and the
|
|
153
156
|
// two must keep agreeing.
|
|
154
157
|
if (segments.length === 1)
|
|
155
158
|
continue;
|
|
@@ -165,10 +168,9 @@ export class Validator {
|
|
|
165
168
|
});
|
|
166
169
|
continue;
|
|
167
170
|
}
|
|
168
|
-
// fast-glob (which computes artifact status
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
// showing as done — the same class of silent divergence.
|
|
171
|
+
// fast-glob (which computes artifact status) skips dot-directories while
|
|
172
|
+
// the merge path's readdir does not, so a dot capability would archive
|
|
173
|
+
// without ever showing done.
|
|
172
174
|
if (segments[0].startsWith('.')) {
|
|
173
175
|
hasMisplacedSpec = true;
|
|
174
176
|
issues.push({
|
|
@@ -178,12 +180,9 @@ export class Validator {
|
|
|
178
180
|
});
|
|
179
181
|
continue;
|
|
180
182
|
}
|
|
181
|
-
// Right folder, wrong basename
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// specs/<capability>/spec.md) silently drops the file while validation
|
|
185
|
-
// reported clean. Any *.md here is either a delta that will be lost or
|
|
186
|
-
// clutter that belongs elsewhere, so both are flagged the same way.
|
|
183
|
+
// Right folder, wrong basename. findSpecFiles filters on
|
|
184
|
+
// basename === 'spec.md', so the merge path silently drops the file;
|
|
185
|
+
// findAllMarkdownFiles is what makes it visible here at all.
|
|
187
186
|
if (segments[1] !== 'spec.md') {
|
|
188
187
|
hasMisplacedSpec = true;
|
|
189
188
|
issues.push({
|
|
@@ -196,18 +195,29 @@ export class Validator {
|
|
|
196
195
|
specFiles.push(specFile);
|
|
197
196
|
}
|
|
198
197
|
for (const specFile of specFiles) {
|
|
198
|
+
const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile));
|
|
199
199
|
let content;
|
|
200
200
|
try {
|
|
201
201
|
content = await fs.readFile(specFile, 'utf-8');
|
|
202
202
|
}
|
|
203
|
-
catch {
|
|
203
|
+
catch (error) {
|
|
204
|
+
// A file the walk just listed but cannot be read (EACCES, EIO) is a
|
|
205
|
+
// delta, not an absence: skipping it let a change whose only delta
|
|
206
|
+
// was unreadable validate clean and fail inside archive instead. Only
|
|
207
|
+
// a path that vanished between the walk and the read may be skipped.
|
|
208
|
+
if (isMissingPathError(error))
|
|
209
|
+
continue;
|
|
210
|
+
issues.push({
|
|
211
|
+
level: 'ERROR',
|
|
212
|
+
path: entryPath,
|
|
213
|
+
message: `Delta spec specs/${entryPath} exists but could not be read, so it was not validated: ${error instanceof Error ? error.message : String(error)}`,
|
|
214
|
+
});
|
|
204
215
|
continue;
|
|
205
216
|
}
|
|
206
217
|
const plan = parseDeltaSpec(content);
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
const fenceScan = scanCodeFences(content.split(/\r?\n/));
|
|
218
|
+
// An unclosed fence masks every following line — surface it instead of
|
|
219
|
+
// silently hiding the rest of the document.
|
|
220
|
+
const fenceScan = scanCodeFences(normalizeDocument(content).split('\n'));
|
|
211
221
|
if (fenceScan.unclosedFenceLine !== null) {
|
|
212
222
|
issues.push({
|
|
213
223
|
level: 'ERROR',
|
|
@@ -216,12 +226,14 @@ export class Validator {
|
|
|
216
226
|
message: `Code fence opened at line ${fenceScan.unclosedFenceLine} is never closed — all content after it is invisible to the parser. Close the fence.`,
|
|
217
227
|
});
|
|
218
228
|
}
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
229
|
+
// A new capability's `## Purpose` is carried verbatim into the main spec
|
|
230
|
+
// archive creates, so the main-spec Purpose rules must run before the
|
|
231
|
+
// copy — otherwise the change archives and only then fails
|
|
232
|
+
// `validate --all --strict`, from a source now under changes/archive/.
|
|
233
|
+
// New capabilities only: an existing one owns its Purpose.
|
|
234
|
+
issues.push(...(await this.collectDeltaPurposeIssues(content, entryPath, mainSpecsDir)));
|
|
235
|
+
// Two identical delta headers are an editing accident, and combining
|
|
236
|
+
// them would guess which one the author meant.
|
|
225
237
|
for (const duplicate of plan.duplicateSections) {
|
|
226
238
|
issues.push({
|
|
227
239
|
level: 'ERROR',
|
|
@@ -230,14 +242,24 @@ export class Validator {
|
|
|
230
242
|
message: `Delta section "${duplicate.title}" appears more than once (lines ${duplicate.lines.join(', ')}). Merge them into a single section — two headers with the same name are ambiguous about which requirements belong to which.`,
|
|
231
243
|
});
|
|
232
244
|
}
|
|
245
|
+
// A present section that yields no entry is a grammar miss, not an
|
|
246
|
+
// empty section: the author wrote something there. Left unreported, the
|
|
247
|
+
// MODIFIED block keeps failing the scenario-loss check with no sign that
|
|
248
|
+
// its declaration was skipped.
|
|
249
|
+
if (plan.sectionPresence.removedScenarios && plan.removedScenarios.length === 0) {
|
|
250
|
+
issues.push({
|
|
251
|
+
level: 'ERROR',
|
|
252
|
+
path: entryPath,
|
|
253
|
+
message: `"## REMOVED Scenarios" declares nothing the parser recognises. ${REMOVED_SCENARIOS_GRAMMAR}`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
233
256
|
const hasSections = plan.sectionPresence.added ||
|
|
234
257
|
plan.sectionPresence.modified ||
|
|
235
258
|
plan.sectionPresence.removed ||
|
|
236
259
|
plan.sectionPresence.renamed;
|
|
237
260
|
const hasEntries = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
|
|
238
|
-
// hasEntries sums all four categories, so once it is false every
|
|
239
|
-
//
|
|
240
|
-
// extra per-section length check is needed to know that.
|
|
261
|
+
// hasEntries sums all four categories, so once it is false every section
|
|
262
|
+
// present in sectionPresence is individually empty too.
|
|
241
263
|
if (!hasEntries) {
|
|
242
264
|
if (hasSections) {
|
|
243
265
|
if (plan.sectionPresence.added)
|
|
@@ -258,9 +280,8 @@ export class Validator {
|
|
|
258
280
|
const removedNames = new Set();
|
|
259
281
|
const renamedFrom = new Set();
|
|
260
282
|
const renamedTo = new Set();
|
|
261
|
-
// ADDED and MODIFIED carry identical per-requirement rules
|
|
262
|
-
//
|
|
263
|
-
// outer loop preserves the all-ADDED-then-all-MODIFIED issue order.
|
|
283
|
+
// ADDED and MODIFIED carry identical per-requirement rules, so one body
|
|
284
|
+
// stops a new rule landing on only one of them.
|
|
264
285
|
for (const [section, blocks, seen] of [
|
|
265
286
|
['ADDED', plan.added, addedNames],
|
|
266
287
|
['MODIFIED', plan.modified, modifiedNames],
|
|
@@ -268,6 +289,17 @@ export class Validator {
|
|
|
268
289
|
for (const block of blocks) {
|
|
269
290
|
const key = normalizeRequirementName(block.name);
|
|
270
291
|
totalDeltas++;
|
|
292
|
+
if (isTemplatePlaceholder(block.name)) {
|
|
293
|
+
// One finding per block: its body is the template's too, and the
|
|
294
|
+
// keyword and scenario checks would only restate that.
|
|
295
|
+
issues.push({
|
|
296
|
+
level: 'ERROR',
|
|
297
|
+
path: entryPath,
|
|
298
|
+
line: block.startLine,
|
|
299
|
+
message: `${section} "${block.name}" (line ${block.startLine}) still carries the template's placeholder name. Replace it with the requirement's real name and fill in the block, or delete the block if this change does not need it.`,
|
|
300
|
+
});
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
271
303
|
if (seen.has(key)) {
|
|
272
304
|
issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in ${section}: "${block.name}"` });
|
|
273
305
|
}
|
|
@@ -278,32 +310,66 @@ export class Validator {
|
|
|
278
310
|
if (!requirementText) {
|
|
279
311
|
issues.push({ level: 'ERROR', path: entryPath, message: `${section} "${block.name}" is missing requirement text` });
|
|
280
312
|
}
|
|
281
|
-
else if (!
|
|
313
|
+
else if (!containsShallOrMust(requirementText)) {
|
|
282
314
|
// WARNING, not ERROR: the keyword check is English-only, so at
|
|
283
|
-
// ERROR it blocked
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
315
|
+
// ERROR it blocked requirements written in another language.
|
|
316
|
+
// `--strict` still refuses it.
|
|
317
|
+
// tospec/decisions/20260814_134116-shall-must-missing-is-a-warning.md
|
|
318
|
+
issues.push({
|
|
319
|
+
level: 'WARNING',
|
|
320
|
+
path: entryPath,
|
|
321
|
+
...this.buildMissingShallOrMustMessage(`${section} "${block.name}"`, block.name),
|
|
322
|
+
});
|
|
289
323
|
}
|
|
290
324
|
issues.push(...this.collectScenarioIssues(section, block, entryPath));
|
|
291
325
|
}
|
|
292
326
|
}
|
|
293
|
-
|
|
327
|
+
// Also entered on declarations alone: a `## REMOVED Scenarios` with no
|
|
328
|
+
// MODIFIED block matches nothing by construction, and gating on
|
|
329
|
+
// MODIFIED meant the "declaration matched nothing" error below could
|
|
330
|
+
// never fire for the one delta shape where it is always true. The
|
|
331
|
+
// declaration removes nothing by itself (the MODIFIED omission is the
|
|
332
|
+
// mechanism), so such a change archived with the scenario intact.
|
|
333
|
+
// tospec/decisions/20260918_230803-removed-scenarios-is-the-declared-path.md
|
|
334
|
+
// Dropped from the plan once reported: a placeholder entry would
|
|
335
|
+
// otherwise also fail the "matched nothing" check, naming one mistake
|
|
336
|
+
// twice.
|
|
337
|
+
const removedScenarios = plan.removedScenarios.filter((entry) => {
|
|
338
|
+
const placeholders = [entry.requirement, entry.scenario].filter(isTemplatePlaceholder);
|
|
339
|
+
if (placeholders.length === 0)
|
|
340
|
+
return true;
|
|
341
|
+
issues.push({
|
|
342
|
+
level: 'ERROR',
|
|
343
|
+
path: entryPath,
|
|
344
|
+
line: entry.startLine,
|
|
345
|
+
message: `REMOVED Scenarios entry (line ${entry.startLine}) still carries the template's placeholder ${placeholders
|
|
346
|
+
.map((name) => `"${name}"`)
|
|
347
|
+
.join(' and ')}. Name the requirement and the scenario being dropped, or delete the section if this change drops none.`,
|
|
348
|
+
});
|
|
349
|
+
return false;
|
|
350
|
+
});
|
|
351
|
+
if (mainSpecsDir !== undefined && (plan.modified.length > 0 || removedScenarios.length > 0)) {
|
|
294
352
|
const renamedToFrom = new Map();
|
|
295
353
|
for (const { from, to } of plan.renamed) {
|
|
296
354
|
renamedToFrom.set(normalizeRequirementName(to), from);
|
|
297
355
|
}
|
|
298
356
|
// entryPath is always "<capability>/spec.md" here — misplaced and
|
|
299
357
|
// dot-prefixed files were reported and dropped above.
|
|
300
|
-
issues.push(...(await this.collectDroppedScenarioIssues(mainSpecsDir, entryPath.split('/')[0], plan.modified, entryPath, renamedToFrom)));
|
|
358
|
+
issues.push(...(await this.collectDroppedScenarioIssues(mainSpecsDir, entryPath.split('/')[0], plan.modified, entryPath, renamedToFrom, removedScenarios)));
|
|
301
359
|
}
|
|
302
|
-
//
|
|
303
|
-
// fields on every entry (report 2.4).
|
|
360
|
+
// The template requires Reason and Migration on every REMOVED entry.
|
|
304
361
|
for (const entry of plan.removedEntries) {
|
|
305
362
|
const key = normalizeRequirementName(entry.name);
|
|
306
363
|
totalDeltas++;
|
|
364
|
+
if (isTemplatePlaceholder(entry.name)) {
|
|
365
|
+
issues.push({
|
|
366
|
+
level: 'ERROR',
|
|
367
|
+
path: entryPath,
|
|
368
|
+
line: entry.startLine,
|
|
369
|
+
message: `REMOVED "${entry.name}" (line ${entry.startLine}) still carries the template's placeholder name. Name the requirement being removed, or delete the section if this change removes none.`,
|
|
370
|
+
});
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
307
373
|
if (removedNames.has(key)) {
|
|
308
374
|
issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate requirement in REMOVED: "${entry.name}"` });
|
|
309
375
|
}
|
|
@@ -327,7 +393,7 @@ export class Validator {
|
|
|
327
393
|
});
|
|
328
394
|
}
|
|
329
395
|
}
|
|
330
|
-
// Orphaned FROM:/TO: lines
|
|
396
|
+
// Orphaned FROM:/TO: lines would otherwise be dropped silently.
|
|
331
397
|
for (const renamedIssue of plan.renamedIssues) {
|
|
332
398
|
const description = renamedIssue.kind === 'from-without-to'
|
|
333
399
|
? `RENAMED FROM "${renamedIssue.name}" (line ${renamedIssue.line}) has no matching TO: line`
|
|
@@ -339,11 +405,21 @@ export class Validator {
|
|
|
339
405
|
message: `${description} — write RENAMED entries as a FROM:/TO: pair.`,
|
|
340
406
|
});
|
|
341
407
|
}
|
|
342
|
-
// Validate RENAMED pairs
|
|
343
408
|
for (const { from, to } of plan.renamed) {
|
|
344
409
|
const fromKey = normalizeRequirementName(from);
|
|
345
410
|
const toKey = normalizeRequirementName(to);
|
|
346
411
|
totalDeltas++;
|
|
412
|
+
const placeholders = [from, to].filter(isTemplatePlaceholder);
|
|
413
|
+
if (placeholders.length > 0) {
|
|
414
|
+
issues.push({
|
|
415
|
+
level: 'ERROR',
|
|
416
|
+
path: entryPath,
|
|
417
|
+
message: `RENAMED entry still carries the template's placeholder ${placeholders
|
|
418
|
+
.map((name) => `"${name}"`)
|
|
419
|
+
.join(' and ')}. Name the requirement being renamed and its new name, or delete the section if this change renames none.`,
|
|
420
|
+
});
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
347
423
|
if (renamedFrom.has(fromKey)) {
|
|
348
424
|
issues.push({ level: 'ERROR', path: entryPath, message: `Duplicate FROM in RENAMED: "${from}"` });
|
|
349
425
|
}
|
|
@@ -357,7 +433,6 @@ export class Validator {
|
|
|
357
433
|
renamedTo.add(toKey);
|
|
358
434
|
}
|
|
359
435
|
}
|
|
360
|
-
// Cross-section conflicts (within the same spec file)
|
|
361
436
|
for (const n of modifiedNames) {
|
|
362
437
|
if (removedNames.has(n)) {
|
|
363
438
|
issues.push({ level: 'ERROR', path: entryPath, message: `Requirement present in both MODIFIED and REMOVED: "${n}"` });
|
|
@@ -383,8 +458,12 @@ export class Validator {
|
|
|
383
458
|
}
|
|
384
459
|
}
|
|
385
460
|
}
|
|
386
|
-
catch {
|
|
387
|
-
//
|
|
461
|
+
catch (error) {
|
|
462
|
+
// Only an absent specs dir means "no deltas". The walk and the reads
|
|
463
|
+
// above already answer that on their own, so anything else reaching here
|
|
464
|
+
// is a real failure that a bare catch used to turn into a clean verdict.
|
|
465
|
+
if (!isMissingPathError(error))
|
|
466
|
+
throw error;
|
|
388
467
|
}
|
|
389
468
|
for (const { path: specPath, kind, header } of emptySections) {
|
|
390
469
|
issues.push({
|
|
@@ -400,24 +479,27 @@ export class Validator {
|
|
|
400
479
|
message: 'No delta sections found. Add headers such as "## ADDED Requirements" or move non-delta notes outside specs/.',
|
|
401
480
|
});
|
|
402
481
|
}
|
|
403
|
-
// Runs here, after every structural error has been pushed, so the exclusion
|
|
404
|
-
// set below is just "what has already been reported". Calling it inside the
|
|
405
|
-
// loop above meant hand-splicing the two lists that are only raised down
|
|
406
|
-
// here — a set that any later check would silently fall out of.
|
|
407
|
-
//
|
|
408
482
|
// The checks above compare a delta against itself and, for MODIFIED, against
|
|
409
483
|
// the main spec's scenarios. None asks whether the main spec can supply the
|
|
410
|
-
// target the delta acts on — the merge's own preconditions
|
|
411
|
-
// consulted until archive.
|
|
484
|
+
// target the delta acts on — the merge's own preconditions.
|
|
412
485
|
if (mainSpecsDir !== undefined && archivePreflight) {
|
|
413
|
-
issues.push(...(await this.findArchiveBlockers(changeDir, mainSpecsDir
|
|
486
|
+
issues.push(...(await this.findArchiveBlockers(changeDir, mainSpecsDir)));
|
|
414
487
|
}
|
|
415
|
-
// A misplaced-spec error already names the file and the fix;
|
|
416
|
-
//
|
|
417
|
-
// the file just reported.
|
|
488
|
+
// A misplaced-spec error already names the file and the fix; "No deltas
|
|
489
|
+
// found" on top would contradict it.
|
|
418
490
|
if (totalDeltas === 0 && !hasMisplacedSpec) {
|
|
419
491
|
issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
|
|
420
492
|
}
|
|
493
|
+
// The ceiling `ChangeSchema.deltas.max()` declares. WARNING because the rule
|
|
494
|
+
// is advice about change size, not a correctness claim; `--strict` is what
|
|
495
|
+
// turns advice into a gate.
|
|
496
|
+
if (totalDeltas > MAX_DELTAS_PER_CHANGE) {
|
|
497
|
+
issues.push({
|
|
498
|
+
level: 'WARNING',
|
|
499
|
+
path: 'file',
|
|
500
|
+
message: `${VALIDATION_MESSAGES.CHANGE_TOO_MANY_DELTAS} (found ${totalDeltas}).`,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
421
503
|
return this.createReport(issues);
|
|
422
504
|
}
|
|
423
505
|
convertZodErrors(error) {
|
|
@@ -426,16 +508,22 @@ export class Validator {
|
|
|
426
508
|
if (message === VALIDATION_MESSAGES.CHANGE_NO_DELTAS) {
|
|
427
509
|
message = `${message}. ${VALIDATION_MESSAGES.GUIDE_NO_DELTAS}`;
|
|
428
510
|
}
|
|
511
|
+
// The guidance used to ride on a second, lower-severity copy of this
|
|
512
|
+
// finding emitted by applySpecRules. Appended here instead so the report
|
|
513
|
+
// carries one issue per fact, at the severity that decides the exit code.
|
|
514
|
+
if (message === VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS) {
|
|
515
|
+
message = `${message}. ${VALIDATION_MESSAGES.GUIDE_SCENARIO_FORMAT}`;
|
|
516
|
+
}
|
|
429
517
|
return {
|
|
430
518
|
level: 'ERROR',
|
|
431
|
-
path: err.path
|
|
519
|
+
path: formatIssuePath(err.path),
|
|
432
520
|
message,
|
|
433
521
|
};
|
|
434
522
|
});
|
|
435
523
|
}
|
|
436
524
|
applySpecRules(spec, content) {
|
|
437
525
|
const issues = [];
|
|
438
|
-
const fenceScan = scanCodeFences(content.split(
|
|
526
|
+
const fenceScan = scanCodeFences(normalizeDocument(content).split('\n'));
|
|
439
527
|
if (fenceScan.unclosedFenceLine !== null) {
|
|
440
528
|
issues.push({
|
|
441
529
|
level: 'ERROR',
|
|
@@ -452,10 +540,8 @@ export class Validator {
|
|
|
452
540
|
message: structuralIssue.message,
|
|
453
541
|
});
|
|
454
542
|
}
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
// instead. Checked first because a hand-written "TBD" is both a placeholder
|
|
458
|
-
// and too brief, and only one of those two tells the author what to do.
|
|
543
|
+
// Checked before brevity: a hand-written "TBD" is both a placeholder and too
|
|
544
|
+
// brief, and only the placeholder message tells the author what to do.
|
|
459
545
|
const placeholder = findPurposePlaceholderIssue(spec.overview, content);
|
|
460
546
|
if (placeholder) {
|
|
461
547
|
issues.push({
|
|
@@ -465,7 +551,7 @@ export class Validator {
|
|
|
465
551
|
message: VALIDATION_MESSAGES.PURPOSE_IS_PLACEHOLDER,
|
|
466
552
|
});
|
|
467
553
|
}
|
|
468
|
-
else if (spec.overview
|
|
554
|
+
else if (proseLength(spec.overview) < MIN_PURPOSE_LENGTH) {
|
|
469
555
|
issues.push({
|
|
470
556
|
level: 'WARNING',
|
|
471
557
|
path: 'overview',
|
|
@@ -480,62 +566,29 @@ export class Validator {
|
|
|
480
566
|
message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG,
|
|
481
567
|
});
|
|
482
568
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
message: `${VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS}. ${VALIDATION_MESSAGES.GUIDE_SCENARIO_FORMAT}`,
|
|
488
|
-
});
|
|
489
|
-
}
|
|
569
|
+
// A missing scenario is not checked here: SpecSchema's `scenarios.min(1)`
|
|
570
|
+
// already reports it as an ERROR, and convertZodErrors appends the same
|
|
571
|
+
// format guidance this branch used to carry. Two copies of one fact, at
|
|
572
|
+
// two severities, only made the report ambiguous about which to fix.
|
|
490
573
|
});
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
// the delta detection: a body that omits the keyword errors, with the
|
|
495
|
-
// targeted "move it to the body line" hint when the keyword is in the header
|
|
496
|
-
// only and the generic message otherwise. Emitted exactly once per
|
|
497
|
-
// requirement (the Zod refine that used to emit a generic error is removed).
|
|
574
|
+
// The main-spec parser collapses the requirement header into `text`, so
|
|
575
|
+
// recover the header+body pairs here — the same source the delta path
|
|
576
|
+
// trusts — and reuse the delta detection.
|
|
498
577
|
extractRequirementsSection(content).bodyBlocks.forEach((block, index) => {
|
|
499
578
|
const requirementText = this.extractRequirementText(block.raw);
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
// hint that fires for it — move the statement to the body — is the
|
|
504
|
-
// intended answer. Splitting them re-reports that case as missing text
|
|
505
|
-
// and loses the hint.
|
|
579
|
+
// One branch on purpose: an absent body here is the header-only pattern
|
|
580
|
+
// (`### Requirement: The system MUST ...` with no body line), whose hint —
|
|
581
|
+
// move the statement to the body — is the intended answer.
|
|
506
582
|
//
|
|
507
583
|
// WARNING, not ERROR: the keyword check is English-only, so at ERROR it
|
|
508
|
-
// blocked
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
if (!requirementText || !this.containsShallOrMust(requirementText)) {
|
|
584
|
+
// blocked requirements written in another language. `--strict` still
|
|
585
|
+
// refuses it.
|
|
586
|
+
// tospec/decisions/20260814_134116-shall-must-missing-is-a-warning.md
|
|
587
|
+
if (!requirementText || !containsShallOrMust(requirementText)) {
|
|
513
588
|
issues.push({
|
|
514
589
|
level: 'WARNING',
|
|
515
590
|
path: `requirements[${index}]`,
|
|
516
|
-
|
|
517
|
-
});
|
|
518
|
-
}
|
|
519
|
-
});
|
|
520
|
-
return issues;
|
|
521
|
-
}
|
|
522
|
-
applyChangeRules(change, content) {
|
|
523
|
-
const issues = [];
|
|
524
|
-
const MIN_DELTA_DESCRIPTION_LENGTH = 10;
|
|
525
|
-
change.deltas.forEach((delta, index) => {
|
|
526
|
-
if (!delta.description || delta.description.length < MIN_DELTA_DESCRIPTION_LENGTH) {
|
|
527
|
-
issues.push({
|
|
528
|
-
level: 'WARNING',
|
|
529
|
-
path: `deltas[${index}].description`,
|
|
530
|
-
message: VALIDATION_MESSAGES.DELTA_DESCRIPTION_TOO_BRIEF,
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
if ((delta.operation === 'ADDED' || delta.operation === 'MODIFIED') &&
|
|
534
|
-
(!delta.requirements || delta.requirements.length === 0)) {
|
|
535
|
-
issues.push({
|
|
536
|
-
level: 'WARNING',
|
|
537
|
-
path: `deltas[${index}].requirements`,
|
|
538
|
-
message: `${delta.operation} ${VALIDATION_MESSAGES.DELTA_MISSING_REQUIREMENTS}`,
|
|
591
|
+
...this.buildMissingShallOrMustMessage(`Requirement "${block.name}"`, block.name),
|
|
539
592
|
});
|
|
540
593
|
}
|
|
541
594
|
});
|
|
@@ -561,88 +614,135 @@ export class Validator {
|
|
|
561
614
|
const valid = this.strictMode
|
|
562
615
|
? errors === 0 && warnings === 0
|
|
563
616
|
: errors === 0;
|
|
617
|
+
// One report, one copy of each rule's background: the note belongs to the
|
|
618
|
+
// rule, not to the line that tripped it.
|
|
619
|
+
const notes = [...new Set(issues.map((issue) => issue.note).filter((n) => !!n))];
|
|
620
|
+
const flattened = issues.map(({ note: _note, ...issue }) => issue);
|
|
564
621
|
return {
|
|
565
622
|
valid,
|
|
566
|
-
issues,
|
|
623
|
+
issues: flattened,
|
|
567
624
|
summary: {
|
|
568
625
|
errors,
|
|
569
626
|
warnings,
|
|
570
627
|
info,
|
|
571
628
|
},
|
|
629
|
+
...(notes.length ? { notes } : {}),
|
|
572
630
|
};
|
|
573
631
|
}
|
|
574
632
|
extractRequirementText(blockRaw) {
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
// appears only in the header must still receive the body-keyword hint.
|
|
579
|
-
// Line 0 is the `### Requirement: ...` header.
|
|
633
|
+
// Line 0 is the `### Requirement: ...` header. Validation deliberately skips
|
|
634
|
+
// the parser's header-title fallback: a SHALL/MUST that appears only in the
|
|
635
|
+
// header must still receive the body-keyword hint.
|
|
580
636
|
const [, ...bodyLines] = blockRaw.split('\n');
|
|
581
637
|
return extractRequirementBodyShared(bodyLines) || undefined;
|
|
582
638
|
}
|
|
583
|
-
containsShallOrMust(text) {
|
|
584
|
-
return containsShallOrMustShared(text);
|
|
585
|
-
}
|
|
586
639
|
/**
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
* ("must contain SHALL or MUST") is confusing because the keyword is visibly
|
|
592
|
-
* present in the spec. Per the Tospec conventions the keyword has to live
|
|
593
|
-
* on the requirement body line (the line right after the header), so we point
|
|
594
|
-
* the author at that exact fix when the keyword is found in the header only.
|
|
640
|
+
* When the keyword already appears in the header (`### Requirement: The system
|
|
641
|
+
* SHALL ...`) the generic "must contain SHALL or MUST" reads as wrong, since
|
|
642
|
+
* the keyword is visibly there. The convention puts it on the body line, so
|
|
643
|
+
* point the author at that exact fix instead.
|
|
595
644
|
*/
|
|
596
645
|
buildMissingShallOrMustMessage(prefix, blockName) {
|
|
597
646
|
const base = `${prefix} must contain SHALL or MUST`;
|
|
598
|
-
if (
|
|
599
|
-
return
|
|
647
|
+
if (containsShallOrMust(blockName)) {
|
|
648
|
+
return {
|
|
649
|
+
message: `${base} in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.`,
|
|
650
|
+
};
|
|
600
651
|
}
|
|
601
|
-
|
|
652
|
+
// Carried as a `note`, not appended to the message: it is identical for
|
|
653
|
+
// every requirement, and inlining reprinted the paragraph once per
|
|
654
|
+
// occurrence, burying the names that actually differ.
|
|
655
|
+
return {
|
|
656
|
+
message: `${base}.`,
|
|
657
|
+
note: 'SHALL/MUST detection matches those two English keywords only — a requirement that states ' +
|
|
658
|
+
'its obligation in another language will always report this. It stays a warning for that ' +
|
|
659
|
+
'reason; if your specs are not written in English, prefer `tospec validate` over `--strict`, ' +
|
|
660
|
+
'which treats warnings as fatal.',
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* The main spec's two Purpose rules, applied to a delta that is about to
|
|
665
|
+
* create that main spec. Placeholder before brevity, as on the spec path.
|
|
666
|
+
*
|
|
667
|
+
* `mainSpecsDir` absent means new capabilities cannot be told from existing
|
|
668
|
+
* ones, so nothing is reported.
|
|
669
|
+
*/
|
|
670
|
+
async collectDeltaPurposeIssues(content, entryPath, mainSpecsDir) {
|
|
671
|
+
if (mainSpecsDir === undefined)
|
|
672
|
+
return [];
|
|
673
|
+
const purpose = extractDeltaPurpose(content);
|
|
674
|
+
const capability = entryPath.split('/')[0];
|
|
675
|
+
try {
|
|
676
|
+
await fs.access(path.join(mainSpecsDir, capability, 'spec.md'));
|
|
677
|
+
return []; // Capability exists: its own spec owns the Purpose.
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
// Only a genuinely absent spec means "new capability"; guessing "new"
|
|
681
|
+
// would report a Purpose that is never copied.
|
|
682
|
+
if (!isMissingPathError(error))
|
|
683
|
+
return [];
|
|
684
|
+
}
|
|
685
|
+
// WARNING, not ERROR: the schema calls the Purpose mandatory, but archive
|
|
686
|
+
// still completes with a placeholder, so this is advice on where the text
|
|
687
|
+
// belongs rather than a merge precondition. Reported here because archive
|
|
688
|
+
// is the only other place that notices, and by then the delta is under
|
|
689
|
+
// changes/archive/ where nobody edits it.
|
|
690
|
+
if (!purpose) {
|
|
691
|
+
return [{ level: 'WARNING', path: entryPath, message: VALIDATION_MESSAGES.DELTA_PURPOSE_MISSING }];
|
|
692
|
+
}
|
|
693
|
+
const placeholder = findPurposePlaceholderIssue(purpose, content);
|
|
694
|
+
if (placeholder) {
|
|
695
|
+
return [
|
|
696
|
+
{
|
|
697
|
+
level: 'WARNING',
|
|
698
|
+
path: entryPath,
|
|
699
|
+
line: placeholder.line,
|
|
700
|
+
message: VALIDATION_MESSAGES.DELTA_PURPOSE_IS_PLACEHOLDER,
|
|
701
|
+
},
|
|
702
|
+
];
|
|
703
|
+
}
|
|
704
|
+
if (proseLength(purpose) < MIN_PURPOSE_LENGTH) {
|
|
705
|
+
return [{ level: 'WARNING', path: entryPath, message: VALIDATION_MESSAGES.DELTA_PURPOSE_TOO_BRIEF }];
|
|
706
|
+
}
|
|
707
|
+
return [];
|
|
602
708
|
}
|
|
603
709
|
/**
|
|
604
710
|
* Scenarios a MODIFIED block would delete from the current main spec.
|
|
605
711
|
*
|
|
606
712
|
* A MODIFIED replaces the whole requirement, so a scenario the block omits is
|
|
607
|
-
* lost. `specs-apply`
|
|
608
|
-
*
|
|
609
|
-
* reviewed, and fail days later at the most expensive possible moment. Both
|
|
610
|
-
* sides now call `findMissingScenarios`, so validate reports exactly what
|
|
611
|
-
* archive refuses.
|
|
713
|
+
* lost. `specs-apply` refuses such a block too, but only at archive time. Both
|
|
714
|
+
* sides call `findMissingScenarios`, so validate reports what archive refuses.
|
|
612
715
|
*
|
|
613
|
-
*
|
|
614
|
-
* a
|
|
615
|
-
*
|
|
616
|
-
* - the requirement header is absent from it (a MODIFIED written against a
|
|
617
|
-
* sister change still in flight)
|
|
716
|
+
* Silent when the main spec does not exist yet (new capability) or lacks the
|
|
717
|
+
* requirement header (a MODIFIED written against a sister change still in
|
|
718
|
+
* flight): archive gates both separately.
|
|
618
719
|
*/
|
|
619
720
|
async collectDroppedScenarioIssues(mainSpecsDir, capability, modified, entryPath,
|
|
620
721
|
/**
|
|
621
|
-
* New name → old name, for requirements this change also renames.
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
*
|
|
722
|
+
* New name → old name, for requirements this change also renames. Without
|
|
723
|
+
* it a MODIFIED under the post-rename header finds nothing in the main spec
|
|
724
|
+
* and is skipped, so archive refuses what validate called valid.
|
|
725
|
+
*/
|
|
726
|
+
renamedToFrom,
|
|
727
|
+
/**
|
|
728
|
+
* `## REMOVED Scenarios` declarations. A scenario named here is one the
|
|
729
|
+
* author means to drop, so omitting it from the MODIFIED block stops being
|
|
730
|
+
* the accident this check exists to catch.
|
|
628
731
|
*/
|
|
629
|
-
|
|
732
|
+
declaredRemovals) {
|
|
630
733
|
let content;
|
|
631
734
|
try {
|
|
632
735
|
content = await fs.readFile(path.join(mainSpecsDir, capability, 'spec.md'), 'utf-8');
|
|
633
736
|
}
|
|
634
737
|
catch (error) {
|
|
635
|
-
// Silence belongs to the genuinely-absent case
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
// cannot tell a new capability from a broken one.
|
|
738
|
+
// Silence belongs to the genuinely-absent case only: a spec that exists
|
|
739
|
+
// and could not be read means the check never ran, and [] would report
|
|
740
|
+
// that as "nothing to lose".
|
|
639
741
|
if (isMissingPathError(error))
|
|
640
742
|
return [];
|
|
641
743
|
// Reported rather than thrown: the enclosing handler treats any throw as
|
|
642
|
-
// "no specs dir" and would
|
|
643
|
-
//
|
|
644
|
-
// would discard. ERROR because archive reads the same file and now fails
|
|
645
|
-
// on it too, so this is what archive refuses.
|
|
744
|
+
// "no specs dir" and would discard the other capabilities' findings.
|
|
745
|
+
// ERROR because archive reads the same file and fails on it too.
|
|
646
746
|
return [
|
|
647
747
|
{
|
|
648
748
|
level: 'ERROR',
|
|
@@ -656,6 +756,27 @@ export class Validator {
|
|
|
656
756
|
currentByName.set(normalizeRequirementName(block.name), block);
|
|
657
757
|
}
|
|
658
758
|
const issues = [];
|
|
759
|
+
// Declarations are consumed as matched, so a leftover is one that removed
|
|
760
|
+
// nothing — a typo that would otherwise read as a successful removal.
|
|
761
|
+
const unmatchedRemovals = new Set(declaredRemovals);
|
|
762
|
+
const declaredFor = (requirementKey) => {
|
|
763
|
+
const names = new Set();
|
|
764
|
+
for (const entry of declaredRemovals) {
|
|
765
|
+
if (entry.requirement === requirementKey)
|
|
766
|
+
names.add(entry.scenario);
|
|
767
|
+
}
|
|
768
|
+
return names;
|
|
769
|
+
};
|
|
770
|
+
for (const entry of declaredRemovals) {
|
|
771
|
+
if (!entry.hasReason) {
|
|
772
|
+
issues.push({
|
|
773
|
+
level: 'WARNING',
|
|
774
|
+
path: entryPath,
|
|
775
|
+
line: entry.startLine,
|
|
776
|
+
message: `REMOVED Scenarios entry for "${entry.scenario}" has no Reason. A scenario removal is a behavior removal — record why, the same as a removed requirement does.`,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
659
780
|
for (const block of modified) {
|
|
660
781
|
const key = normalizeRequirementName(block.name);
|
|
661
782
|
// Direct hit first; only fall back to the pre-rename name when this change
|
|
@@ -665,45 +786,86 @@ export class Validator {
|
|
|
665
786
|
(renamedFrom === undefined ? undefined : currentByName.get(normalizeRequirementName(renamedFrom)));
|
|
666
787
|
if (!current)
|
|
667
788
|
continue;
|
|
668
|
-
const missing = findMissingScenarios(current.raw, block.raw);
|
|
669
|
-
if (missing.length === 0)
|
|
670
|
-
continue;
|
|
671
789
|
// Named by its spec header, not its new one: that is the block the reader
|
|
672
790
|
// has to open to see the scenarios at risk.
|
|
673
791
|
const target = renamedFrom !== undefined && !currentByName.has(key) ? ` (renamed from "${renamedFrom}")` : '';
|
|
792
|
+
const declared = declaredFor(currentByName.has(key) ? key : normalizeRequirementName(renamedFrom ?? ''));
|
|
793
|
+
const missing = findMissingScenarios(current.raw, block.raw).filter((name) => {
|
|
794
|
+
if (!declared.has(name))
|
|
795
|
+
return true;
|
|
796
|
+
for (const entry of unmatchedRemovals) {
|
|
797
|
+
if (entry.scenario === name)
|
|
798
|
+
unmatchedRemovals.delete(entry);
|
|
799
|
+
}
|
|
800
|
+
return false;
|
|
801
|
+
});
|
|
802
|
+
if (missing.length > 0) {
|
|
803
|
+
issues.push({
|
|
804
|
+
level: 'ERROR',
|
|
805
|
+
path: entryPath,
|
|
806
|
+
message: `MODIFIED "${block.name}"${target} omits scenario(s) the current spec still has: ${missing
|
|
807
|
+
.map((name) => `"${name}"`)
|
|
808
|
+
.join(', ')}. A MODIFIED block replaces the whole requirement, so archiving would delete them — restate them in the block, or declare the removal under "## REMOVED Scenarios" with a Reason. ${REMOVED_SCENARIOS_GRAMMAR}`,
|
|
809
|
+
});
|
|
810
|
+
// One finding per requirement: a block missing whole scenarios is also
|
|
811
|
+
// missing their bullets, and reporting both buries the actionable one.
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
// WARNING, not ERROR: rewording a bullet is a legitimate MODIFIED, but on
|
|
815
|
+
// disk it is indistinguishable from a stale block reverting someone else's
|
|
816
|
+
// archived edit.
|
|
817
|
+
//
|
|
818
|
+
// Compared against the current requirement *minus* the scenarios this
|
|
819
|
+
// change declared removed: their bullets go with them by definition.
|
|
820
|
+
const currentRaw = withoutScenarios(current.raw, [...declared]);
|
|
821
|
+
const dropped = findDroppedBullets(currentRaw, block.raw);
|
|
822
|
+
// A rewrite replaces bullets; a stale block loses them. When the delta
|
|
823
|
+
// brings at least as many new bullets as it drops, every dropped line has
|
|
824
|
+
// a successor and the block is a rewording — reporting it made every
|
|
825
|
+
// legitimate MODIFIED fail `--strict`. Fewer bullets than before is the
|
|
826
|
+
// shape of a reverted edit, which is the case this check exists for.
|
|
827
|
+
const replaced = findDroppedBullets(block.raw, currentRaw);
|
|
828
|
+
if (dropped.length > 0 && replaced.length < dropped.length) {
|
|
829
|
+
issues.push({
|
|
830
|
+
level: 'WARNING',
|
|
831
|
+
path: entryPath,
|
|
832
|
+
message: `MODIFIED "${block.name}"${target} drops bullet(s) the current spec still has: ${dropped
|
|
833
|
+
.map((line) => `"${line}"`)
|
|
834
|
+
.join(', ')}. Confirm this is an intentional rewrite and not a block written against an older ` +
|
|
835
|
+
`copy of the requirement — run tospec show <change> --diff to see the full comparison.`,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// A declaration that matched nothing: the author believes a scenario was
|
|
840
|
+
// removed, the spec still has it, and every command reports success. ERROR
|
|
841
|
+
// because there is no reading of it that is correct.
|
|
842
|
+
for (const entry of unmatchedRemovals) {
|
|
674
843
|
issues.push({
|
|
675
844
|
level: 'ERROR',
|
|
676
845
|
path: entryPath,
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
.join(', ')}. A MODIFIED block replaces the whole requirement, so archiving would delete them — restate them in the block.`,
|
|
846
|
+
line: entry.startLine,
|
|
847
|
+
message: `REMOVED Scenarios declares "${entry.scenario}" under requirement "${entry.requirement}", but no MODIFIED block for that requirement drops that scenario. Check both names against tospec/specs/${capability}/spec.md — a declaration that matches nothing removes nothing.`,
|
|
680
848
|
});
|
|
681
849
|
}
|
|
682
850
|
return issues;
|
|
683
851
|
}
|
|
684
852
|
/**
|
|
685
|
-
* Dry-run the merge and report what it would refuse
|
|
853
|
+
* Dry-run the merge and report what it would refuse, plus the one thing it
|
|
854
|
+
* would accept while doing nothing.
|
|
686
855
|
*
|
|
687
856
|
* Reusing the merge builder rather than restating its preconditions is the
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
* would be free to drift — which shows up as validate and archive disagreeing,
|
|
691
|
-
* the one thing this check exists to prevent.
|
|
857
|
+
* point: a second copy would be free to drift, which shows up as validate and
|
|
858
|
+
* archive disagreeing.
|
|
692
859
|
*
|
|
693
|
-
* WARNING, not ERROR
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
* counts warnings as fatal and ignores INFO, so the one gate built to be a
|
|
698
|
-
* pre-archive check waved through changes this very dry run had just proved
|
|
699
|
-
* archive would refuse. WARNING keeps the default verdict and gives `--strict`
|
|
700
|
-
* something to refuse. See
|
|
860
|
+
* WARNING, not ERROR or INFO. ERROR is wrong because a MODIFIED whose target
|
|
861
|
+
* is missing is also what a change modifying a sibling's unarchived
|
|
862
|
+
* requirement looks like. INFO was wrong because `--strict` ignores it, so the
|
|
863
|
+
* pre-archive gate waved through changes archive would refuse.
|
|
701
864
|
* tospec/decisions/20260916_154000-archive-dry-run-findings-are-warnings.md
|
|
702
865
|
*/
|
|
703
|
-
async findArchiveBlockers(changeDir, mainSpecsDir
|
|
704
|
-
|
|
705
|
-
//
|
|
706
|
-
// dry run discards along with the rest of the rebuilt content.
|
|
866
|
+
async findArchiveBlockers(changeDir, mainSpecsDir) {
|
|
867
|
+
// Only reaches the generated skeleton's placeholder Purpose, which this dry
|
|
868
|
+
// run discards with the rest of the rebuilt content.
|
|
707
869
|
const changeName = path.basename(changeDir);
|
|
708
870
|
const issues = [];
|
|
709
871
|
let updates;
|
|
@@ -711,9 +873,8 @@ export class Validator {
|
|
|
711
873
|
updates = await findSpecUpdates(changeDir, mainSpecsDir);
|
|
712
874
|
}
|
|
713
875
|
catch (error) {
|
|
714
|
-
// An advisory check that cannot start must not take the report with it
|
|
715
|
-
//
|
|
716
|
-
// it found; those findings are the ones the author can act on.
|
|
876
|
+
// An advisory check that cannot start must not take the report with it:
|
|
877
|
+
// the structural pass walks the same tree and its findings are actionable.
|
|
717
878
|
return [
|
|
718
879
|
{
|
|
719
880
|
level: 'INFO',
|
|
@@ -727,35 +888,53 @@ export class Validator {
|
|
|
727
888
|
// rebuilds the same entryPath the checks above report under.
|
|
728
889
|
const capability = path.basename(path.dirname(update.source));
|
|
729
890
|
const entryPath = FileSystemUtils.toPosixPath(`${capability}/spec.md`);
|
|
730
|
-
// A delta those checks already rejected would be reported twice, the
|
|
731
|
-
// second time in the merge's wording rather than the wording that names
|
|
732
|
-
// the actual mistake.
|
|
733
|
-
if (alreadyReported.has(entryPath))
|
|
734
|
-
continue;
|
|
735
891
|
try {
|
|
736
|
-
await buildUpdatedSpec(update, changeName, { silent: true });
|
|
892
|
+
const built = await buildUpdatedSpec(update, changeName, { silent: true });
|
|
893
|
+
// The merge would succeed but delete nothing, which is not what the
|
|
894
|
+
// delta appears to say. A mistyped REMOVED header is the likeliest
|
|
895
|
+
// cause and the hardest to catch, since nothing visibly happens.
|
|
896
|
+
//
|
|
897
|
+
// Only the REMOVED codes: the merge's other notices are about Purpose,
|
|
898
|
+
// which this validator judges under its own rules.
|
|
899
|
+
for (const notice of built.notices) {
|
|
900
|
+
if (!REMOVED_NOOP_NOTICE_CODES.has(notice.code))
|
|
901
|
+
continue;
|
|
902
|
+
issues.push({
|
|
903
|
+
level: 'WARNING',
|
|
904
|
+
path: entryPath,
|
|
905
|
+
message: `Archive would accept this delta but remove nothing: ${notice.message}`,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
737
908
|
}
|
|
738
909
|
catch (error) {
|
|
739
910
|
// Only the thrown preconditions, which carry no errno. A filesystem
|
|
740
|
-
// error says nothing about whether the delta applies, and
|
|
741
|
-
// --all`
|
|
742
|
-
// a collision is a conflict that is not there.
|
|
911
|
+
// error says nothing about whether the delta applies, and under
|
|
912
|
+
// `validate --all` a transient EMFILE would read as a collision.
|
|
743
913
|
if (error?.code !== undefined)
|
|
744
914
|
continue;
|
|
915
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
916
|
+
// The structural pass above already reported these in its own, more
|
|
917
|
+
// precise wording; repeating them in the merge's would name the same
|
|
918
|
+
// mistake twice. Filtered by *kind* rather than by path: the merge's
|
|
919
|
+
// other preconditions (a MODIFIED or RENAMED target the main spec does
|
|
920
|
+
// not have, an ADDED that collides) are checked nowhere else, so a delta
|
|
921
|
+
// with one unrelated grammar error used to hide them until that error
|
|
922
|
+
// was fixed — one round trip per finding.
|
|
923
|
+
if (STRUCTURAL_MERGE_FAILURE.test(message))
|
|
924
|
+
continue;
|
|
745
925
|
issues.push({
|
|
746
926
|
level: 'WARNING',
|
|
747
927
|
path: entryPath,
|
|
748
|
-
message: `Archive would refuse this delta: ${
|
|
928
|
+
message: `Archive would refuse this delta: ${message}`,
|
|
749
929
|
});
|
|
750
930
|
}
|
|
751
931
|
}
|
|
752
932
|
return issues;
|
|
753
933
|
}
|
|
754
934
|
/**
|
|
755
|
-
* Scenario-quality rules for a delta requirement block
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
* level-4 headers get a WARNING so authors know they were not counted.
|
|
935
|
+
* Scenario-quality rules for a delta requirement block: only `#### Scenario:`
|
|
936
|
+
* headers count, each scenario body must contain uppercase WHEN and THEN, and
|
|
937
|
+
* a stray level-4 header gets a WARNING so the author knows it was not counted.
|
|
759
938
|
*/
|
|
760
939
|
collectScenarioIssues(section, block, entryPath) {
|
|
761
940
|
const issues = [];
|
|
@@ -770,13 +949,9 @@ export class Validator {
|
|
|
770
949
|
message: `${section} "${block.name}" must include at least one "#### Scenario:" block`,
|
|
771
950
|
});
|
|
772
951
|
}
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
// it — restating one of them twice keeps the count and silently deletes the
|
|
777
|
-
// other's body on archive. Refusing the duplicate here is what keeps that
|
|
778
|
-
// guard meaningful, and this is the only door a duplicate enters through:
|
|
779
|
-
// main specs are only ever written by merging these blocks.
|
|
952
|
+
// Scenario names are the only handle the MODIFIED scenario-loss guard has —
|
|
953
|
+
// it counts how many times each name appears on each side. Restating one
|
|
954
|
+
// twice keeps the count and silently deletes the other's body on archive.
|
|
780
955
|
const scenarioNames = new Set();
|
|
781
956
|
for (const scenario of analysis.scenarios) {
|
|
782
957
|
const key = scenario.title.trim().toLowerCase();
|
|
@@ -793,6 +968,15 @@ export class Validator {
|
|
|
793
968
|
}
|
|
794
969
|
}
|
|
795
970
|
for (const scenario of analysis.scenarios) {
|
|
971
|
+
if (isTemplatePlaceholder(scenario.title)) {
|
|
972
|
+
issues.push({
|
|
973
|
+
level: 'ERROR',
|
|
974
|
+
path: entryPath,
|
|
975
|
+
line: toLine(scenario.relLine),
|
|
976
|
+
message: `${section} "${block.name}" scenario "${scenario.title}" (line ${toLine(scenario.relLine)}) still carries the template's placeholder name. Name the scenario after the behavior it checks.`,
|
|
977
|
+
});
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
796
980
|
const missing = [
|
|
797
981
|
...(scenario.hasWhen ? [] : ['WHEN']),
|
|
798
982
|
...(scenario.hasThen ? [] : ['THEN']),
|
|
@@ -818,10 +1002,9 @@ export class Validator {
|
|
|
818
1002
|
}
|
|
819
1003
|
/**
|
|
820
1004
|
* RENAMED/REMOVED have their own grammar (FROM:/TO: pairs; name bullets with
|
|
821
|
-
* Reason/Migration), not the `### Requirement:` block ADDED/MODIFIED use
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
* tospec/changes/delta-syntax-diagnostics-misleading/task.md.
|
|
1005
|
+
* Reason/Migration), not the `### Requirement:` block ADDED/MODIFIED use, so
|
|
1006
|
+
* "add a ### Requirement: block" would send their authors to a fix that does
|
|
1007
|
+
* not work.
|
|
825
1008
|
*/
|
|
826
1009
|
formatEmptySectionMessage(kind, header) {
|
|
827
1010
|
switch (kind) {
|