@seanmars/tospec 0.24.0 → 0.26.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 +243 -0
- package/README.md +10 -6
- package/assets/dashboard/lib/derive.js +11 -5
- package/assets/dashboard/lib/store.js +4 -2
- package/assets/dashboard/views/change.js +5 -3
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +42 -7
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +213 -100
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/dashboard.d.ts +9 -0
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +77 -21
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/decision.d.ts +38 -1
- package/dist/commands/decision.d.ts.map +1 -1
- package/dist/commands/decision.js +303 -27
- package/dist/commands/decision.js.map +1 -1
- package/dist/commands/metrics.d.ts +3 -4
- package/dist/commands/metrics.d.ts.map +1 -1
- package/dist/commands/metrics.js +3 -5
- package/dist/commands/metrics.js.map +1 -1
- package/dist/commands/shared-output.d.ts +24 -0
- package/dist/commands/shared-output.d.ts.map +1 -1
- package/dist/commands/shared-output.js +21 -2
- package/dist/commands/shared-output.js.map +1 -1
- package/dist/commands/show.d.ts.map +1 -1
- package/dist/commands/show.js +12 -9
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/validate.d.ts +19 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +243 -64
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/workflow/instructions.d.ts +7 -1
- package/dist/commands/workflow/instructions.d.ts.map +1 -1
- package/dist/commands/workflow/instructions.js +299 -81
- package/dist/commands/workflow/instructions.js.map +1 -1
- package/dist/commands/workflow/new-change.d.ts.map +1 -1
- package/dist/commands/workflow/new-change.js +26 -17
- package/dist/commands/workflow/new-change.js.map +1 -1
- package/dist/commands/workflow/prompt-escape.d.ts +25 -0
- package/dist/commands/workflow/prompt-escape.d.ts.map +1 -0
- package/dist/commands/workflow/prompt-escape.js +62 -0
- package/dist/commands/workflow/prompt-escape.js.map +1 -0
- package/dist/commands/workflow/schemas.d.ts.map +1 -1
- package/dist/commands/workflow/schemas.js +34 -16
- package/dist/commands/workflow/schemas.js.map +1 -1
- package/dist/commands/workflow/shared.d.ts +46 -10
- package/dist/commands/workflow/shared.d.ts.map +1 -1
- package/dist/commands/workflow/shared.js +66 -30
- package/dist/commands/workflow/shared.js.map +1 -1
- package/dist/commands/workflow/status.d.ts +5 -2
- package/dist/commands/workflow/status.d.ts.map +1 -1
- package/dist/commands/workflow/status.js +62 -28
- package/dist/commands/workflow/status.js.map +1 -1
- package/dist/core/archive.d.ts +38 -0
- package/dist/core/archive.d.ts.map +1 -1
- package/dist/core/archive.js +492 -75
- package/dist/core/archive.js.map +1 -1
- package/dist/core/artifact-graph/index.d.ts +2 -2
- package/dist/core/artifact-graph/index.d.ts.map +1 -1
- package/dist/core/artifact-graph/index.js +2 -2
- package/dist/core/artifact-graph/index.js.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts +8 -0
- package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.js +58 -36
- package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
- package/dist/core/artifact-graph/resolver.d.ts +39 -12
- package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
- package/dist/core/artifact-graph/resolver.js +82 -36
- package/dist/core/artifact-graph/resolver.js.map +1 -1
- package/dist/core/artifact-graph/schema.d.ts +24 -0
- package/dist/core/artifact-graph/schema.d.ts.map +1 -1
- package/dist/core/artifact-graph/schema.js +32 -1
- package/dist/core/artifact-graph/schema.js.map +1 -1
- package/dist/core/artifact-graph/stub-detection.d.ts +10 -2
- package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -1
- package/dist/core/artifact-graph/stub-detection.js +18 -11
- package/dist/core/artifact-graph/stub-detection.js.map +1 -1
- package/dist/core/artifact-graph/types.d.ts +2 -2
- package/dist/core/artifact-graph/types.d.ts.map +1 -1
- package/dist/core/artifact-graph/types.js +10 -1
- package/dist/core/artifact-graph/types.js.map +1 -1
- package/dist/core/change-metadata/schema.d.ts.map +1 -1
- package/dist/core/change-metadata/schema.js +18 -4
- package/dist/core/change-metadata/schema.js.map +1 -1
- package/dist/core/change-presenter.d.ts.map +1 -1
- package/dist/core/change-presenter.js +13 -8
- package/dist/core/change-presenter.js.map +1 -1
- package/dist/core/change-status-policy.d.ts.map +1 -1
- package/dist/core/change-status-policy.js +3 -1
- package/dist/core/change-status-policy.js.map +1 -1
- package/dist/core/codex-metrics.d.ts +1 -1
- package/dist/core/codex-metrics.js +1 -1
- package/dist/core/config-schema.d.ts +8 -0
- package/dist/core/config-schema.d.ts.map +1 -1
- package/dist/core/config-schema.js +10 -0
- package/dist/core/config-schema.js.map +1 -1
- package/dist/core/dashboard-activity.d.ts +4 -4
- package/dist/core/dashboard-activity.js +4 -4
- package/dist/core/dashboard-data.d.ts +11 -5
- package/dist/core/dashboard-data.d.ts.map +1 -1
- package/dist/core/dashboard-data.js +54 -28
- package/dist/core/dashboard-data.js.map +1 -1
- package/dist/core/dashboard-hub.d.ts +3 -3
- package/dist/core/dashboard-hub.d.ts.map +1 -1
- package/dist/core/dashboard-hub.js +16 -4
- package/dist/core/dashboard-hub.js.map +1 -1
- package/dist/core/dashboard-routes.d.ts +2 -1
- package/dist/core/dashboard-routes.d.ts.map +1 -1
- package/dist/core/dashboard-routes.js +102 -33
- package/dist/core/dashboard-routes.js.map +1 -1
- package/dist/core/global-config.d.ts +71 -2
- package/dist/core/global-config.d.ts.map +1 -1
- package/dist/core/global-config.js +149 -4
- package/dist/core/global-config.js.map +1 -1
- package/dist/core/id.d.ts +9 -0
- package/dist/core/id.d.ts.map +1 -1
- package/dist/core/id.js +37 -0
- package/dist/core/id.js.map +1 -1
- package/dist/core/init.d.ts +9 -2
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +116 -33
- package/dist/core/init.js.map +1 -1
- package/dist/core/list.d.ts.map +1 -1
- package/dist/core/list.js +69 -45
- package/dist/core/list.js.map +1 -1
- package/dist/core/local-server.d.ts +9 -2
- package/dist/core/local-server.d.ts.map +1 -1
- package/dist/core/local-server.js +24 -4
- package/dist/core/local-server.js.map +1 -1
- package/dist/core/markdown-render.d.ts.map +1 -1
- package/dist/core/markdown-render.js +7 -2
- package/dist/core/markdown-render.js.map +1 -1
- package/dist/core/migrate.d.ts +24 -2
- package/dist/core/migrate.d.ts.map +1 -1
- package/dist/core/migrate.js +335 -59
- package/dist/core/migrate.js.map +1 -1
- package/dist/core/parsers/change-parser.d.ts +20 -1
- package/dist/core/parsers/change-parser.d.ts.map +1 -1
- package/dist/core/parsers/change-parser.js +76 -73
- package/dist/core/parsers/change-parser.js.map +1 -1
- package/dist/core/parsers/markdown-parser.d.ts +24 -0
- package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
- package/dist/core/parsers/markdown-parser.js +65 -10
- package/dist/core/parsers/markdown-parser.js.map +1 -1
- package/dist/core/parsers/requirement-blocks.d.ts +127 -6
- package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
- package/dist/core/parsers/requirement-blocks.js +349 -56
- package/dist/core/parsers/requirement-blocks.js.map +1 -1
- package/dist/core/parsers/requirement-text.d.ts +25 -1
- package/dist/core/parsers/requirement-text.d.ts.map +1 -1
- package/dist/core/parsers/requirement-text.js +62 -6
- 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 +36 -24
- package/dist/core/parsers/spec-structure.js.map +1 -1
- package/dist/core/project-config.d.ts +9 -2
- package/dist/core/project-config.d.ts.map +1 -1
- package/dist/core/project-config.js +30 -7
- package/dist/core/project-config.js.map +1 -1
- package/dist/core/project-layout.d.ts +26 -0
- package/dist/core/project-layout.d.ts.map +1 -1
- package/dist/core/project-layout.js +51 -1
- package/dist/core/project-layout.js.map +1 -1
- package/dist/core/schema-names.d.ts +17 -0
- package/dist/core/schema-names.d.ts.map +1 -1
- package/dist/core/schema-names.js +24 -0
- package/dist/core/schema-names.js.map +1 -1
- package/dist/core/shared/index.d.ts +1 -1
- package/dist/core/shared/index.d.ts.map +1 -1
- package/dist/core/shared/index.js +1 -1
- package/dist/core/shared/index.js.map +1 -1
- package/dist/core/shared/rules-generation.d.ts +12 -0
- package/dist/core/shared/rules-generation.d.ts.map +1 -1
- package/dist/core/shared/rules-generation.js +26 -2
- package/dist/core/shared/rules-generation.js.map +1 -1
- package/dist/core/shared/skill-generation.d.ts +7 -0
- package/dist/core/shared/skill-generation.d.ts.map +1 -1
- package/dist/core/shared/skill-generation.js +19 -0
- package/dist/core/shared/skill-generation.js.map +1 -1
- package/dist/core/shared/tool-detection.d.ts +10 -1
- package/dist/core/shared/tool-detection.d.ts.map +1 -1
- package/dist/core/shared/tool-detection.js +37 -6
- package/dist/core/shared/tool-detection.js.map +1 -1
- package/dist/core/spec-presenter.d.ts.map +1 -1
- package/dist/core/spec-presenter.js +23 -1
- package/dist/core/spec-presenter.js.map +1 -1
- package/dist/core/specs-apply.d.ts +98 -1
- package/dist/core/specs-apply.d.ts.map +1 -1
- package/dist/core/specs-apply.js +426 -63
- package/dist/core/specs-apply.js.map +1 -1
- package/dist/core/templates/fragments/change-diff.d.ts +37 -0
- package/dist/core/templates/fragments/change-diff.d.ts.map +1 -0
- package/dist/core/templates/fragments/change-diff.js +37 -0
- package/dist/core/templates/fragments/change-diff.js.map +1 -0
- package/dist/core/templates/fragments/sync-fail.d.ts +8 -0
- package/dist/core/templates/fragments/sync-fail.d.ts.map +1 -0
- package/dist/core/templates/fragments/sync-fail.js +8 -0
- package/dist/core/templates/fragments/sync-fail.js.map +1 -0
- package/dist/core/templates/fragments/validate.d.ts +14 -1
- package/dist/core/templates/fragments/validate.d.ts.map +1 -1
- package/dist/core/templates/fragments/validate.js +14 -1
- package/dist/core/templates/fragments/validate.js.map +1 -1
- package/dist/core/templates/fragments/verify.d.ts +2 -2
- package/dist/core/templates/fragments/verify.d.ts.map +1 -1
- package/dist/core/templates/fragments/verify.js +8 -5
- package/dist/core/templates/fragments/verify.js.map +1 -1
- package/dist/core/templates/workflows/apply.d.ts +1 -1
- package/dist/core/templates/workflows/apply.d.ts.map +1 -1
- package/dist/core/templates/workflows/apply.js +29 -10
- package/dist/core/templates/workflows/apply.js.map +1 -1
- package/dist/core/templates/workflows/archive.d.ts.map +1 -1
- package/dist/core/templates/workflows/archive.js +27 -9
- package/dist/core/templates/workflows/archive.js.map +1 -1
- package/dist/core/templates/workflows/decision.d.ts.map +1 -1
- package/dist/core/templates/workflows/decision.js +10 -4
- package/dist/core/templates/workflows/decision.js.map +1 -1
- package/dist/core/templates/workflows/explore.d.ts +2 -2
- package/dist/core/templates/workflows/explore.d.ts.map +1 -1
- package/dist/core/templates/workflows/explore.js +7 -3
- package/dist/core/templates/workflows/explore.js.map +1 -1
- package/dist/core/templates/workflows/grill.d.ts +2 -2
- package/dist/core/templates/workflows/grill.d.ts.map +1 -1
- package/dist/core/templates/workflows/grill.js +5 -4
- package/dist/core/templates/workflows/grill.js.map +1 -1
- package/dist/core/templates/workflows/issue.d.ts.map +1 -1
- package/dist/core/templates/workflows/issue.js +9 -3
- package/dist/core/templates/workflows/issue.js.map +1 -1
- package/dist/core/templates/workflows/propose.d.ts.map +1 -1
- package/dist/core/templates/workflows/propose.js +4 -1
- package/dist/core/templates/workflows/propose.js.map +1 -1
- package/dist/core/templates/workflows/sync.d.ts.map +1 -1
- package/dist/core/templates/workflows/sync.js +25 -11
- package/dist/core/templates/workflows/sync.js.map +1 -1
- package/dist/core/templates/workflows/update.d.ts.map +1 -1
- package/dist/core/templates/workflows/update.js +17 -10
- package/dist/core/templates/workflows/update.js.map +1 -1
- package/dist/core/update.d.ts +5 -0
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +66 -29
- package/dist/core/update.js.map +1 -1
- package/dist/core/validation/constants.d.ts +7 -1
- package/dist/core/validation/constants.d.ts.map +1 -1
- package/dist/core/validation/constants.js +16 -2
- package/dist/core/validation/constants.js.map +1 -1
- package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
- package/dist/core/validation/purpose-placeholder.js +35 -11
- package/dist/core/validation/purpose-placeholder.js.map +1 -1
- package/dist/core/validation/section-validator.d.ts +7 -0
- package/dist/core/validation/section-validator.d.ts.map +1 -1
- package/dist/core/validation/section-validator.js +22 -2
- package/dist/core/validation/section-validator.js.map +1 -1
- package/dist/core/validation/sister-changes.d.ts +93 -0
- package/dist/core/validation/sister-changes.d.ts.map +1 -0
- package/dist/core/validation/sister-changes.js +180 -0
- package/dist/core/validation/sister-changes.js.map +1 -0
- package/dist/core/validation/task-markers.d.ts +9 -0
- package/dist/core/validation/task-markers.d.ts.map +1 -0
- package/dist/core/validation/task-markers.js +71 -0
- package/dist/core/validation/task-markers.js.map +1 -0
- package/dist/core/validation/types.d.ts +58 -0
- package/dist/core/validation/types.d.ts.map +1 -1
- package/dist/core/validation/types.js +22 -0
- package/dist/core/validation/types.js.map +1 -1
- package/dist/core/validation/validator.d.ts +79 -2
- package/dist/core/validation/validator.d.ts.map +1 -1
- package/dist/core/validation/validator.js +565 -84
- package/dist/core/validation/validator.js.map +1 -1
- package/dist/utils/change-metadata.d.ts +62 -5
- package/dist/utils/change-metadata.d.ts.map +1 -1
- package/dist/utils/change-metadata.js +193 -31
- package/dist/utils/change-metadata.js.map +1 -1
- package/dist/utils/change-utils.d.ts +21 -0
- package/dist/utils/change-utils.d.ts.map +1 -1
- package/dist/utils/change-utils.js +168 -53
- package/dist/utils/change-utils.js.map +1 -1
- package/dist/utils/command-line.d.ts +11 -0
- package/dist/utils/command-line.d.ts.map +1 -0
- package/dist/utils/command-line.js +43 -0
- package/dist/utils/command-line.js.map +1 -0
- package/dist/utils/display-width.d.ts +10 -0
- package/dist/utils/display-width.d.ts.map +1 -0
- package/dist/utils/display-width.js +15 -0
- package/dist/utils/display-width.js.map +1 -0
- package/dist/utils/file-system.d.ts +16 -0
- package/dist/utils/file-system.d.ts.map +1 -1
- package/dist/utils/file-system.js +49 -2
- package/dist/utils/file-system.js.map +1 -1
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +11 -5
- package/dist/utils/frontmatter.js.map +1 -1
- package/dist/utils/interactive.d.ts.map +1 -1
- package/dist/utils/interactive.js +3 -1
- package/dist/utils/interactive.js.map +1 -1
- package/dist/utils/item-discovery.d.ts +3 -2
- package/dist/utils/item-discovery.d.ts.map +1 -1
- package/dist/utils/item-discovery.js +34 -16
- package/dist/utils/item-discovery.js.map +1 -1
- package/dist/utils/link.d.ts +0 -1
- package/dist/utils/link.d.ts.map +1 -1
- package/dist/utils/link.js +0 -1
- package/dist/utils/link.js.map +1 -1
- package/dist/utils/match.d.ts.map +1 -1
- package/dist/utils/match.js +10 -1
- package/dist/utils/match.js.map +1 -1
- package/dist/utils/option-flags.d.ts +9 -0
- package/dist/utils/option-flags.d.ts.map +1 -0
- package/dist/utils/option-flags.js +12 -0
- package/dist/utils/option-flags.js.map +1 -0
- package/dist/utils/sentence.d.ts +9 -0
- package/dist/utils/sentence.d.ts.map +1 -0
- package/dist/utils/sentence.js +12 -0
- package/dist/utils/sentence.js.map +1 -0
- package/dist/utils/spec-files.d.ts +84 -1
- package/dist/utils/spec-files.d.ts.map +1 -1
- package/dist/utils/spec-files.js +192 -3
- package/dist/utils/spec-files.js.map +1 -1
- package/dist/utils/task-progress.d.ts.map +1 -1
- package/dist/utils/task-progress.js +32 -9
- package/dist/utils/task-progress.js.map +1 -1
- package/dist/utils/timestamp.d.ts +27 -0
- package/dist/utils/timestamp.d.ts.map +1 -1
- package/dist/utils/timestamp.js +37 -0
- package/dist/utils/timestamp.js.map +1 -1
- package/package.json +4 -1
- package/schemas/decision/templates/decision.md +2 -2
- package/schemas/issue/schema.yaml +12 -6
- package/schemas/issue/templates/task.md +1 -1
- package/schemas/sdd/schema.yaml +21 -10
- package/schemas/sdd/templates/proposal.md +1 -1
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { readFileSync, promises as fs } from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { SpecSchema } from '../schemas/index.js';
|
|
4
|
-
import { MarkdownParser } from '../parsers/markdown-parser.js';
|
|
5
|
-
import {
|
|
4
|
+
import { MarkdownParser, SpecHeadingLevelError } from '../parsers/markdown-parser.js';
|
|
5
|
+
import { ARCHIVE_REFUSAL_CODE, SISTER_CHANGE_OVERLAP_CODE, REQUIREMENT_HEADER_MISMATCH_CODE, } from './types.js';
|
|
6
|
+
import { appendSentence } from '../../utils/sentence.js';
|
|
7
|
+
import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, MAX_DELTAS_PER_CHANGE, VALIDATION_MESSAGES, noDeltasGuide } from './constants.js';
|
|
6
8
|
import { proseLength } from './prose-length.js';
|
|
7
|
-
import { parseDeltaSpec, normalizeRequirementName, extractRequirementsSection } from '../parsers/requirement-blocks.js';
|
|
9
|
+
import { parseDeltaSpec, normalizeRequirementName, findFoldedMatch, extractRequirementsSection, postRenameRemovalHint, } from '../parsers/requirement-blocks.js';
|
|
8
10
|
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust, analyzeScenarios, scanCodeFences, findMissingScenarios, findDroppedBullets, withoutScenarios, normalizeDocument, } from '../parsers/requirement-text.js';
|
|
9
11
|
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
|
10
12
|
import { findPurposePlaceholderIssue } from './purpose-placeholder.js';
|
|
11
|
-
import { FileSystemUtils, extractNameFromPath, isMissingPathError } from '../../utils/file-system.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
13
|
+
import { FileSystemUtils, extractNameFromPath, isMissingPathError, isAbsentDirectoryError } from '../../utils/file-system.js';
|
|
14
|
+
import { isUnicodeKebabName, portableNameFault, UNICODE_KEBAB_NAME_DESCRIPTION } from '../id.js';
|
|
15
|
+
import { findAllMarkdownFiles, findRootSpecDelta, specLayoutFault } from '../../utils/spec-files.js';
|
|
16
|
+
import { findSpecUpdates, buildUpdatedSpec, extractDeltaPurpose, resolveOnDiskSpecName, MissingTargetSpecError, MissingTargetRequirementError, RequirementNearMissError, } from '../specs-apply.js';
|
|
17
|
+
import { findHeaderMismatches, findSharedRequirements, listDeltaTouches, listSisterChangeDeltas, readChangeOrderKey, } from './sister-changes.js';
|
|
14
18
|
/**
|
|
15
19
|
* The one place the `## REMOVED Scenarios` grammar is spelled out in a
|
|
16
20
|
* message. Both the "omits scenario(s)" error and the empty-section error
|
|
@@ -18,24 +22,80 @@ import { findSpecUpdates, buildUpdatedSpec, extractDeltaPurpose } from '../specs
|
|
|
18
22
|
* instead of being sent to the template.
|
|
19
23
|
*/
|
|
20
24
|
const REMOVED_SCENARIOS_GRAMMAR = 'Each entry is three bullets: "- Requirement: `<name>`" / "- Scenario: `<name>`" / "- Reason: <why>".';
|
|
25
|
+
/**
|
|
26
|
+
* The entry names under `tospec/specs/` as spelled on disk, or undefined when
|
|
27
|
+
* they cannot be known (no `mainSpecsDir`, or a directory that exists but cannot
|
|
28
|
+
* be read): then new capabilities cannot be told from existing ones, and
|
|
29
|
+
* guessing "new" would hold an existing capability to the naming rule.
|
|
30
|
+
*/
|
|
31
|
+
async function listMainSpecNames(mainSpecsDir) {
|
|
32
|
+
if (mainSpecsDir === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
try {
|
|
35
|
+
return await fs.readdir(mainSpecsDir);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return isAbsentDirectoryError(error) ? [] : undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The capability folder's name checked against the main specs it will merge
|
|
43
|
+
* into. Matched against the readdir spelling, never through the file system:
|
|
44
|
+
* asking whether `tospec/specs/AUTH/spec.md` exists answers yes on Windows and
|
|
45
|
+
* default macOS and no on Linux, so the same delta merged into `auth` on one
|
|
46
|
+
* and archived a second capability `AUTH` on the other. An existing
|
|
47
|
+
* capability keeps the name it has — the rule binds only the name a change is
|
|
48
|
+
* about to create.
|
|
49
|
+
*/
|
|
50
|
+
function capabilityNameIssues(entryPath, mainSpecNames) {
|
|
51
|
+
const capability = entryPath.split('/')[0];
|
|
52
|
+
if (mainSpecNames.includes(capability))
|
|
53
|
+
return [];
|
|
54
|
+
const folded = capability.toLowerCase();
|
|
55
|
+
const clash = mainSpecNames.find((name) => name.toLowerCase() === folded);
|
|
56
|
+
if (clash !== undefined) {
|
|
57
|
+
return [
|
|
58
|
+
{
|
|
59
|
+
level: 'ERROR',
|
|
60
|
+
path: entryPath,
|
|
61
|
+
message: `Capability folder "${capability}" differs only in case from the existing main spec tospec/specs/${clash}/. A case-insensitive file system (Windows, default macOS) merges it into ${clash}, a case-sensitive one archives it as a second capability, so the result depends on the OS. Rename the folder to specs/${clash}/.`,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
const fault = portableNameFault(capability);
|
|
66
|
+
if (fault !== undefined) {
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
level: 'ERROR',
|
|
70
|
+
path: entryPath,
|
|
71
|
+
message: `Capability folder "${capability}" ${fault}. Archive creates tospec/specs/${capability}/ from this name, and a repo holding it cannot be checked out on Windows. Rename the folder.`,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
// WARNING, not ERROR: the name works on every OS, and a project may have
|
|
76
|
+
// its own convention. It is raised now because archive turns the folder name
|
|
77
|
+
// into the main spec's name, and renaming a capability later is a change of
|
|
78
|
+
// its own.
|
|
79
|
+
if (!isUnicodeKebabName(capability)) {
|
|
80
|
+
return [
|
|
81
|
+
{
|
|
82
|
+
level: 'WARNING',
|
|
83
|
+
path: entryPath,
|
|
84
|
+
message: `Capability folder "${capability}" ${UNICODE_KEBAB_NAME_DESCRIPTION}. Archive turns it into the name of a new main spec, tospec/specs/${capability}/, so rename the folder now if it should follow the convention.`,
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
/** The `specs/spec.md` ERROR. */
|
|
91
|
+
const ROOT_SPEC_DELTA_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.';
|
|
21
92
|
/**
|
|
22
93
|
* Merge notices the archive dry run forwards as validation warnings: a REMOVED
|
|
23
94
|
* block naming something the target spec does not have, so archiving deletes
|
|
24
95
|
* nothing. Matched by code, not message text, so rewording a notice cannot
|
|
25
96
|
* switch the check off.
|
|
26
97
|
*/
|
|
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)/;
|
|
98
|
+
const REMOVED_NOOP_NOTICE_CODES = new Set(['archive_removed_requirement_absent']);
|
|
39
99
|
/**
|
|
40
100
|
* A name the template shipped and nobody replaced: `[name]`, `[old name]`,
|
|
41
101
|
* `[scenario name]`. Bracketed end to end, which is how every placeholder in
|
|
@@ -50,6 +110,108 @@ const STRUCTURAL_MERGE_FAILURE = /( validation failed - |^Delta parsing found no
|
|
|
50
110
|
function isTemplatePlaceholder(name) {
|
|
51
111
|
return /^\[.*\]$/.test(name.trim());
|
|
52
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Names the earlier spelling when a duplicate only folds onto it: without it,
|
|
115
|
+
* "Duplicate requirement" over two visibly different headers reads as a bug.
|
|
116
|
+
*/
|
|
117
|
+
function foldedDuplicateNote(earlier, name) {
|
|
118
|
+
return earlier === name ? '' : ` (differs only in case or spacing from "${earlier}")`;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The in-progress changes whose ADDED delta creates `capability`, or, given
|
|
122
|
+
* `requirement`, that requirement in it. Matched exactly, as the merge
|
|
123
|
+
* matches: a folded match named a change whose archive still left the delta
|
|
124
|
+
* refused, and `blockedBy` promised the opposite. That case is
|
|
125
|
+
* `findHeaderMismatches`'s.
|
|
126
|
+
*/
|
|
127
|
+
function addingChanges(sisters, capability, requirement) {
|
|
128
|
+
const exact = requirement === undefined ? undefined : normalizeRequirementName(requirement);
|
|
129
|
+
return sisters
|
|
130
|
+
.filter((s) => s.capability === capability &&
|
|
131
|
+
(exact === undefined
|
|
132
|
+
? s.plan.added.length > 0
|
|
133
|
+
: s.plan.added.some((block) => normalizeRequirementName(block.name) === exact)))
|
|
134
|
+
.map((s) => s.change);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The sentence naming the change(s) that add what the delta needs, or
|
|
138
|
+
* undefined when none does. Without it the refusal reads as a delta written
|
|
139
|
+
* against the wrong capability or requirement, when the delta is right and
|
|
140
|
+
* only the archive order is.
|
|
141
|
+
*/
|
|
142
|
+
function addingChangeHint(adders, what) {
|
|
143
|
+
if (adders.length === 0)
|
|
144
|
+
return undefined;
|
|
145
|
+
if (adders.length === 1)
|
|
146
|
+
return `Archive '${adders[0]}' first: it adds this ${what}.`;
|
|
147
|
+
return `Archive ${adders.map((name) => `'${name}'`).join(', ')} first: each adds this ${what}.`;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The requirement names of `capability`'s main spec: [] when it has none,
|
|
151
|
+
* undefined when it exists but cannot be read. The header-mismatch check
|
|
152
|
+
* needs them and stays silent on undefined, since the scenario-loss check
|
|
153
|
+
* already reports an unreadable main spec.
|
|
154
|
+
*/
|
|
155
|
+
async function readMainRequirementNames(mainSpecsDir, capability) {
|
|
156
|
+
try {
|
|
157
|
+
const content = await fs.readFile(path.join(mainSpecsDir, capability, 'spec.md'), 'utf-8');
|
|
158
|
+
return extractRequirementsSection(content).bodyBlocks.map((block) => normalizeRequirementName(block.name));
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return isMissingPathError(error) ? [] : undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The edit that writes `suggestedHeader` into the delta. A RENAMED names its
|
|
166
|
+
* requirement on the `- FROM:` bullet, and "change the header" sent the
|
|
167
|
+
* author to a `### Requirement:` line the entry does not have.
|
|
168
|
+
*/
|
|
169
|
+
export function headerCorrectionClause(operation, suggestedHeader) {
|
|
170
|
+
const target = operation === 'RENAMED' ? 'the FROM line' : 'the header';
|
|
171
|
+
return `change ${target} to "### Requirement: ${suggestedHeader}"`;
|
|
172
|
+
}
|
|
173
|
+
/** The `requirement_header_mismatch` ERROR's message. */
|
|
174
|
+
function headerMismatchMessage({ change, own, suggestedHeader }) {
|
|
175
|
+
const at = own.line === undefined ? '' : ` (line ${own.line})`;
|
|
176
|
+
return `${own.operation} "${own.requirement}"${at} differs only in case or spacing from "${suggestedHeader}", which in-progress change '${change}' adds. Archive matches requirement names exactly, so archiving '${change}' first does not resolve it: ${headerCorrectionClause(own.operation, suggestedHeader)}.`;
|
|
177
|
+
}
|
|
178
|
+
/** True only for a main spec that is genuinely absent, the case archive refuses a MODIFIED, REMOVED or RENAMED on. */
|
|
179
|
+
async function isMissingMainSpec(mainSpecsDir, capability) {
|
|
180
|
+
try {
|
|
181
|
+
await fs.access(path.join(mainSpecsDir, capability, 'spec.md'));
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
return isMissingPathError(error);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The older change first: both sides read the same two keys, so each tells
|
|
190
|
+
* its author the same order. Equal or unreadable keys give no order at all.
|
|
191
|
+
*/
|
|
192
|
+
function compareChangeAge(own, sister) {
|
|
193
|
+
if (own === undefined || sister === undefined || own === sister)
|
|
194
|
+
return 'unknown';
|
|
195
|
+
return own < sister ? 'this-first' : 'sister-first';
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The advice closing a sister-change WARNING. Each change's validate writes
|
|
199
|
+
* it from its own side, so the two must say one thing: advising "archive the
|
|
200
|
+
* other one first" on both, as this did, left an agent no order to follow.
|
|
201
|
+
* A sister ADDED fixes the order by itself, since a rewrite needs the
|
|
202
|
+
* requirement to exist; two rewrites are ordered by creation, the older first.
|
|
203
|
+
*/
|
|
204
|
+
function sisterArchiveAdvice(sister, operation, order) {
|
|
205
|
+
const syncNote = 'sync may also rewrite the requirement during it.';
|
|
206
|
+
if (order === 'sister-first') {
|
|
207
|
+
const why = operation === 'ADDED' ? '' : ` (it was created before this change)`;
|
|
208
|
+
return `Archive '${sister}' first${why}, then rewrite this delta against the main spec that archive produces: ${syncNote}`;
|
|
209
|
+
}
|
|
210
|
+
if (order === 'this-first') {
|
|
211
|
+
return `This change was created before '${sister}', so archive this change first; '${sister}' must then rewrite its delta against the main spec that archive produces: ${syncNote}`;
|
|
212
|
+
}
|
|
213
|
+
return `Which of the two was created first cannot be told, so decide which archives first, then rewrite the other one's delta against the main spec that archive produces: ${syncNote}`;
|
|
214
|
+
}
|
|
53
215
|
/**
|
|
54
216
|
* One `path` notation across the report. Zod hands back a segment array that
|
|
55
217
|
* `join('.')` renders as `requirements.0.scenarios`, while every hand-written
|
|
@@ -98,10 +260,35 @@ export class Validator {
|
|
|
98
260
|
catch (error) {
|
|
99
261
|
const baseMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
100
262
|
const enriched = this.enrichTopLevelError(specName, baseMessage);
|
|
101
|
-
|
|
263
|
+
const line = error instanceof SpecHeadingLevelError ? { line: error.line } : {};
|
|
264
|
+
issues.push({ level: 'ERROR', path: 'file', ...line, message: enriched });
|
|
102
265
|
}
|
|
103
266
|
return this.createReport(issues);
|
|
104
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Whether archive would refuse to write `rebuilt`, and whose fault that is.
|
|
270
|
+
* Null when it would write it.
|
|
271
|
+
*
|
|
272
|
+
* The verdict counts ERRORs only, whatever this validator's strictness:
|
|
273
|
+
* archive judges the rebuilt spec non-strictly, and this answers for archive.
|
|
274
|
+
*
|
|
275
|
+
* A main spec that already fails is the cause, even if the delta adds its own
|
|
276
|
+
* faults: those the change's validation reports anyway, while the main
|
|
277
|
+
* spec's are reported nowhere a change author looks — and its issue paths,
|
|
278
|
+
* unlike the rebuilt spec's, are the ones `tospec validate <spec>` prints.
|
|
279
|
+
*/
|
|
280
|
+
async diagnoseRebuiltSpec(specName, rebuilt, original) {
|
|
281
|
+
const rebuiltReport = await this.validateSpecContent(specName, rebuilt);
|
|
282
|
+
if (!rebuiltReport.issues.some((i) => i.level === 'ERROR'))
|
|
283
|
+
return null;
|
|
284
|
+
if (original !== undefined) {
|
|
285
|
+
const mainReport = await this.validateSpecContent(specName, original.content);
|
|
286
|
+
if (mainReport.issues.some((i) => i.level === 'ERROR')) {
|
|
287
|
+
return { source: 'main', specPath: original.path, issues: mainReport.issues };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { source: 'delta', issues: rebuiltReport.issues };
|
|
291
|
+
}
|
|
105
292
|
/**
|
|
106
293
|
* Validate delta-formatted spec files under a change directory: at least one
|
|
107
294
|
* delta overall, ADDED/MODIFIED requirements carrying SHALL/MUST and a
|
|
@@ -115,9 +302,33 @@ export class Validator {
|
|
|
115
302
|
* `archivePreflight` is opt-out because the dry run rebuilds every updated
|
|
116
303
|
* spec. For `tospec validate` that is the point; for `archive` it is
|
|
117
304
|
* duplication, since the real merge follows moments later.
|
|
305
|
+
*
|
|
306
|
+
* `requireDelta: false` is for a change whose schema demands no delta. Every
|
|
307
|
+
* other check still runs, because archive merges each
|
|
308
|
+
* specs/<capability>/spec.md whatever the schema says. Dropped: the "at least
|
|
309
|
+
* one delta" ERROR, and the specs/spec.md ERROR for a file with no delta
|
|
310
|
+
* header, which such a schema leaves as notes, as at the rest of the root.
|
|
311
|
+
*
|
|
312
|
+
* `sisterChanges: false` drops the check against other in-progress changes'
|
|
313
|
+
* deltas. Archive turns it off: it runs its own check, in the direction the
|
|
314
|
+
* archive decides (this change goes first), where this one would advise
|
|
315
|
+
* archiving the other change first.
|
|
118
316
|
*/
|
|
119
317
|
async validateChangeDeltaSpecs(changeDir, mainSpecsDir, options = {}) {
|
|
120
318
|
const archivePreflight = options.archivePreflight ?? true;
|
|
319
|
+
const requireDelta = options.requireDelta ?? true;
|
|
320
|
+
const checkSisterChanges = options.sisterChanges ?? true;
|
|
321
|
+
let sisters;
|
|
322
|
+
// Per change dir, read once per run however many requirements two changes share.
|
|
323
|
+
const orderKeys = new Map();
|
|
324
|
+
const orderKeyOf = (dir) => {
|
|
325
|
+
let key = orderKeys.get(dir);
|
|
326
|
+
if (key === undefined) {
|
|
327
|
+
key = readChangeOrderKey(dir);
|
|
328
|
+
orderKeys.set(dir, key);
|
|
329
|
+
}
|
|
330
|
+
return key;
|
|
331
|
+
};
|
|
121
332
|
const issues = [];
|
|
122
333
|
const specsDir = path.join(changeDir, 'specs');
|
|
123
334
|
let totalDeltas = 0;
|
|
@@ -127,20 +338,15 @@ export class Validator {
|
|
|
127
338
|
try {
|
|
128
339
|
// The walk below is recursive in order to *reject* anything outside
|
|
129
340
|
// specs/<capability>/spec.md: the layout is fixed at exactly one directory
|
|
130
|
-
// level
|
|
341
|
+
// level.
|
|
131
342
|
// findSpecUpdates never went recursive, so a misplaced file that validates
|
|
132
343
|
// clean is a file that archives into nothing.
|
|
133
344
|
const rootSpecPath = path.join(specsDir, 'spec.md');
|
|
134
345
|
// Only a regular file counts — a *directory* named spec.md is a capability
|
|
135
|
-
// folder like any other.
|
|
136
|
-
|
|
137
|
-
if (rootSpecStat?.isFile() === true) {
|
|
346
|
+
// folder like any other. Picked by the rule the archive backstop reads.
|
|
347
|
+
if ((await findRootSpecDelta(specsDir, { requireDelta })) !== undefined) {
|
|
138
348
|
hasMisplacedSpec = true;
|
|
139
|
-
issues.push({
|
|
140
|
-
level: 'ERROR',
|
|
141
|
-
path: 'spec.md',
|
|
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.',
|
|
143
|
-
});
|
|
349
|
+
issues.push({ level: 'ERROR', path: 'spec.md', message: ROOT_SPEC_DELTA_MESSAGE });
|
|
144
350
|
}
|
|
145
351
|
// Report each misplaced file once and drop it from the delta pass below —
|
|
146
352
|
// validating it as a real delta would contradict the error that just said
|
|
@@ -151,12 +357,14 @@ export class Validator {
|
|
|
151
357
|
continue; // already reported above
|
|
152
358
|
const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile));
|
|
153
359
|
const segments = entryPath.split('/');
|
|
154
|
-
// A
|
|
155
|
-
//
|
|
156
|
-
// two must keep agreeing.
|
|
360
|
+
// A root .md that carries a delta header is reported by
|
|
361
|
+
// rootDeltaFileIssues instead, from the one list archive warns about.
|
|
157
362
|
if (segments.length === 1)
|
|
158
363
|
continue;
|
|
159
|
-
|
|
364
|
+
// Judged by specLayoutFault, the rule apply's apply_specs_misplaced
|
|
365
|
+
// warning reads, so apply cannot call ready what this refuses.
|
|
366
|
+
const fault = specLayoutFault(entryPath);
|
|
367
|
+
if (fault === 'nested') {
|
|
160
368
|
hasMisplacedSpec = true;
|
|
161
369
|
// The fix is mechanical, so name the flattened path outright:
|
|
162
370
|
// specs/platform/session/spec.md -> specs/platform-session/spec.md.
|
|
@@ -171,7 +379,7 @@ export class Validator {
|
|
|
171
379
|
// fast-glob (which computes artifact status) skips dot-directories while
|
|
172
380
|
// the merge path's readdir does not, so a dot capability would archive
|
|
173
381
|
// without ever showing done.
|
|
174
|
-
if (
|
|
382
|
+
if (fault === 'dot-folder') {
|
|
175
383
|
hasMisplacedSpec = true;
|
|
176
384
|
issues.push({
|
|
177
385
|
level: 'ERROR',
|
|
@@ -183,7 +391,7 @@ export class Validator {
|
|
|
183
391
|
// Right folder, wrong basename. findSpecFiles filters on
|
|
184
392
|
// basename === 'spec.md', so the merge path silently drops the file;
|
|
185
393
|
// findAllMarkdownFiles is what makes it visible here at all.
|
|
186
|
-
if (
|
|
394
|
+
if (fault === 'misnamed') {
|
|
187
395
|
hasMisplacedSpec = true;
|
|
188
396
|
issues.push({
|
|
189
397
|
level: 'ERROR',
|
|
@@ -194,8 +402,12 @@ export class Validator {
|
|
|
194
402
|
}
|
|
195
403
|
specFiles.push(specFile);
|
|
196
404
|
}
|
|
405
|
+
const mainSpecNames = await listMainSpecNames(mainSpecsDir);
|
|
197
406
|
for (const specFile of specFiles) {
|
|
198
407
|
const entryPath = FileSystemUtils.toPosixPath(path.relative(specsDir, specFile));
|
|
408
|
+
if (mainSpecNames !== undefined) {
|
|
409
|
+
issues.push(...capabilityNameIssues(entryPath, mainSpecNames));
|
|
410
|
+
}
|
|
199
411
|
let content;
|
|
200
412
|
try {
|
|
201
413
|
content = await fs.readFile(specFile, 'utf-8');
|
|
@@ -230,8 +442,9 @@ export class Validator {
|
|
|
230
442
|
// archive creates, so the main-spec Purpose rules must run before the
|
|
231
443
|
// copy — otherwise the change archives and only then fails
|
|
232
444
|
// `validate --all --strict`, from a source now under changes/archive/.
|
|
233
|
-
//
|
|
234
|
-
|
|
445
|
+
// An existing capability owns its Purpose, so there only the drop is
|
|
446
|
+
// reported.
|
|
447
|
+
issues.push(...(await this.collectDeltaPurposeIssues(content, plan, entryPath, mainSpecsDir)));
|
|
235
448
|
// Two identical delta headers are an editing accident, and combining
|
|
236
449
|
// them would guess which one the author meant.
|
|
237
450
|
for (const duplicate of plan.duplicateSections) {
|
|
@@ -242,6 +455,29 @@ export class Validator {
|
|
|
242
455
|
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.`,
|
|
243
456
|
});
|
|
244
457
|
}
|
|
458
|
+
// WARNING, not ERROR: a requirement outside every delta section is
|
|
459
|
+
// usually a misplaced one, but the scan cannot tell it from prose that
|
|
460
|
+
// happens to quote a header, and archive merges nothing from it either way.
|
|
461
|
+
for (const orphan of plan.orphanRequirements) {
|
|
462
|
+
const where = orphan.section === null ? 'before the first "##" section' : `under "## ${orphan.section}", which is not a delta section`;
|
|
463
|
+
issues.push({
|
|
464
|
+
level: 'WARNING',
|
|
465
|
+
path: entryPath,
|
|
466
|
+
line: orphan.line,
|
|
467
|
+
message: `Requirement "${orphan.name}" (line ${orphan.line}) sits ${where}, so it is neither validated nor merged on archive. Move it under "## ADDED Requirements" or "## MODIFIED Requirements", or fix the section header's spelling.`,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
// Only near misses of a delta title: archive also leaves `## Notes`
|
|
471
|
+
// unmerged, but that is a heading the author meant. WARNING for the same
|
|
472
|
+
// reason as above: the title may sit close to a delta title on purpose.
|
|
473
|
+
for (const section of plan.misspelledSections) {
|
|
474
|
+
issues.push({
|
|
475
|
+
level: 'WARNING',
|
|
476
|
+
path: entryPath,
|
|
477
|
+
line: section.line,
|
|
478
|
+
message: `Section "${section.title}" (line ${section.line}) is not a delta section, so nothing under it is validated or merged on archive. Rename it to "${section.suggestion}".`,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
245
481
|
// A present section that yields no entry is a grammar miss, not an
|
|
246
482
|
// empty section: the author wrote something there. Left unreported, the
|
|
247
483
|
// MODIFIED block keeps failing the scenario-loss check with no sign that
|
|
@@ -300,8 +536,13 @@ export class Validator {
|
|
|
300
536
|
});
|
|
301
537
|
continue;
|
|
302
538
|
}
|
|
303
|
-
|
|
304
|
-
|
|
539
|
+
const earlier = findFoldedMatch(seen, key);
|
|
540
|
+
if (earlier !== undefined) {
|
|
541
|
+
issues.push({
|
|
542
|
+
level: 'ERROR',
|
|
543
|
+
path: entryPath,
|
|
544
|
+
message: `Duplicate requirement in ${section}: "${block.name}"${foldedDuplicateNote(earlier, key)}`,
|
|
545
|
+
});
|
|
305
546
|
}
|
|
306
547
|
else {
|
|
307
548
|
seen.add(key);
|
|
@@ -314,7 +555,6 @@ export class Validator {
|
|
|
314
555
|
// WARNING, not ERROR: the keyword check is English-only, so at
|
|
315
556
|
// ERROR it blocked requirements written in another language.
|
|
316
557
|
// `--strict` still refuses it.
|
|
317
|
-
// tospec/decisions/20260814_134116-shall-must-missing-is-a-warning.md
|
|
318
558
|
issues.push({
|
|
319
559
|
level: 'WARNING',
|
|
320
560
|
path: entryPath,
|
|
@@ -330,7 +570,6 @@ export class Validator {
|
|
|
330
570
|
// never fire for the one delta shape where it is always true. The
|
|
331
571
|
// declaration removes nothing by itself (the MODIFIED omission is the
|
|
332
572
|
// mechanism), so such a change archived with the scenario intact.
|
|
333
|
-
// tospec/decisions/20260918_230803-removed-scenarios-is-the-declared-path.md
|
|
334
573
|
// Dropped from the plan once reported: a placeholder entry would
|
|
335
574
|
// otherwise also fail the "matched nothing" check, naming one mistake
|
|
336
575
|
// twice.
|
|
@@ -355,7 +594,7 @@ export class Validator {
|
|
|
355
594
|
}
|
|
356
595
|
// entryPath is always "<capability>/spec.md" here — misplaced and
|
|
357
596
|
// dot-prefixed files were reported and dropped above.
|
|
358
|
-
issues.push(...(await this.collectDroppedScenarioIssues(mainSpecsDir, entryPath.split('/')[0], plan.modified, entryPath, renamedToFrom, removedScenarios)));
|
|
597
|
+
issues.push(...(await this.collectDroppedScenarioIssues(mainSpecsDir, entryPath.split('/')[0], plan.modified, entryPath, renamedToFrom, removedScenarios, path.basename(changeDir))));
|
|
359
598
|
}
|
|
360
599
|
// The template requires Reason and Migration on every REMOVED entry.
|
|
361
600
|
for (const entry of plan.removedEntries) {
|
|
@@ -370,8 +609,13 @@ export class Validator {
|
|
|
370
609
|
});
|
|
371
610
|
continue;
|
|
372
611
|
}
|
|
373
|
-
|
|
374
|
-
|
|
612
|
+
const earlierRemoved = findFoldedMatch(removedNames, key);
|
|
613
|
+
if (earlierRemoved !== undefined) {
|
|
614
|
+
issues.push({
|
|
615
|
+
level: 'ERROR',
|
|
616
|
+
path: entryPath,
|
|
617
|
+
message: `Duplicate requirement in REMOVED: "${entry.name}"${foldedDuplicateNote(earlierRemoved, key)}`,
|
|
618
|
+
});
|
|
375
619
|
}
|
|
376
620
|
else {
|
|
377
621
|
removedNames.add(key);
|
|
@@ -420,14 +664,24 @@ export class Validator {
|
|
|
420
664
|
});
|
|
421
665
|
continue;
|
|
422
666
|
}
|
|
423
|
-
|
|
424
|
-
|
|
667
|
+
const earlierFrom = findFoldedMatch(renamedFrom, fromKey);
|
|
668
|
+
if (earlierFrom !== undefined) {
|
|
669
|
+
issues.push({
|
|
670
|
+
level: 'ERROR',
|
|
671
|
+
path: entryPath,
|
|
672
|
+
message: `Duplicate FROM in RENAMED: "${from}"${foldedDuplicateNote(earlierFrom, fromKey)}`,
|
|
673
|
+
});
|
|
425
674
|
}
|
|
426
675
|
else {
|
|
427
676
|
renamedFrom.add(fromKey);
|
|
428
677
|
}
|
|
429
|
-
|
|
430
|
-
|
|
678
|
+
const earlierTo = findFoldedMatch(renamedTo, toKey);
|
|
679
|
+
if (earlierTo !== undefined) {
|
|
680
|
+
issues.push({
|
|
681
|
+
level: 'ERROR',
|
|
682
|
+
path: entryPath,
|
|
683
|
+
message: `Duplicate TO in RENAMED: "${to}"${foldedDuplicateNote(earlierTo, toKey)}`,
|
|
684
|
+
});
|
|
431
685
|
}
|
|
432
686
|
else {
|
|
433
687
|
renamedTo.add(toKey);
|
|
@@ -452,10 +706,75 @@ export class Validator {
|
|
|
452
706
|
if (modifiedNames.has(fromKey)) {
|
|
453
707
|
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED references old name from RENAMED. Use new header for "${to}"` });
|
|
454
708
|
}
|
|
455
|
-
|
|
709
|
+
// Folded, as the merge checks it: a TO that differs from an ADDED name
|
|
710
|
+
// only in case or spacing would land two requirements no reader can
|
|
711
|
+
// tell apart.
|
|
712
|
+
if (findFoldedMatch(addedNames, toKey) !== undefined) {
|
|
456
713
|
issues.push({ level: 'ERROR', path: entryPath, message: `RENAMED TO collides with ADDED for "${to}"` });
|
|
457
714
|
}
|
|
458
715
|
}
|
|
716
|
+
// A MODIFIED replaces the whole main-spec block with the text it was
|
|
717
|
+
// written against, so two changes rewriting one requirement lose the
|
|
718
|
+
// edit of whichever archives first. When only the prose differs, the
|
|
719
|
+
// scenario and bullet checks above see nothing. Comparing the prose to
|
|
720
|
+
// the main spec would flag every deliberate rewrite, and a recorded
|
|
721
|
+
// base would be state tospec does not keep; the other change's delta
|
|
722
|
+
// is the evidence that is on disk. Read once per run, and only when
|
|
723
|
+
// this delta rewrites something.
|
|
724
|
+
const rewrites = listDeltaTouches(plan, ['MODIFIED', 'REMOVED', 'RENAMED']);
|
|
725
|
+
if (mainSpecsDir !== undefined && checkSisterChanges && rewrites.length > 0) {
|
|
726
|
+
const changesDir = path.join(path.dirname(mainSpecsDir), 'changes');
|
|
727
|
+
sisters ??= await listSisterChangeDeltas(changesDir, path.basename(changeDir));
|
|
728
|
+
const capability = entryPath.split('/')[0];
|
|
729
|
+
// With no main spec, the archive-blocker WARNING below already names
|
|
730
|
+
// every change that ADDs this capability, so a sister ADDED here
|
|
731
|
+
// would report one cause twice. Only when that blocker is raised:
|
|
732
|
+
// without the preflight, this is the only hint.
|
|
733
|
+
const blockerNamesAdders = archivePreflight &&
|
|
734
|
+
(plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) &&
|
|
735
|
+
(await isMissingMainSpec(mainSpecsDir, capability));
|
|
736
|
+
const sisterOperations = blockerNamesAdders
|
|
737
|
+
? ['MODIFIED', 'REMOVED', 'RENAMED']
|
|
738
|
+
: ['ADDED', 'MODIFIED', 'REMOVED', 'RENAMED'];
|
|
739
|
+
const mainNames = await readMainRequirementNames(mainSpecsDir, capability);
|
|
740
|
+
const mismatches = mainNames === undefined ? [] : findHeaderMismatches(capability, rewrites, sisters, mainNames);
|
|
741
|
+
for (const mismatch of mismatches) {
|
|
742
|
+
// No `blockedBy`: no archive order makes this delta apply, so
|
|
743
|
+
// naming the sister would tell an agent to wait on a fix that
|
|
744
|
+
// never comes.
|
|
745
|
+
issues.push({
|
|
746
|
+
level: 'ERROR',
|
|
747
|
+
path: entryPath,
|
|
748
|
+
...(mismatch.own.line === undefined ? {} : { line: mismatch.own.line }),
|
|
749
|
+
code: REQUIREMENT_HEADER_MISMATCH_CODE,
|
|
750
|
+
suggestedHeader: mismatch.suggestedHeader,
|
|
751
|
+
message: headerMismatchMessage(mismatch),
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
for (const { change, own, sister } of findSharedRequirements(capability, rewrites, sisters, sisterOperations)) {
|
|
755
|
+
// The pair the mismatch ERROR names. Its overlap advice ("archive
|
|
756
|
+
// the sister first") holds only once the header is fixed, and the
|
|
757
|
+
// re-run after that fix reports it, with its `blockedBy`.
|
|
758
|
+
if (sister.operation === 'ADDED' && mismatches.some((m) => m.change === change && m.own === own))
|
|
759
|
+
continue;
|
|
760
|
+
const at = own.line === undefined ? '' : ` (line ${own.line})`;
|
|
761
|
+
const order = sister.operation === 'ADDED'
|
|
762
|
+
? 'sister-first'
|
|
763
|
+
: compareChangeAge(await orderKeyOf(changeDir), await orderKeyOf(path.join(changesDir, change)));
|
|
764
|
+
// `blockedBy` only on the side told to wait. The older side is
|
|
765
|
+
// told to archive first and the unordered pair to decide: neither
|
|
766
|
+
// waits on anyone, and naming the sister there would tell an
|
|
767
|
+
// agent to hold this change back behind it.
|
|
768
|
+
issues.push({
|
|
769
|
+
level: 'WARNING',
|
|
770
|
+
path: entryPath,
|
|
771
|
+
...(own.line === undefined ? {} : { line: own.line }),
|
|
772
|
+
code: SISTER_CHANGE_OVERLAP_CODE,
|
|
773
|
+
...(order === 'sister-first' ? { blockedBy: [change] } : {}),
|
|
774
|
+
message: `${own.operation} "${own.requirement}"${at}: in-progress change '${change}' also acts on this requirement (${sister.operation}), and the change archived second overwrites the first one's edit with the text it was written against. ${sisterArchiveAdvice(change, sister.operation, order)}`,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
}
|
|
459
778
|
}
|
|
460
779
|
}
|
|
461
780
|
catch (error) {
|
|
@@ -483,12 +802,22 @@ export class Validator {
|
|
|
483
802
|
// the main spec's scenarios. None asks whether the main spec can supply the
|
|
484
803
|
// target the delta acts on — the merge's own preconditions.
|
|
485
804
|
if (mainSpecsDir !== undefined && archivePreflight) {
|
|
486
|
-
|
|
805
|
+
// A header mismatch is not a structural fault, so it does not vouch for
|
|
806
|
+
// a rebuilt-spec failure as already reported.
|
|
807
|
+
const structuralErrorPaths = new Set(issues.filter((i) => i.level === 'ERROR' && i.code !== REQUIREMENT_HEADER_MISMATCH_CODE).map((i) => i.path));
|
|
808
|
+
const findings = await this.findArchiveBlockers(changeDir, mainSpecsDir, structuralErrorPaths);
|
|
809
|
+
// Coded here, not in the dry run: apply reads the same findings and
|
|
810
|
+
// reports them under its own status code.
|
|
811
|
+
issues.push(...findings.map((finding) => finding.refuses ? { ...finding.issue, code: ARCHIVE_REFUSAL_CODE } : finding.issue));
|
|
487
812
|
}
|
|
488
813
|
// A misplaced-spec error already names the file and the fix; "No deltas
|
|
489
814
|
// found" on top would contradict it.
|
|
490
|
-
if (totalDeltas === 0 && !hasMisplacedSpec) {
|
|
491
|
-
issues.push({
|
|
815
|
+
if (requireDelta && totalDeltas === 0 && !hasMisplacedSpec) {
|
|
816
|
+
issues.push({
|
|
817
|
+
level: 'ERROR',
|
|
818
|
+
path: 'file',
|
|
819
|
+
message: `${VALIDATION_MESSAGES.CHANGE_NO_DELTAS}. ${noDeltasGuide(path.basename(changeDir))}`,
|
|
820
|
+
});
|
|
492
821
|
}
|
|
493
822
|
// The ceiling `ChangeSchema.deltas.max()` declares. WARNING because the rule
|
|
494
823
|
// is advice about change size, not a correctness claim; `--strict` is what
|
|
@@ -583,7 +912,6 @@ export class Validator {
|
|
|
583
912
|
// WARNING, not ERROR: the keyword check is English-only, so at ERROR it
|
|
584
913
|
// blocked requirements written in another language. `--strict` still
|
|
585
914
|
// refuses it.
|
|
586
|
-
// tospec/decisions/20260814_134116-shall-must-missing-is-a-warning.md
|
|
587
915
|
if (!requirementText || !containsShallOrMust(requirementText)) {
|
|
588
916
|
issues.push({
|
|
589
917
|
level: 'WARNING',
|
|
@@ -663,18 +991,33 @@ export class Validator {
|
|
|
663
991
|
/**
|
|
664
992
|
* The main spec's two Purpose rules, applied to a delta that is about to
|
|
665
993
|
* create that main spec. Placeholder before brevity, as on the spec path.
|
|
994
|
+
* For an existing capability, only that a delta Purpose will be dropped.
|
|
666
995
|
*
|
|
667
996
|
* `mainSpecsDir` absent means new capabilities cannot be told from existing
|
|
668
997
|
* ones, so nothing is reported.
|
|
669
998
|
*/
|
|
670
|
-
async collectDeltaPurposeIssues(content, entryPath, mainSpecsDir) {
|
|
999
|
+
async collectDeltaPurposeIssues(content, plan, entryPath, mainSpecsDir) {
|
|
671
1000
|
if (mainSpecsDir === undefined)
|
|
672
1001
|
return [];
|
|
673
1002
|
const purpose = extractDeltaPurpose(content);
|
|
674
1003
|
const capability = entryPath.split('/')[0];
|
|
1004
|
+
const mainSpecPath = path.join(mainSpecsDir, capability, 'spec.md');
|
|
675
1005
|
try {
|
|
676
|
-
await fs.access(
|
|
677
|
-
|
|
1006
|
+
await fs.access(mainSpecPath);
|
|
1007
|
+
// Capability exists: its own spec owns the Purpose, and archive drops the
|
|
1008
|
+
// delta's with `archive_delta_purpose_ignored`. Said here too, worded as
|
|
1009
|
+
// that notice is, because archive is too late: the author who wrote it
|
|
1010
|
+
// meant to change the Purpose, and by then the change is archived.
|
|
1011
|
+
if (!purpose)
|
|
1012
|
+
return [];
|
|
1013
|
+
const onDisk = await resolveOnDiskSpecName(mainSpecPath, capability);
|
|
1014
|
+
return [
|
|
1015
|
+
{
|
|
1016
|
+
level: 'WARNING',
|
|
1017
|
+
path: entryPath,
|
|
1018
|
+
message: `Delta "## Purpose" will be ignored on archive; ${onDisk} already has a main spec and owns its Purpose. Edit tospec/specs/${onDisk}/spec.md directly if it needs changing, and remove the Purpose from this delta.`,
|
|
1019
|
+
},
|
|
1020
|
+
];
|
|
678
1021
|
}
|
|
679
1022
|
catch (error) {
|
|
680
1023
|
// Only a genuinely absent spec means "new capability"; guessing "new"
|
|
@@ -682,6 +1025,13 @@ export class Validator {
|
|
|
682
1025
|
if (!isMissingPathError(error))
|
|
683
1026
|
return [];
|
|
684
1027
|
}
|
|
1028
|
+
// Archive refuses a MODIFIED, REMOVED or RENAMED on a capability with no
|
|
1029
|
+
// main spec before it reads the Purpose, and the archive-blocker WARNING
|
|
1030
|
+
// says so. Advice on the Purpose here sent authors to add one, which turns
|
|
1031
|
+
// into "will be ignored on archive" once the change that adds the
|
|
1032
|
+
// capability archives first — the usual way out of this state.
|
|
1033
|
+
if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0)
|
|
1034
|
+
return [];
|
|
685
1035
|
// WARNING, not ERROR: the schema calls the Purpose mandatory, but archive
|
|
686
1036
|
// still completes with a placeholder, so this is advice on where the text
|
|
687
1037
|
// belongs rather than a merge precondition. Reported here because archive
|
|
@@ -729,7 +1079,9 @@ export class Validator {
|
|
|
729
1079
|
* author means to drop, so omitting it from the MODIFIED block stops being
|
|
730
1080
|
* the accident this check exists to catch.
|
|
731
1081
|
*/
|
|
732
|
-
declaredRemovals
|
|
1082
|
+
declaredRemovals,
|
|
1083
|
+
/** Named in the `show --diff` hint, so the hint runs as written. */
|
|
1084
|
+
changeName) {
|
|
733
1085
|
let content;
|
|
734
1086
|
try {
|
|
735
1087
|
content = await fs.readFile(path.join(mainSpecsDir, capability, 'spec.md'), 'utf-8');
|
|
@@ -838,19 +1190,24 @@ export class Validator {
|
|
|
838
1190
|
message: `MODIFIED "${block.name}"${target} drops bullet(s) the current spec still has: ${dropped
|
|
839
1191
|
.map((line) => `"${line}"`)
|
|
840
1192
|
.join(', ')}. Confirm this is an intentional rewrite and not a block written against an older ` +
|
|
841
|
-
`copy of the requirement — run tospec show
|
|
1193
|
+
`copy of the requirement — run tospec show "${changeName}" --diff to see the full comparison.`,
|
|
842
1194
|
});
|
|
843
1195
|
}
|
|
844
1196
|
}
|
|
845
1197
|
// A declaration that matched nothing: the author believes a scenario was
|
|
846
1198
|
// removed, the spec still has it, and every command reports success. ERROR
|
|
847
1199
|
// because there is no reading of it that is correct.
|
|
1200
|
+
const renamed = [...renamedToFrom].map(([to, from]) => ({ from, to }));
|
|
848
1201
|
for (const entry of unmatchedRemovals) {
|
|
1202
|
+
const renameHint = postRenameRemovalHint(entry, renamed);
|
|
849
1203
|
issues.push({
|
|
850
1204
|
level: 'ERROR',
|
|
851
1205
|
path: entryPath,
|
|
852
1206
|
line: entry.startLine,
|
|
853
|
-
message: `REMOVED Scenarios declares "${entry.scenario}" under requirement "${entry.requirement}", but no MODIFIED block for that requirement drops that scenario.
|
|
1207
|
+
message: `REMOVED Scenarios declares "${entry.scenario}" under requirement "${entry.requirement}", but no MODIFIED block for that requirement drops that scenario. ` +
|
|
1208
|
+
(renameHint === undefined
|
|
1209
|
+
? `Check both names against tospec/specs/${capability}/spec.md — a declaration that matches nothing removes nothing.`
|
|
1210
|
+
: `${renameHint}.`),
|
|
854
1211
|
});
|
|
855
1212
|
}
|
|
856
1213
|
return issues;
|
|
@@ -867,13 +1224,13 @@ export class Validator {
|
|
|
867
1224
|
* is missing is also what a change modifying a sibling's unarchived
|
|
868
1225
|
* requirement looks like. INFO was wrong because `--strict` ignores it, so the
|
|
869
1226
|
* pre-archive gate waved through changes archive would refuse.
|
|
870
|
-
* tospec/decisions/20260916_154000-archive-dry-run-findings-are-warnings.md
|
|
871
1227
|
*/
|
|
872
|
-
async findArchiveBlockers(changeDir, mainSpecsDir) {
|
|
1228
|
+
async findArchiveBlockers(changeDir, mainSpecsDir, structuralErrorPaths) {
|
|
873
1229
|
// Only reaches the generated skeleton's placeholder Purpose, which this dry
|
|
874
1230
|
// run discards with the rest of the rebuilt content.
|
|
875
1231
|
const changeName = path.basename(changeDir);
|
|
876
|
-
const
|
|
1232
|
+
const findings = [];
|
|
1233
|
+
let sisters;
|
|
877
1234
|
let updates;
|
|
878
1235
|
try {
|
|
879
1236
|
updates = await findSpecUpdates(changeDir, mainSpecsDir);
|
|
@@ -883,9 +1240,12 @@ export class Validator {
|
|
|
883
1240
|
// the structural pass walks the same tree and its findings are actionable.
|
|
884
1241
|
return [
|
|
885
1242
|
{
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1243
|
+
refuses: false,
|
|
1244
|
+
issue: {
|
|
1245
|
+
level: 'INFO',
|
|
1246
|
+
path: 'specs',
|
|
1247
|
+
message: `Could not check the merge preconditions: ${error instanceof Error ? error.message : String(error)}`,
|
|
1248
|
+
},
|
|
889
1249
|
},
|
|
890
1250
|
];
|
|
891
1251
|
}
|
|
@@ -895,7 +1255,14 @@ export class Validator {
|
|
|
895
1255
|
const capability = path.basename(path.dirname(update.source));
|
|
896
1256
|
const entryPath = FileSystemUtils.toPosixPath(`${capability}/spec.md`);
|
|
897
1257
|
try {
|
|
898
|
-
|
|
1258
|
+
// The structural pass above already reports the merge's structural
|
|
1259
|
+
// failures in its own, more precise wording, so the merge defers them
|
|
1260
|
+
// and goes on to the preconditions checked nowhere else (a MODIFIED or
|
|
1261
|
+
// RENAMED target the main spec lacks, an ADDED that collides). Throwing
|
|
1262
|
+
// on the first used to hide those until it was fixed, one round trip
|
|
1263
|
+
// per finding; filtering its message afterwards could not bring back
|
|
1264
|
+
// the checks the throw had skipped.
|
|
1265
|
+
const built = await buildUpdatedSpec(update, changeName, { silent: true, deferStructural: true });
|
|
899
1266
|
// The merge would succeed but delete nothing, which is not what the
|
|
900
1267
|
// delta appears to say. A mistyped REMOVED header is the likeliest
|
|
901
1268
|
// cause and the hardest to catch, since nothing visibly happens.
|
|
@@ -905,12 +1272,26 @@ export class Validator {
|
|
|
905
1272
|
for (const notice of built.notices) {
|
|
906
1273
|
if (!REMOVED_NOOP_NOTICE_CODES.has(notice.code))
|
|
907
1274
|
continue;
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1275
|
+
findings.push({
|
|
1276
|
+
refuses: false,
|
|
1277
|
+
issue: {
|
|
1278
|
+
level: 'WARNING',
|
|
1279
|
+
path: entryPath,
|
|
1280
|
+
message: `Archive would accept this delta but remove nothing: ${notice.message}`,
|
|
1281
|
+
},
|
|
912
1282
|
});
|
|
913
1283
|
}
|
|
1284
|
+
// Archive validates the rebuilt spec before writing it, so a main spec
|
|
1285
|
+
// that is already broken refuses every delta aimed at it — while the
|
|
1286
|
+
// checks above, which read only the delta, call the change valid.
|
|
1287
|
+
// A retired capability is deleted, not validated. A deferred merge is
|
|
1288
|
+
// partial, so judging it would report the structural failures again
|
|
1289
|
+
// under positional paths.
|
|
1290
|
+
if (!built.retired && built.structuralFailures.length === 0) {
|
|
1291
|
+
const specName = path.basename(path.dirname(update.target));
|
|
1292
|
+
const blockers = await this.findRebuiltSpecBlockers(specName, entryPath, built, structuralErrorPaths);
|
|
1293
|
+
findings.push(...blockers.map((issue) => ({ refuses: true, issue })));
|
|
1294
|
+
}
|
|
914
1295
|
}
|
|
915
1296
|
catch (error) {
|
|
916
1297
|
// Only the thrown preconditions, which carry no errno. A filesystem
|
|
@@ -918,24 +1299,124 @@ export class Validator {
|
|
|
918
1299
|
// `validate --all` a transient EMFILE would read as a collision.
|
|
919
1300
|
if (error?.code !== undefined)
|
|
920
1301
|
continue;
|
|
921
|
-
|
|
922
|
-
// The
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1302
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
1303
|
+
// The missing capability, or the requirement a MODIFIED or RENAMED
|
|
1304
|
+
// names, may be one a sister change is still adding; then the fix is
|
|
1305
|
+
// archiving that change, not editing this delta. Read once per run and
|
|
1306
|
+
// only for these refusals, the ones it can explain. `blockedBy` comes
|
|
1307
|
+
// from the list the hint is written from, so the field and the
|
|
1308
|
+
// sentence cannot name different changes.
|
|
1309
|
+
let blockedBy = [];
|
|
1310
|
+
let suggestedHeader;
|
|
1311
|
+
let headerCorrection;
|
|
1312
|
+
if (error instanceof RequirementNearMissError) {
|
|
1313
|
+
// A typo against the main spec, whose name is settled, so the field
|
|
1314
|
+
// carries it: the agent fixes this WARNING itself, and reading the
|
|
1315
|
+
// name out of the merge's sentence would break on its rewording.
|
|
1316
|
+
suggestedHeader = error.existing;
|
|
1317
|
+
headerCorrection = { operation: error.operation, source: 'main-spec' };
|
|
1318
|
+
}
|
|
1319
|
+
else if (error instanceof MissingTargetSpecError || error instanceof MissingTargetRequirementError) {
|
|
1320
|
+
sisters ??= await listSisterChangeDeltas(path.join(path.dirname(mainSpecsDir), 'changes'), changeName);
|
|
1321
|
+
const requirement = error instanceof MissingTargetRequirementError ? error.requirement : undefined;
|
|
1322
|
+
// A header the sister ADDs under another spelling is a second cause
|
|
1323
|
+
// no archive order settles, so the order is not the whole cause and
|
|
1324
|
+
// neither the hint nor `blockedBy` may say it is. The mismatch ERROR
|
|
1325
|
+
// reports it; `suggestedHeader` rides here so apply can name the fix.
|
|
1326
|
+
const mismatch = (await this.findDeltaHeaderMismatches(update.source, mainSpecsDir, capability, sisters, error))[0];
|
|
1327
|
+
if (mismatch !== undefined) {
|
|
1328
|
+
suggestedHeader = mismatch.suggestedHeader;
|
|
1329
|
+
headerCorrection = { operation: mismatch.own.operation, source: 'sister-change' };
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
blockedBy = addingChanges(sisters, capability, requirement);
|
|
1333
|
+
const hint = addingChangeHint(blockedBy, requirement === undefined ? 'capability' : 'requirement');
|
|
1334
|
+
if (hint !== undefined)
|
|
1335
|
+
message = appendSentence(message, hint);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
findings.push({
|
|
1339
|
+
refuses: true,
|
|
1340
|
+
issue: {
|
|
1341
|
+
level: 'WARNING',
|
|
1342
|
+
path: entryPath,
|
|
1343
|
+
message: `Archive would refuse this delta: ${message}`,
|
|
1344
|
+
...(blockedBy.length > 0 ? { blockedBy } : {}),
|
|
1345
|
+
...(suggestedHeader === undefined ? {} : { suggestedHeader }),
|
|
1346
|
+
},
|
|
1347
|
+
...(headerCorrection === undefined ? {} : { headerCorrection }),
|
|
935
1348
|
});
|
|
936
1349
|
}
|
|
937
1350
|
}
|
|
938
|
-
return
|
|
1351
|
+
return findings;
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* The header mismatches behind a missing-target refusal: the one requirement
|
|
1355
|
+
* a MODIFIED or RENAMED names, or, for a capability with no main spec, every
|
|
1356
|
+
* rewrite in the delta, since the merge stops before reading any of them.
|
|
1357
|
+
* Through `findHeaderMismatches`, so the refusal and the sister scan's
|
|
1358
|
+
* ERROR cannot disagree about which headers mismatch.
|
|
1359
|
+
*/
|
|
1360
|
+
async findDeltaHeaderMismatches(deltaSource, mainSpecsDir, capability, sisters, error) {
|
|
1361
|
+
const mainNames = await readMainRequirementNames(mainSpecsDir, capability);
|
|
1362
|
+
if (mainNames === undefined)
|
|
1363
|
+
return [];
|
|
1364
|
+
let touches;
|
|
1365
|
+
if (error instanceof MissingTargetRequirementError) {
|
|
1366
|
+
touches = [{ operation: error.operation, requirement: error.requirement }];
|
|
1367
|
+
}
|
|
1368
|
+
else {
|
|
1369
|
+
try {
|
|
1370
|
+
touches = listDeltaTouches(parseDeltaSpec(await fs.readFile(deltaSource, 'utf-8')), ['MODIFIED', 'REMOVED', 'RENAMED']);
|
|
1371
|
+
}
|
|
1372
|
+
catch {
|
|
1373
|
+
return [];
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
return findHeaderMismatches(capability, touches, sisters, mainNames);
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* What archive's merge would refuse in this change, from the dry run above,
|
|
1380
|
+
* for `instructions apply` to report before the work starts: it said ready
|
|
1381
|
+
* for a delta validate already called unarchivable, so the agent learned it
|
|
1382
|
+
* after implementing. One dry run serves both, so they cannot disagree.
|
|
1383
|
+
*
|
|
1384
|
+
* Without the structural pass's paths: apply reports none of its findings,
|
|
1385
|
+
* so a delta fault that resurfaces in the rebuilt spec is the only word apply
|
|
1386
|
+
* gets of it. Suppressed there as already reported, apply would say nothing.
|
|
1387
|
+
*/
|
|
1388
|
+
async findArchiveRefusals(changeDir, mainSpecsDir) {
|
|
1389
|
+
const findings = await this.findArchiveBlockers(changeDir, mainSpecsDir, new Set());
|
|
1390
|
+
return findings
|
|
1391
|
+
.filter((finding) => finding.refuses)
|
|
1392
|
+
.map(({ issue, headerCorrection }) => (headerCorrection === undefined ? { issue } : { issue, headerCorrection }));
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* The rebuilt spec's validation failures, as archive would meet them. WARNING
|
|
1396
|
+
* for the reason `findArchiveBlockers` gives.
|
|
1397
|
+
*/
|
|
1398
|
+
async findRebuiltSpecBlockers(specName, entryPath, built, structuralErrorPaths) {
|
|
1399
|
+
const failure = await this.diagnoseRebuiltSpec(specName, built.rebuilt, built.original);
|
|
1400
|
+
if (failure === null)
|
|
1401
|
+
return [];
|
|
1402
|
+
// A delta fault the structural pass already reported for this file
|
|
1403
|
+
// resurfaces in the rebuilt spec as the same mistake under a positional
|
|
1404
|
+
// path (ADDED without a scenario becomes `requirements[3].scenarios`).
|
|
1405
|
+
// Repeating it names one mistake twice; the structural ERROR already fails
|
|
1406
|
+
// the change. Only a fault that pass cannot see is reported here.
|
|
1407
|
+
if (failure.source === 'delta' && structuralErrorPaths.has(entryPath))
|
|
1408
|
+
return [];
|
|
1409
|
+
// The finding goes last because some messages end in a multi-line example.
|
|
1410
|
+
const lead = failure.source === 'main'
|
|
1411
|
+
? `Archive would refuse this delta: the main spec ${failure.specPath} already fails validation, so fix it first (the delta is not the cause). In ${failure.specPath}, `
|
|
1412
|
+
: 'Archive would refuse this delta: the rebuilt spec fails validation. ';
|
|
1413
|
+
return failure.issues
|
|
1414
|
+
.filter((i) => i.level === 'ERROR')
|
|
1415
|
+
.map((issue) => ({
|
|
1416
|
+
level: 'WARNING',
|
|
1417
|
+
path: entryPath,
|
|
1418
|
+
message: `${lead}${issue.path}: ${issue.message}`,
|
|
1419
|
+
}));
|
|
939
1420
|
}
|
|
940
1421
|
/**
|
|
941
1422
|
* Scenario-quality rules for a delta requirement block: only `#### Scenario:`
|