@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
package/dist/core/specs-apply.js
CHANGED
|
@@ -1,14 +1,111 @@
|
|
|
1
1
|
/** Applies a change's delta specs to the main specs, independently of archiving. */
|
|
2
2
|
import { promises as fs } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, foldRequirementName,
|
|
4
|
+
import { extractRequirementsSection, parseDeltaSpec, normalizeRequirementName, foldRequirementName, findFoldedMatch, matchRequirementHeader, sectionHeaderKey, postRenameRemovalHint, } from './parsers/requirement-blocks.js';
|
|
5
5
|
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
|
|
6
|
-
import {
|
|
6
|
+
import { headingLevel } from './parsers/markdown-parser.js';
|
|
7
|
+
import { isAbsentDirectoryError, isDirectoryEntry, isMissingPathError } from '../utils/file-system.js';
|
|
7
8
|
import { buildCodeFenceMask, findMissingScenarios, normalizeDocument } from './parsers/requirement-text.js';
|
|
8
9
|
import { PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX, } from './validation/constants.js';
|
|
10
|
+
/**
|
|
11
|
+
* A merge refused for what the main spec holds, not the delta. Typed so the
|
|
12
|
+
* caller can point its fix at the main spec: every merge error used to read
|
|
13
|
+
* "Fix the change delta specs", including a structural fault in the main spec,
|
|
14
|
+
* where no edit to the delta helps.
|
|
15
|
+
*/
|
|
16
|
+
export class MainSpecMergeError extends Error {
|
|
17
|
+
specPath;
|
|
18
|
+
constructor(message,
|
|
19
|
+
/** Project-relative POSIX path of the main spec to edit. */
|
|
20
|
+
specPath) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.specPath = specPath;
|
|
23
|
+
this.name = 'MainSpecMergeError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A MODIFIED, REMOVED or RENAMED aimed at a capability that has no main spec. Typed so
|
|
28
|
+
* validate can tell this refusal from the others: it is the one another
|
|
29
|
+
* in-progress change's archive can resolve, and validate names that change.
|
|
30
|
+
*/
|
|
31
|
+
export class MissingTargetSpecError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'MissingTargetSpecError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A MODIFIED or RENAMED whose requirement the main spec lacks, with no near
|
|
39
|
+
* miss to blame. Typed for the reason `MissingTargetSpecError` is: a sister
|
|
40
|
+
* change may still be adding that requirement, and validate names it.
|
|
41
|
+
*/
|
|
42
|
+
export class MissingTargetRequirementError extends Error {
|
|
43
|
+
requirement;
|
|
44
|
+
operation;
|
|
45
|
+
constructor(message,
|
|
46
|
+
/** The requirement name as the delta wrote it. */
|
|
47
|
+
requirement,
|
|
48
|
+
/** Which entry named it, so validate reads it as the delta touch it is. */
|
|
49
|
+
operation) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.requirement = requirement;
|
|
52
|
+
this.operation = operation;
|
|
53
|
+
this.name = 'MissingTargetRequirementError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A MODIFIED, REMOVED or RENAMED (FROM) name the main spec holds only under
|
|
58
|
+
* a spelling that differs in case or spacing. Typed so validate can hand the
|
|
59
|
+
* main spec's exact name on as `suggestedHeader`: reading it back out of the
|
|
60
|
+
* message would break on any rewording of it.
|
|
61
|
+
*/
|
|
62
|
+
export class RequirementNearMissError extends Error {
|
|
63
|
+
requirement;
|
|
64
|
+
existing;
|
|
65
|
+
operation;
|
|
66
|
+
constructor(message,
|
|
67
|
+
/** The requirement name as the delta wrote it. */
|
|
68
|
+
requirement,
|
|
69
|
+
/** The main spec's exact name, the one the delta has to use. */
|
|
70
|
+
existing, operation) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.requirement = requirement;
|
|
73
|
+
this.existing = existing;
|
|
74
|
+
this.operation = operation;
|
|
75
|
+
this.name = 'RequirementNearMissError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
9
78
|
export async function findSpecUpdates(changeDir, mainSpecsDir) {
|
|
10
79
|
const updates = [];
|
|
11
|
-
const
|
|
80
|
+
for (const { capability, source } of await findDeltaSpecSources(path.join(changeDir, 'specs'))) {
|
|
81
|
+
const targetFile = path.join(mainSpecsDir, capability, 'spec.md');
|
|
82
|
+
// Same rule: calling an unreachable target absent sends the merge down the
|
|
83
|
+
// new-capability path, which rebuilds the spec from a skeleton.
|
|
84
|
+
let exists = true;
|
|
85
|
+
try {
|
|
86
|
+
await fs.access(targetFile);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!isMissingPathError(error))
|
|
90
|
+
throw error;
|
|
91
|
+
exists = false;
|
|
92
|
+
}
|
|
93
|
+
updates.push({
|
|
94
|
+
source,
|
|
95
|
+
target: targetFile,
|
|
96
|
+
exists,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return updates;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The delta files the merge reads, one per capability folder. `show` reads
|
|
103
|
+
* this list too: with a walk of its own it listed a `SPEC.md` (on a
|
|
104
|
+
* case-insensitive file system) and a dot folder's spec.md as deltas, which
|
|
105
|
+
* validate and archive never read.
|
|
106
|
+
*/
|
|
107
|
+
export async function findDeltaSpecSources(changeSpecsDir) {
|
|
108
|
+
const sources = [];
|
|
12
109
|
let entries;
|
|
13
110
|
try {
|
|
14
111
|
entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
|
|
@@ -17,42 +114,47 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
|
|
|
17
114
|
// An empty result means "no deltas", and archive merges nothing and reports
|
|
18
115
|
// success on it. Only a genuinely absent directory may say that.
|
|
19
116
|
if (isAbsentDirectoryError(error))
|
|
20
|
-
return
|
|
117
|
+
return sources;
|
|
21
118
|
throw error;
|
|
22
119
|
}
|
|
23
120
|
for (const entry of entries) {
|
|
24
|
-
|
|
121
|
+
// Dot-directories are not capabilities: validate rejects one as a delta
|
|
122
|
+
// folder, and list, show, validate --specs and artifact status all skip
|
|
123
|
+
// one. Merging it wrote a main spec at tospec/specs/.<name>/ that none of
|
|
124
|
+
// them can reach; left out here, archive names it as an unmerged file.
|
|
125
|
+
// A linked capability folder is followed, as status and instructions
|
|
126
|
+
// follow it: skipped here, its delta was reported as nothing to merge.
|
|
127
|
+
if (!isDirectoryEntry(changeSpecsDir, entry) || entry.name.startsWith('.'))
|
|
25
128
|
continue;
|
|
26
|
-
const
|
|
27
|
-
const
|
|
129
|
+
const capabilityDir = path.join(changeSpecsDir, entry.name);
|
|
130
|
+
const specFile = path.join(capabilityDir, 'spec.md');
|
|
131
|
+
// Spelled exactly spec.md, as validate, findSpecFiles and artifact status
|
|
132
|
+
// read it. Asking the file system for the path lets it decide the case: on
|
|
133
|
+
// Windows and default macOS a specs/<capability>/SPEC.md merged here while
|
|
134
|
+
// validate called it ignored, and a case-sensitive file system dropped it.
|
|
135
|
+
// Left out on both, archive names it with the other misnamed deltas.
|
|
136
|
+
if (!(await fs.readdir(capabilityDir)).includes('spec.md'))
|
|
137
|
+
continue;
|
|
138
|
+
// stat, not Dirent.isFile(): it follows a link, so a linked spec.md stays a
|
|
139
|
+
// delta. A directory named spec.md is not one; taken as a source, it failed
|
|
140
|
+
// the merge with EISDIR. The access() this replaced only repeated the name
|
|
141
|
+
// check above and let that directory through.
|
|
142
|
+
let stats;
|
|
28
143
|
try {
|
|
29
|
-
await fs.
|
|
144
|
+
stats = await fs.stat(specFile);
|
|
30
145
|
}
|
|
31
146
|
catch (error) {
|
|
32
|
-
// A
|
|
33
|
-
//
|
|
147
|
+
// A dangling link is not a delta; an unreachable spec.md is one, and
|
|
148
|
+
// skipping it drops its requirements silently.
|
|
34
149
|
if (isMissingPathError(error))
|
|
35
150
|
continue;
|
|
36
151
|
throw error;
|
|
37
152
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
await fs.access(targetFile);
|
|
43
|
-
}
|
|
44
|
-
catch (error) {
|
|
45
|
-
if (!isMissingPathError(error))
|
|
46
|
-
throw error;
|
|
47
|
-
exists = false;
|
|
48
|
-
}
|
|
49
|
-
updates.push({
|
|
50
|
-
source: specFile,
|
|
51
|
-
target: targetFile,
|
|
52
|
-
exists,
|
|
53
|
-
});
|
|
153
|
+
if (!stats.isFile())
|
|
154
|
+
continue;
|
|
155
|
+
sources.push({ capability: entry.name, source: specFile });
|
|
54
156
|
}
|
|
55
|
-
return
|
|
157
|
+
return sources;
|
|
56
158
|
}
|
|
57
159
|
/**
|
|
58
160
|
* The capability directory's real name on disk, or `fallback` when absent.
|
|
@@ -60,7 +162,7 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
|
|
|
60
162
|
* `specs/regexcap` are one directory — this normalises a message, it does not
|
|
61
163
|
* merge anything.
|
|
62
164
|
*/
|
|
63
|
-
async function resolveOnDiskSpecName(targetPath, fallback) {
|
|
165
|
+
export async function resolveOnDiskSpecName(targetPath, fallback) {
|
|
64
166
|
const parent = path.dirname(path.dirname(targetPath));
|
|
65
167
|
try {
|
|
66
168
|
const entries = await fs.readdir(parent);
|
|
@@ -77,6 +179,19 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
77
179
|
if (!options.silent)
|
|
78
180
|
console.log(`Warning: ${message}`);
|
|
79
181
|
};
|
|
182
|
+
const structuralFailures = [];
|
|
183
|
+
const structural = (message) => {
|
|
184
|
+
if (!options.deferStructural)
|
|
185
|
+
throw new Error(message);
|
|
186
|
+
structuralFailures.push(message);
|
|
187
|
+
};
|
|
188
|
+
// Names the delta contradicts itself about (a duplicate, a cross-section
|
|
189
|
+
// conflict, an unpaired rename). Its intent for them is unknown, so a deferred
|
|
190
|
+
// run applies no operation on them: checking one against the main spec would
|
|
191
|
+
// report a consequence of the contradiction (a MODIFIED "not found" after the
|
|
192
|
+
// REMOVED that conflicts with it ran) as a second mistake. Stays empty when
|
|
193
|
+
// not deferring, since the contradiction throws first.
|
|
194
|
+
const contested = new Set();
|
|
80
195
|
const changeContent = await fs.readFile(update.source, 'utf-8');
|
|
81
196
|
const plan = parseDeltaSpec(changeContent);
|
|
82
197
|
// The capability as the delta spelled it — messages about the delta's own
|
|
@@ -86,12 +201,21 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
86
201
|
// filesystem: a delta under `specs/RegexCap/` resolves to the existing
|
|
87
202
|
// `tospec/specs/regexcap/`.
|
|
88
203
|
const onDiskSpecName = await resolveOnDiskSpecName(update.target, specName);
|
|
204
|
+
const mainSpecPath = `tospec/specs/${onDiskSpecName}/spec.md`;
|
|
205
|
+
// Duplicates are judged folded, the rule the cross-section checks below and
|
|
206
|
+
// the main-spec guard apply: `Foo` + `foo` in one ADDED landed two
|
|
207
|
+
// requirements no reader can tell apart, and in REMOVED the second turned
|
|
208
|
+
// into a silent "already removed" no-op.
|
|
209
|
+
const foldedDuplicateNote = (earlier, name) => earlier === name ? '' : ` (differs only in case or spacing from "${earlier}")`;
|
|
89
210
|
const collectUnique = (names, section) => {
|
|
90
211
|
const seen = new Set();
|
|
91
212
|
for (const original of names) {
|
|
92
213
|
const name = normalizeRequirementName(original);
|
|
93
|
-
|
|
94
|
-
|
|
214
|
+
const earlier = findFoldedMatch(seen, name);
|
|
215
|
+
if (earlier !== undefined) {
|
|
216
|
+
structural(`${specName} validation failed - duplicate requirement in ${section} for header "### Requirement: ${original}"${foldedDuplicateNote(earlier, name)}`);
|
|
217
|
+
contested.add(earlier).add(name);
|
|
218
|
+
continue;
|
|
95
219
|
}
|
|
96
220
|
seen.add(name);
|
|
97
221
|
}
|
|
@@ -100,6 +224,19 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
100
224
|
const addedNames = collectUnique(plan.added.map((add) => add.name), 'ADDED');
|
|
101
225
|
const modifiedNames = collectUnique(plan.modified.map((mod) => mod.name), 'MODIFIED');
|
|
102
226
|
const removedNamesSet = collectUnique(plan.removed, 'REMOVED');
|
|
227
|
+
// A FROM or TO with no partner. The pairs the parser did form around it are
|
|
228
|
+
// a guess (`FROM a / FROM b / TO x / TO y` pairs b->x), and validate rejects
|
|
229
|
+
// the delta; without this, `--no-validate` applied the guess.
|
|
230
|
+
if (plan.renamedIssues.length > 0) {
|
|
231
|
+
const issue = plan.renamedIssues[0];
|
|
232
|
+
const side = issue.kind === 'from-without-to' ? 'FROM' : 'TO';
|
|
233
|
+
const missing = issue.kind === 'from-without-to' ? 'TO:' : 'FROM:';
|
|
234
|
+
structural(`${specName} validation failed - RENAMED ${side} "${issue.name}" (line ${issue.line}) has no matching ${missing} line; write RENAMED entries as FROM:/TO: pairs`);
|
|
235
|
+
// Every pair the parser formed is a guess once one side is unpaired.
|
|
236
|
+
for (const { from, to } of plan.renamed) {
|
|
237
|
+
contested.add(normalizeRequirementName(from)).add(normalizeRequirementName(to));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
103
240
|
// Checked per entry rather than via collectUnique: a pair that duplicates
|
|
104
241
|
// both sides must report the FROM/TO the original interleaving would.
|
|
105
242
|
const renamedFromSet = new Set();
|
|
@@ -107,11 +244,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
107
244
|
for (const { from, to } of plan.renamed) {
|
|
108
245
|
const fromNorm = normalizeRequirementName(from);
|
|
109
246
|
const toNorm = normalizeRequirementName(to);
|
|
110
|
-
|
|
111
|
-
|
|
247
|
+
const earlierFrom = findFoldedMatch(renamedFromSet, fromNorm);
|
|
248
|
+
if (earlierFrom !== undefined) {
|
|
249
|
+
structural(`${specName} validation failed - duplicate FROM in RENAMED for header "### Requirement: ${from}"${foldedDuplicateNote(earlierFrom, fromNorm)}`);
|
|
250
|
+
contested.add(earlierFrom).add(fromNorm).add(toNorm);
|
|
251
|
+
continue;
|
|
112
252
|
}
|
|
113
|
-
|
|
114
|
-
|
|
253
|
+
const earlierTo = findFoldedMatch(renamedToSet, toNorm);
|
|
254
|
+
if (earlierTo !== undefined) {
|
|
255
|
+
structural(`${specName} validation failed - duplicate TO in RENAMED for header "### Requirement: ${to}"${foldedDuplicateNote(earlierTo, toNorm)}`);
|
|
256
|
+
contested.add(earlierTo).add(fromNorm).add(toNorm);
|
|
257
|
+
continue;
|
|
115
258
|
}
|
|
116
259
|
renamedFromSet.add(fromNorm);
|
|
117
260
|
renamedToSet.add(toNorm);
|
|
@@ -133,27 +276,46 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
133
276
|
// A REMOVED naming the FROM side contradicts the rename. Since a missing
|
|
134
277
|
// REMOVED target is a no-op (early-sync), this must be rejected explicitly.
|
|
135
278
|
// Folded, so a case or whitespace variant cannot slip past as a no-op.
|
|
136
|
-
const removedFoldMatch =
|
|
279
|
+
const removedFoldMatch = findFoldedMatch(removedNamesSet, fromNorm);
|
|
137
280
|
if (removedFoldMatch !== undefined) {
|
|
138
|
-
|
|
281
|
+
structural(`${specName} validation failed - requirement present in multiple sections (RENAMED and REMOVED) for header "### Requirement: ${from}"` +
|
|
139
282
|
(removedFoldMatch === fromNorm ? '' : ` (REMOVED spells it "${removedFoldMatch}")`));
|
|
283
|
+
contested.add(removedFoldMatch).add(fromNorm).add(toNorm);
|
|
140
284
|
}
|
|
141
285
|
if (modifiedNames.has(fromNorm)) {
|
|
142
|
-
|
|
286
|
+
structural(`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`);
|
|
287
|
+
contested.add(fromNorm).add(toNorm);
|
|
143
288
|
}
|
|
144
|
-
|
|
145
|
-
|
|
289
|
+
// Folded: `LATE FEES` and `late fees` are one name to a reader, and both
|
|
290
|
+
// landing would leave two requirements no one can tell apart.
|
|
291
|
+
const addedFoldMatch = findFoldedMatch(addedNames, toNorm);
|
|
292
|
+
if (addedFoldMatch !== undefined) {
|
|
293
|
+
structural(`${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"` +
|
|
294
|
+
(addedFoldMatch === toNorm ? '' : ` (ADDED spells it "${addedFoldMatch}")`));
|
|
295
|
+
contested.add(addedFoldMatch).add(fromNorm).add(toNorm);
|
|
146
296
|
}
|
|
147
297
|
}
|
|
148
298
|
if (conflicts.length > 0) {
|
|
149
299
|
const c = conflicts[0];
|
|
150
|
-
|
|
300
|
+
structural(`${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header "### Requirement: ${c.name}"`);
|
|
301
|
+
for (const conflict of conflicts)
|
|
302
|
+
contested.add(conflict.name);
|
|
151
303
|
}
|
|
152
304
|
const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
|
|
153
305
|
if (!hasAnyDelta) {
|
|
154
|
-
|
|
306
|
+
structural(`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
|
|
155
307
|
`Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`);
|
|
156
308
|
}
|
|
309
|
+
// The merge reads delta sections only. Anything else in the file stays in the
|
|
310
|
+
// archived change and never reaches the main spec, so name it at the moment
|
|
311
|
+
// the author can still move it.
|
|
312
|
+
const strays = [
|
|
313
|
+
...plan.orphanRequirements.map((orphan) => `requirement "${orphan.name}" (line ${orphan.line})`),
|
|
314
|
+
...plan.unmergedSections.map((section) => `"${section.title}" (line ${section.line})`),
|
|
315
|
+
];
|
|
316
|
+
if (strays.length > 0) {
|
|
317
|
+
notice('archive_delta_content_not_merged', `${specName} - content outside the delta sections is not merged into the main spec: ${strays.join(', ')}.`);
|
|
318
|
+
}
|
|
157
319
|
let targetContent;
|
|
158
320
|
let isNewSpec = false;
|
|
159
321
|
try {
|
|
@@ -164,11 +326,14 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
164
326
|
// may exist with content the skeleton rebuild below would discard.
|
|
165
327
|
if (!isMissingPathError(error))
|
|
166
328
|
throw error;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
329
|
+
// REMOVED included: it was a notice ("nothing to remove") and the merge went
|
|
330
|
+
// on, so a REMOVED-only delta failed later as an empty rebuilt spec, and one
|
|
331
|
+
// beside an ADDED archived with its removal dropped. A REMOVED names a
|
|
332
|
+
// requirement its author expects to exist; with no main spec, that is a
|
|
333
|
+
// sister change's ADDED (archived after this, it brings the requirement
|
|
334
|
+
// back) or a wrong capability, and either way the refusal is the answer.
|
|
335
|
+
if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) {
|
|
336
|
+
throw new MissingTargetSpecError(`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED, REMOVED and RENAMED operations require an existing spec.`);
|
|
172
337
|
}
|
|
173
338
|
isNewSpec = true;
|
|
174
339
|
// Only plain prose is carried; a Purpose that could restructure the spec
|
|
@@ -209,7 +374,7 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
209
374
|
const details = structureIssues
|
|
210
375
|
.map(issue => `line ${issue.line}: ${issue.message}`)
|
|
211
376
|
.join('\n');
|
|
212
|
-
throw new
|
|
377
|
+
throw new MainSpecMergeError(`${specName}: target spec is structurally invalid and cannot be updated until fixed:\n${details}`, mainSpecPath);
|
|
213
378
|
}
|
|
214
379
|
const parts = extractRequirementsSection(targetContent);
|
|
215
380
|
const nameToBlock = new Map();
|
|
@@ -225,16 +390,33 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
225
390
|
for (const r of plan.renamed) {
|
|
226
391
|
const from = normalizeRequirementName(r.from);
|
|
227
392
|
const to = normalizeRequirementName(r.to);
|
|
393
|
+
if (contested.has(from) || contested.has(to))
|
|
394
|
+
continue;
|
|
228
395
|
if (!nameToBlock.has(from)) {
|
|
229
396
|
// Source gone but target present: the rename was already synced.
|
|
230
397
|
if (nameToBlock.has(to)) {
|
|
231
398
|
continue;
|
|
232
399
|
}
|
|
233
|
-
|
|
400
|
+
// The near miss MODIFIED and REMOVED report. "source not found" alone
|
|
401
|
+
// is also what a sister change still adding the requirement looks like,
|
|
402
|
+
// so a case typo here read as an archive-order problem.
|
|
403
|
+
const nearMiss = findFoldedMatch(nameToBlock.keys(), from);
|
|
404
|
+
if (nearMiss !== undefined) {
|
|
405
|
+
const existing = nameToBlock.get(nearMiss).name;
|
|
406
|
+
throw new RequirementNearMissError(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found, but "### Requirement: ${existing}" exists; fix the FROM line to match it exactly`, r.from, existing, 'RENAMED');
|
|
407
|
+
}
|
|
408
|
+
throw new MissingTargetRequirementError(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`, r.from, 'RENAMED');
|
|
234
409
|
}
|
|
235
410
|
if (nameToBlock.has(to)) {
|
|
236
411
|
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`);
|
|
237
412
|
}
|
|
413
|
+
// The source is exempt, so a case-only rename (`late fees` -> `Late Fees`)
|
|
414
|
+
// stays legal; any other folded match is a second spelling of a name the
|
|
415
|
+
// spec already has.
|
|
416
|
+
const renameNearMiss = findFoldedNameClash(nameToBlock, to, from);
|
|
417
|
+
if (renameNearMiss !== undefined) {
|
|
418
|
+
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - "### Requirement: ${renameNearMiss}" already exists and differs only in case or spacing`);
|
|
419
|
+
}
|
|
238
420
|
const block = nameToBlock.get(from);
|
|
239
421
|
const newHeader = `### Requirement: ${to}`;
|
|
240
422
|
const rawLines = block.raw.split('\n');
|
|
@@ -262,17 +444,19 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
262
444
|
const removedKeys = new Set();
|
|
263
445
|
for (const name of plan.removed) {
|
|
264
446
|
const key = normalizeRequirementName(name);
|
|
447
|
+
if (contested.has(key))
|
|
448
|
+
continue;
|
|
265
449
|
if (!nameToBlock.has(key)) {
|
|
266
450
|
// Already gone means the removal was already synced. A near miss — one
|
|
267
451
|
// still present differing only in case or interior whitespace — is a typo
|
|
268
|
-
// instead, and stays a hard abort.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
notice('archive_removed_requirement_absent', `${specName} - REMOVED requirement "${name}" is not in the current spec; treating it as already removed. Nothing was deleted — if you meant to remove a requirement, check the header spelling against tospec/specs/${onDiskSpecName}/spec.md.`);
|
|
452
|
+
// instead, and stays a hard abort. A new spec never gets here: a REMOVED
|
|
453
|
+
// without a main spec is refused above.
|
|
454
|
+
const nearMiss = findFoldedMatch(nameToBlock.keys(), key);
|
|
455
|
+
if (nearMiss !== undefined) {
|
|
456
|
+
const existing = nameToBlock.get(nearMiss).name;
|
|
457
|
+
throw new RequirementNearMissError(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found, but "### Requirement: ${existing}" exists; fix the header to match it exactly`, name, existing, 'REMOVED');
|
|
275
458
|
}
|
|
459
|
+
notice('archive_removed_requirement_absent', `${specName} - REMOVED requirement "${name}" is not in the current spec; treating it as already removed. Nothing was deleted — if you meant to remove a requirement, check the header spelling against tospec/specs/${onDiskSpecName}/spec.md.`);
|
|
276
460
|
continue;
|
|
277
461
|
}
|
|
278
462
|
nameToBlock.delete(key);
|
|
@@ -288,12 +472,21 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
288
472
|
const unmatchedDeclarations = new Set(plan.removedScenarios);
|
|
289
473
|
for (const mod of plan.modified) {
|
|
290
474
|
const key = normalizeRequirementName(mod.name);
|
|
475
|
+
if (contested.has(key))
|
|
476
|
+
continue;
|
|
291
477
|
const currentBlock = nameToBlock.get(key);
|
|
292
478
|
if (!currentBlock) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
479
|
+
// The same near-miss hint REMOVED gives: "not found" alone sends the
|
|
480
|
+
// author looking for a missing requirement rather than a case typo.
|
|
481
|
+
const nearMiss = findFoldedMatch(nameToBlock.keys(), key);
|
|
482
|
+
const notFound = `${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`;
|
|
483
|
+
if (nearMiss === undefined)
|
|
484
|
+
throw new MissingTargetRequirementError(notFound, mod.name, 'MODIFIED');
|
|
485
|
+
const existing = nameToBlock.get(nearMiss).name;
|
|
486
|
+
throw new RequirementNearMissError(`${notFound}, but "### Requirement: ${existing}" exists; fix the header to match it exactly`, mod.name, existing, 'MODIFIED');
|
|
487
|
+
}
|
|
488
|
+
const modHeaderName = matchRequirementHeader(mod.raw.split('\n')[0]);
|
|
489
|
+
if (modHeaderName === null || normalizeRequirementName(modHeaderName) !== key) {
|
|
297
490
|
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
|
|
298
491
|
}
|
|
299
492
|
// Guards against a stale MODIFIED block dropping scenarios a previous
|
|
@@ -310,7 +503,9 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
310
503
|
return false;
|
|
311
504
|
});
|
|
312
505
|
if (missingScenarios.length > 0) {
|
|
313
|
-
|
|
506
|
+
// Deferred, the block is applied as written: the scenario loss is the
|
|
507
|
+
// only fault, and it does not change which names the spec holds.
|
|
508
|
+
structural(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.`);
|
|
314
509
|
}
|
|
315
510
|
// A plain `### Notes` after the requirement is absorbed into its block (see
|
|
316
511
|
// the recompose pass below), and replacing the block wholesale deleted it
|
|
@@ -320,11 +515,23 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
320
515
|
nameToBlock.set(key, absorbed ? { ...mod, raw: `${mod.raw.trimEnd()}\n\n${absorbed}` } : mod);
|
|
321
516
|
}
|
|
322
517
|
for (const entry of unmatchedDeclarations) {
|
|
323
|
-
|
|
518
|
+
const renameHint = postRenameRemovalHint(entry, plan.renamed);
|
|
519
|
+
const message = `${specName} REMOVED Scenarios declares "${entry.scenario}" under requirement "${entry.requirement}", but no MODIFIED block for that requirement drops that scenario - the declaration removes nothing on its own. ` +
|
|
520
|
+
(renameHint === undefined
|
|
521
|
+
? 'Restate the requirement under "## MODIFIED Requirements" without that scenario, or check both names against the current spec.'
|
|
522
|
+
: `${renameHint}.`);
|
|
523
|
+
// Structural only against an existing main spec: that is the only case
|
|
524
|
+
// validate's own check reads (it has no spec to match a new capability's
|
|
525
|
+
// declarations against), so deferring a new capability's would hide it.
|
|
526
|
+
if (isNewSpec)
|
|
527
|
+
throw new Error(message);
|
|
528
|
+
structural(message);
|
|
324
529
|
}
|
|
325
530
|
let addedApplied = 0;
|
|
326
531
|
for (const add of plan.added) {
|
|
327
532
|
const key = normalizeRequirementName(add.name);
|
|
533
|
+
if (contested.has(key))
|
|
534
|
+
continue;
|
|
328
535
|
const existing = nameToBlock.get(key);
|
|
329
536
|
if (existing) {
|
|
330
537
|
// Identical content means it was already synced; only differing content
|
|
@@ -334,6 +541,10 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
334
541
|
}
|
|
335
542
|
throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
|
|
336
543
|
}
|
|
544
|
+
const addNearMiss = findFoldedNameClash(nameToBlock, key);
|
|
545
|
+
if (addNearMiss !== undefined) {
|
|
546
|
+
throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - "### Requirement: ${addNearMiss}" already exists and differs only in case or spacing`);
|
|
547
|
+
}
|
|
337
548
|
nameToBlock.set(key, add);
|
|
338
549
|
addedApplied++;
|
|
339
550
|
}
|
|
@@ -375,7 +586,17 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
375
586
|
// delete that content with the file; writing it would produce a spec with no
|
|
376
587
|
// requirements, which cannot validate. Neither is safe to choose silently.
|
|
377
588
|
if (keptRequirements === 0 && removedApplied > 0 && salvagedCount > 0) {
|
|
378
|
-
throw new
|
|
589
|
+
throw new MainSpecMergeError(`${specName}: removing every requirement would leave content that is not part of any requirement (e.g. a "### Notes" section). Move that content out of ${specName}/spec.md first, then archive again.`, mainSpecPath);
|
|
590
|
+
}
|
|
591
|
+
// The same refusal for a section outside `## Requirements`: retiring deleted
|
|
592
|
+
// a `## Notes` or `# Appendix` with the file, with no word, while the same
|
|
593
|
+
// notes inside a requirement block were refused above. The title, Purpose and
|
|
594
|
+
// the Requirements section itself are what a retirement is meant to delete.
|
|
595
|
+
if (keptRequirements === 0 && removedApplied > 0) {
|
|
596
|
+
const lost = sectionsLostOnRetire(parts.before, parts.after);
|
|
597
|
+
if (lost.length > 0) {
|
|
598
|
+
throw new MainSpecMergeError(`${specName}: removing every requirement would delete ${onDiskSpecName}/spec.md, and with it the content outside its requirements: ${lost.map((title) => `"${title}"`).join(', ')}. Move that content out of ${specName}/spec.md first, then archive again.`, mainSpecPath);
|
|
599
|
+
}
|
|
379
600
|
}
|
|
380
601
|
const reqBody = [parts.preamble && parts.preamble.trim() ? parts.preamble.trimEnd() : '']
|
|
381
602
|
.filter(Boolean)
|
|
@@ -403,8 +624,60 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
403
624
|
removed: removedApplied,
|
|
404
625
|
renamed: renamedApplied,
|
|
405
626
|
},
|
|
627
|
+
// The main spec as read, for a caller that must tell whether a rebuilt
|
|
628
|
+
// spec fails because of the delta or because of what it was merged into.
|
|
629
|
+
// Absent for a new capability, whose skeleton is the merge's own text.
|
|
630
|
+
original: isNewSpec ? undefined : { path: mainSpecPath, content: targetContent },
|
|
631
|
+
// Non-empty only under `deferStructural`, and then `rebuilt` is a partial
|
|
632
|
+
// merge: it skipped what the failures name and must not be judged as the
|
|
633
|
+
// spec archive would write.
|
|
634
|
+
structuralFailures,
|
|
406
635
|
};
|
|
407
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* The name of a requirement already in `blocks` that differs from `name` only
|
|
639
|
+
* in case or interior whitespace, ignoring `exempt` (a rename's own source).
|
|
640
|
+
* Matching stays exact everywhere else; this only refuses a second spelling.
|
|
641
|
+
*/
|
|
642
|
+
function findFoldedNameClash(blocks, name, exempt) {
|
|
643
|
+
const folded = foldRequirementName(name);
|
|
644
|
+
for (const [key, block] of blocks) {
|
|
645
|
+
if (key === name || key === exempt)
|
|
646
|
+
continue;
|
|
647
|
+
if (foldRequirementName(key) === folded)
|
|
648
|
+
return block.name;
|
|
649
|
+
}
|
|
650
|
+
return undefined;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* The sections of a main spec, outside `## Requirements`, that hold a
|
|
654
|
+
* non-blank line: the content a retirement would delete with the file. The
|
|
655
|
+
* title line and `## Purpose` (its `###` subheadings included) are not
|
|
656
|
+
* counted; text under the title, or under any other `#` or `##` heading, is.
|
|
657
|
+
* A heading with nothing under it loses nothing, as migrate drops an empty
|
|
658
|
+
* section. Fenced lines are content and never headings.
|
|
659
|
+
*/
|
|
660
|
+
function sectionsLostOnRetire(before, after) {
|
|
661
|
+
const lost = [];
|
|
662
|
+
for (const text of [before, after]) {
|
|
663
|
+
const lines = text.split('\n');
|
|
664
|
+
const mask = buildCodeFenceMask(lines);
|
|
665
|
+
let current = { title: 'the text above the title', kept: false, reported: false };
|
|
666
|
+
for (let i = 0; i < lines.length; i++) {
|
|
667
|
+
const level = mask[i] ? null : headingLevel(lines[i]);
|
|
668
|
+
if (level !== null && level <= 2) {
|
|
669
|
+
const kept = level === 2 && sectionHeaderKey(lines[i]) === 'purpose';
|
|
670
|
+
current = { title: lines[i].trim(), kept, reported: false };
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
if (current.kept || current.reported || lines[i].trim() === '')
|
|
674
|
+
continue;
|
|
675
|
+
lost.push(current.title);
|
|
676
|
+
current.reported = true;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return lost;
|
|
680
|
+
}
|
|
408
681
|
function normalizeBlockRaw(raw) {
|
|
409
682
|
return normalizeDocument(raw).trim();
|
|
410
683
|
}
|
|
@@ -473,6 +746,93 @@ function salvageAbsorbedContent(requirementRaw) {
|
|
|
473
746
|
}
|
|
474
747
|
return '';
|
|
475
748
|
}
|
|
749
|
+
const MAX_LINK_HOPS = 40;
|
|
750
|
+
/**
|
|
751
|
+
* Where a path really lands: the realpath of its deepest existing ancestor,
|
|
752
|
+
* with the not-yet-created remainder appended. A target that does not exist yet
|
|
753
|
+
* (a new capability) still resolves through a linked `specs/` above it.
|
|
754
|
+
*
|
|
755
|
+
* `fs.realpath` follows both a POSIX symlink and a Windows junction, so one
|
|
756
|
+
* check covers both — they differ in how they are created and recorded, not in
|
|
757
|
+
* where a write through them lands.
|
|
758
|
+
*
|
|
759
|
+
* A dangling link (file or directory) makes `realpath` fail exactly like an
|
|
760
|
+
* absent path, yet `writeFile` and `mkdir` follow it. Appending its name to the
|
|
761
|
+
* ancestor judged the link's own in-project path, so the first missing segment
|
|
762
|
+
* is `lstat`ed and, when it is a link, resolution restarts from its target.
|
|
763
|
+
*/
|
|
764
|
+
async function resolveRealPath(target) {
|
|
765
|
+
let pending = path.resolve(target);
|
|
766
|
+
// Bounds a chain of dangling links that point at each other; `realpath`
|
|
767
|
+
// reports a cycle of existing links itself (ELOOP).
|
|
768
|
+
for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
|
|
769
|
+
const rest = [];
|
|
770
|
+
let existing = pending;
|
|
771
|
+
let resolved = null;
|
|
772
|
+
while (resolved === null) {
|
|
773
|
+
try {
|
|
774
|
+
resolved = await fs.realpath(existing);
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
if (!isMissingPathError(error))
|
|
778
|
+
throw error;
|
|
779
|
+
const parent = path.dirname(existing);
|
|
780
|
+
if (parent === existing)
|
|
781
|
+
return pending;
|
|
782
|
+
rest.unshift(path.basename(existing));
|
|
783
|
+
existing = parent;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (rest.length === 0)
|
|
787
|
+
return resolved;
|
|
788
|
+
const firstMissing = path.join(resolved, rest[0]);
|
|
789
|
+
const isLink = await fs
|
|
790
|
+
.lstat(firstMissing)
|
|
791
|
+
.then((stat) => stat.isSymbolicLink())
|
|
792
|
+
.catch((error) => {
|
|
793
|
+
if (isMissingPathError(error))
|
|
794
|
+
return false;
|
|
795
|
+
throw error;
|
|
796
|
+
});
|
|
797
|
+
if (!isLink)
|
|
798
|
+
return path.join(resolved, ...rest);
|
|
799
|
+
pending = path.resolve(resolved, await fs.readlink(firstMissing), ...rest.slice(1));
|
|
800
|
+
}
|
|
801
|
+
throw new Error(`Too many levels of symbolic links resolving ${target}`);
|
|
802
|
+
}
|
|
803
|
+
function isInsideDirectory(child, parent) {
|
|
804
|
+
const relative = path.relative(parent, child);
|
|
805
|
+
if (relative === '')
|
|
806
|
+
return true;
|
|
807
|
+
// Only a whole `..` segment steps out; `..shared` is a directory name.
|
|
808
|
+
const leavesParent = relative === '..' || relative.startsWith(`..${path.sep}`);
|
|
809
|
+
return !leavesParent && !path.isAbsolute(relative);
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Checks a prepared update against the project boundary before anything is
|
|
813
|
+
* written. A capability directory (or `tospec/specs` itself) linked outside the
|
|
814
|
+
* project made archive write — and on retire, `fs.rm` — a file the project does
|
|
815
|
+
* not own.
|
|
816
|
+
*
|
|
817
|
+
* A write is allowed and named, since a shared specs directory is a legitimate
|
|
818
|
+
* setup; a delete is refused, since removing a file outside the project is
|
|
819
|
+
* never what archiving a change means. Throws so the caller's all-or-nothing
|
|
820
|
+
* prepare pass aborts with nothing changed.
|
|
821
|
+
*/
|
|
822
|
+
export async function checkTargetWithinProject(update, projectRoot, retired) {
|
|
823
|
+
const realTarget = await resolveRealPath(update.target);
|
|
824
|
+
const realRoot = await resolveRealPath(projectRoot);
|
|
825
|
+
if (isInsideDirectory(realTarget, realRoot))
|
|
826
|
+
return null;
|
|
827
|
+
const specName = path.basename(path.dirname(update.target));
|
|
828
|
+
if (retired) {
|
|
829
|
+
throw new Error(`${specName}: removing every requirement would delete tospec/specs/${specName}/spec.md, which resolves to ${realTarget}, outside the project (a symlink or junction). Refusing to delete a file outside the project; remove it there yourself if that is intended.`);
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
code: 'archive_spec_outside_project',
|
|
833
|
+
message: `${specName} - tospec/specs/${specName}/spec.md resolves to ${realTarget}, outside the project (a symlink or junction); the merge was written there.`,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
476
836
|
/**
|
|
477
837
|
* Delete an emptied capability's `spec.md` and prune the directories that
|
|
478
838
|
* leaves empty. Pruning stops at `specsRoot` (a project with no capabilities
|
|
@@ -525,7 +885,10 @@ export function extractDeltaPurpose(content) {
|
|
|
525
885
|
for (let i = 0; i < lines.length; i++) {
|
|
526
886
|
if (mask[i])
|
|
527
887
|
continue;
|
|
528
|
-
|
|
888
|
+
// A closing `#` run is peeled as `parseDeltaSpec` peels it: that parser
|
|
889
|
+
// reads `## Purpose ##` as Purpose and leaves it off the unmerged list, so
|
|
890
|
+
// missing it here dropped the text with no notice at all.
|
|
891
|
+
if (sectionHeaderKey(lines[i]) === 'purpose') {
|
|
529
892
|
start = i + 1;
|
|
530
893
|
break;
|
|
531
894
|
}
|