From a3c8ab4837f6987de7f1665420388b2c90966140 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 29 Oct 2026 02:53:43 +0100 Subject: [PATCH] bugfix-768-bug-sdd-catalog-links-discard-nested-specification-directories: preserve nested SDD catalog links --- build/cli/index.js | 6 +- build/github_action/index.js | 6 +- docs/development/specifications.mdx | 8 ++ specs/CATALOG.md | 6 +- specs/catalog.json | 4 +- specs/pre-branch-sdd-gate.md | 73 ++++++++++++++++++- .../specification_catalog_validator.cjs | 6 +- .../portable_specification_catalog.test.ts | 45 +++++++++++- 8 files changed, 136 insertions(+), 18 deletions(-) diff --git a/build/cli/index.js b/build/cli/index.js index 8b76f5c97..75718eb8a 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -116288,8 +116288,8 @@ function renderCatalog(catalog) { const primarySpec = capability.specs[0]; const companionCount = capability.specs.length - 1; const specLabel = companionCount > 0 - ? `[${escapeCell(capability.title)}](./${path.posix.basename(primarySpec)}) + ${companionCount} companion` - : `[${escapeCell(capability.title)}](./${path.posix.basename(primarySpec)})`; + ? `[${escapeCell(capability.title)}](./${primarySpec.slice('specs/'.length)}) + ${companionCount} companion` + : `[${escapeCell(capability.title)}](./${primarySpec.slice('specs/'.length)})`; const evidenceCount = capability.workflows.length + capability.entrypoints.length + capability.code.length @@ -116348,7 +116348,7 @@ function renderPathLinks(paths) { if (paths.length === 0) return 'Not applicable for this capability.'; return paths.map(relativePath => { const target = relativePath.startsWith('specs/') - ? `./${path.posix.basename(relativePath)}` + ? `./${relativePath.slice('specs/'.length)}` : `../${relativePath}`; return `[\`${relativePath}\`](${target})`; }).join(' · '); diff --git a/build/github_action/index.js b/build/github_action/index.js index d24c9395f..1c9dafe5d 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -99142,8 +99142,8 @@ function renderCatalog(catalog) { const primarySpec = capability.specs[0]; const companionCount = capability.specs.length - 1; const specLabel = companionCount > 0 - ? `[${escapeCell(capability.title)}](./${path.posix.basename(primarySpec)}) + ${companionCount} companion` - : `[${escapeCell(capability.title)}](./${path.posix.basename(primarySpec)})`; + ? `[${escapeCell(capability.title)}](./${primarySpec.slice('specs/'.length)}) + ${companionCount} companion` + : `[${escapeCell(capability.title)}](./${primarySpec.slice('specs/'.length)})`; const evidenceCount = capability.workflows.length + capability.entrypoints.length + capability.code.length @@ -99202,7 +99202,7 @@ function renderPathLinks(paths) { if (paths.length === 0) return 'Not applicable for this capability.'; return paths.map(relativePath => { const target = relativePath.startsWith('specs/') - ? `./${path.posix.basename(relativePath)}` + ? `./${relativePath.slice('specs/'.length)}` : `../${relativePath}`; return `[\`${relativePath}\`](${target})`; }).join(' · '); diff --git a/docs/development/specifications.mdx b/docs/development/specifications.mdx index 7a14eca49..b897d5349 100644 --- a/docs/development/specifications.mdx +++ b/docs/development/specifications.mdx @@ -50,6 +50,14 @@ contained files; `specs/` and workflow publication boundaries stay fixed. Run `copilot doctor` before enabling the gate and verify compiler/framework tooling separately. See [layout support and migration](/issues/pre-branch-sdds#repository-layouts-and-support-limits). +Nested SDD directories are preserved in the generated catalog. For example, +`specs/group/example.md` links to `./group/example.md` from `specs/CATALOG.md`, +in both the primary-SDD table and the evidence map. Companion SDDs keep their +directories too, so equal filenames in separate groups point to separate files. +Root-level SDD links and `../` links to other repository evidence keep their +existing format. Regenerate the catalog after upgrading to repair older flattened +links; the source files and registered paths do not need to move. + ## Issue-driven specification gate Repositories can opt into `pre-branch-sdd: true` for feature and diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 145f7afee..3c5644c1d 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -20,7 +20,7 @@ debt or convert unknown historic intent into a design decision. | `local-web-setup-assistant` | Proposed | Offer a packaged, loopback-only Svelte setup interface over the existing engine with four-language guidance, English CLI help, basic/custom first-run paths, editable question and plan review, source-labelled GitHub facts, assisted CI and Project evidence, role-separated PATs, and truthful itemized outcomes; inspect existing configuration and apply independent, scope-aware runtime adjustments with permission, drift and read-back verification | [Local web setup assistant](./local-web-setup-assistant.md) + 2 companion | 248 paths · 2026-10-21 | | `guided-bot-pat-onboarding` | Proposed | Guide creation of the persistent workflow PAT, verify bot identity and grants, recover explicitly from wrong-account input within the session, and install the approved Actions Secret | [Guided bot PAT onboarding](./guided-bot-pat-onboarding.md) | 39 paths · 2026-10-07 | | `temporary-setup-operator-authorization` | Proposed | Guide creation of the one-run operator PAT through GitHub's official form, verify final setup access, and report user-owned deletion accurately | [Assisted setup PAT creation](./temporary-setup-operator-authorization.md) | 36 paths · 2026-10-18 | -| `issue-start-and-sdd-readiness` | Implemented | Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 3 companion | 132 paths · 2026-10-27 | +| `issue-start-and-sdd-readiness` | Implemented | Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons and preserve nested catalog specification links | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 3 companion | 132 paths · 2026-10-29 | | `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) | 71 paths · 2026-10-20 | | `comment-automation` | Implemented | Admit only explicit commands or exact mentions, then route them while protecting repository mutations | [Comment automation and authorization](./comment-automation-and-authorization.md) | 88 paths · 2026-10-26 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, exhaustively analyze bounded diff partitions with version-grounded dependency, delegated test-path and external Action input-contract and Kotlin/Wasm lexical-binding evidence, publish stable findings atomically, and apply authorized verified fixes | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 3 companion | 127 paths · 2026-10-29 | @@ -148,7 +148,7 @@ debt or convert unknown historic intent into a design decision. ### `issue-start-and-sdd-readiness` — Uniform issue start and pre-branch SDD readiness - Owner: Copilot maintainers -- Last verified: 2026-10-27 +- Last verified: 2026-10-29 - Specifications: [`specs/issue-start-and-branch-readiness.md`](./issue-start-and-branch-readiness.md) · [`specs/pre-branch-sdd-gate.md`](./pre-branch-sdd-gate.md) · [`specs/pre-branch-sdd-diff.md`](./pre-branch-sdd-diff.md) · [`specs/pre-branch-sdd-status-readiness.md`](./pre-branch-sdd-status-readiness.md) - Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) · [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) · [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) - Entrypoints: [`action.yml`](../action.yml) · [`src/actions/common_action.ts`](../src/actions/common_action.ts) · [`src/actions/github_action.ts`](../src/actions/github_action.ts) @@ -275,7 +275,7 @@ debt or convert unknown historic intent into a design decision. - Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`extension/src/infrastructure/content_entry.ts`](../extension/src/infrastructure/content_entry.ts) · [`extension/src/infrastructure/background_entry.ts`](../extension/src/infrastructure/background_entry.ts) · [`extension/tooling/cli.mts`](../extension/tooling/cli.mts) - Core code: [`src/application/usecases/read_copilot_status_workflow.ts`](../src/application/usecases/read_copilot_status_workflow.ts) · [`src/application/ports/pull_request_status_read_ports.ts`](../src/application/ports/pull_request_status_read_ports.ts) · [`extension/package.json`](../extension/package.json) · [`extension/tsconfig.json`](../extension/tsconfig.json) · [`extension/vitest.config.mts`](../extension/vitest.config.mts) · [`extension/src/domain/eligibility.ts`](../extension/src/domain/eligibility.ts) · [`extension/src/domain/target.ts`](../extension/src/domain/target.ts) · [`extension/src/infrastructure/target_manifests.ts`](../extension/src/infrastructure/target_manifests.ts) · [`src/domain/browser_extension_contract/index.ts`](../src/domain/browser_extension_contract/index.ts) · [`src/domain/browser_extension_contract/manifest.ts`](../src/domain/browser_extension_contract/manifest.ts) · [`extension/tooling/architecture_validation.mts`](../extension/tooling/architecture_validation.mts) · [`extension/tooling/artifact_validation.mts`](../extension/tooling/artifact_validation.mts) · [`extension/tooling/build.mts`](../extension/tooling/build.mts) · [`extension/tooling/cli.mts`](../extension/tooling/cli.mts) · [`extension/tooling/gate_error.mts`](../extension/tooling/gate_error.mts) · [`extension/tooling/package_contract.mts`](../extension/tooling/package_contract.mts) · [`extension/tooling/source_graph.mts`](../extension/tooling/source_graph.mts) · [`extension/tooling/validation.mts`](../extension/tooling/validation.mts) · [`extension/src/adapters/browser/api.ts`](../extension/src/adapters/browser/api.ts) · [`extension/src/adapters/browser/cancellation.ts`](../extension/src/adapters/browser/cancellation.ts) · [`extension/src/adapters/browser/resource_scope.ts`](../extension/src/adapters/browser/resource_scope.ts) · [`extension/src/adapters/browser/storage.ts`](../extension/src/adapters/browser/storage.ts) · [`extension/src/adapters/dom/runtime_panel.ts`](../extension/src/adapters/dom/runtime_panel.ts) · [`extension/src/adapters/github/get_reader.ts`](../extension/src/adapters/github/get_reader.ts) · [`extension/src/application/lifecycle.ts`](../extension/src/application/lifecycle.ts) · [`extension/src/application/message_gateway.ts`](../extension/src/application/message_gateway.ts) · [`extension/src/application/protected_session.ts`](../extension/src/application/protected_session.ts) · [`extension/src/application/read_ports.ts`](../extension/src/application/read_ports.ts) · [`extension/src/application/read_scheduler.ts`](../extension/src/application/read_scheduler.ts) · [`extension/src/application/runtime_ports.ts`](../extension/src/application/runtime_ports.ts) · [`extension/src/application/settings_repository.ts`](../extension/src/application/settings_repository.ts) · [`extension/src/domain/feature_defaults.ts`](../extension/src/domain/feature_defaults.ts) · [`extension/src/domain/page.ts`](../extension/src/domain/page.ts) · [`extension/src/domain/protocol.ts`](../extension/src/domain/protocol.ts) · [`extension/src/domain/read.ts`](../extension/src/domain/read.ts) · [`extension/src/domain/scheduling.ts`](../extension/src/domain/scheduling.ts) · [`extension/src/domain/session.ts`](../extension/src/domain/session.ts) · [`extension/src/domain/settings.ts`](../extension/src/domain/settings.ts) · [`extension/src/domain/value.ts`](../extension/src/domain/value.ts) · [`extension/src/infrastructure/background_entry.ts`](../extension/src/infrastructure/background_entry.ts) · [`extension/src/infrastructure/background_runtime.ts`](../extension/src/infrastructure/background_runtime.ts) · [`extension/src/infrastructure/build_identity.ts`](../extension/src/infrastructure/build_identity.ts) · [`extension/src/infrastructure/content_entry.ts`](../extension/src/infrastructure/content_entry.ts) · [`extension/src/infrastructure/content_runtime.ts`](../extension/src/infrastructure/content_runtime.ts) · [`extension/src/infrastructure/options_runtime.ts`](../extension/src/infrastructure/options_runtime.ts) · [`extension/src/infrastructure/runtime_entry.ts`](../extension/src/infrastructure/runtime_entry.ts) · [`extension/src/presentation/runtime_copy.ts`](../extension/src/presentation/runtime_copy.ts) · [`extension/tooling/browser_cli.mts`](../extension/tooling/browser_cli.mts) · [`extension/tooling/loaded_manifest.mts`](../extension/tooling/loaded_manifest.mts) · [`extension/tooling/runtime_harness.mts`](../extension/tooling/runtime_harness.mts) · [`extension/tooling/safari_cli.mts`](../extension/tooling/safari_cli.mts) · [`extension/tooling/safari_container.mts`](../extension/tooling/safari_container.mts) · [`extension/tooling/static_assets.mts`](../extension/tooling/static_assets.mts) · [`extension/tooling/webdriver.mts`](../extension/tooling/webdriver.mts) · [`extension/src/application/shared_read_cancellation.ts`](../extension/src/application/shared_read_cancellation.ts) - Tests: [`src/application/usecases/__tests__/read_copilot_status_workflow.test.ts`](../src/application/usecases/__tests__/read_copilot_status_workflow.test.ts) · [`extension/test/contracts/architecture.test.ts`](../extension/test/contracts/architecture.test.ts) · [`extension/test/contracts/eligibility.test.ts`](../extension/test/contracts/eligibility.test.ts) · [`extension/test/contracts/manifest_value.test.ts`](../extension/test/contracts/manifest_value.test.ts) · [`extension/test/contracts/target_manifests.test.ts`](../extension/test/contracts/target_manifests.test.ts) · [`extension/test/integration/build.test.ts`](../extension/test/integration/build.test.ts) · [`extension/test/integration/commands.test.ts`](../extension/test/integration/commands.test.ts) · [`extension/test/integration/package.test.ts`](../extension/test/integration/package.test.ts) · [`extension/test/integration/recovery.test.ts`](../extension/test/integration/recovery.test.ts) · [`src/domain/__tests__/browser_extension_contract.test.ts`](../src/domain/__tests__/browser_extension_contract.test.ts) · [`extension/test/integration/receipt.test.ts`](../extension/test/integration/receipt.test.ts) · [`extension/test/contracts/loaded_manifest.test.ts`](../extension/test/contracts/loaded_manifest.test.ts) · [`extension/test/contracts/runtime_browser.test.ts`](../extension/test/contracts/runtime_browser.test.ts) · [`extension/test/contracts/runtime_navigation.test.ts`](../extension/test/contracts/runtime_navigation.test.ts) · [`extension/test/contracts/runtime_entries.test.ts`](../extension/test/contracts/runtime_entries.test.ts) · [`extension/test/contracts/runtime_protocol.test.ts`](../extension/test/contracts/runtime_protocol.test.ts) · [`extension/test/contracts/runtime_reads.test.ts`](../extension/test/contracts/runtime_reads.test.ts) · [`extension/test/contracts/runtime_scheduling.test.ts`](../extension/test/contracts/runtime_scheduling.test.ts) · [`extension/test/contracts/runtime_settings.test.ts`](../extension/test/contracts/runtime_settings.test.ts) · [`extension/test/integration/browser-harness.test.ts`](../extension/test/integration/browser-harness.test.ts) · [`extension/test/integration/gateway.test.ts`](../extension/test/integration/gateway.test.ts) · [`extension/test/integration/runtime.test.ts`](../extension/test/integration/runtime.test.ts) · [`extension/test/integration/runtime_dom.test.ts`](../extension/test/integration/runtime_dom.test.ts) · [`extension/test/integration/runtime_options.test.ts`](../extension/test/integration/runtime_options.test.ts) · [`extension/test/integration/safari-container.test.ts`](../extension/test/integration/safari-container.test.ts) · [`extension/test/integration/scheduler.test.ts`](../extension/test/integration/scheduler.test.ts) · [`extension/test/integration/session.test.ts`](../extension/test/integration/session.test.ts) · [`extension/test/integration/settings_repository.test.ts`](../extension/test/integration/settings_repository.test.ts) · [`extension/test/integration/webdriver.test.ts`](../extension/test/integration/webdriver.test.ts) · [`extension/test/integration/session_races.test.ts`](../extension/test/integration/session_races.test.ts) · [`extension/test/integration/shared_reads.test.ts`](../extension/test/integration/shared_reads.test.ts) · [`extension/test/integration/runtime_settings_navigation.test.ts`](../extension/test/integration/runtime_settings_navigation.test.ts) -- User documentation: [`docs/development/specifications.mdx`](../docs/development/specifications.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) · [`specs/browser-extension/manifest-v1.schema.json`](./manifest-v1.schema.json) · [`specs/browser-extension/status-v1.schema.json`](./status-v1.schema.json) · [`specs/browser-extension/examples/manifest-v1.json`](./manifest-v1.json) · [`specs/browser-extension/examples/status-pr-v1.json`](./status-pr-v1.json) · [`specs/browser-extension/examples/status-issue-v1.json`](./status-issue-v1.json) · [`specs/browser-extension/examples/status-invalid-head-v1.json`](./status-invalid-head-v1.json) · [`specs/browser-extension/feature-catalog.v1.json`](./feature-catalog.v1.json) · [`specs/browser-extension/feature-catalog.schema.json`](./feature-catalog.schema.json) · [`specs/browser-extension/requests-v2.schema.json`](./requests-v2.schema.json) · [`specs/browser-extension/examples/requests-v2.json`](./requests-v2.json) · [`specs/browser-extension/feature-catalog-index.schema.json`](./feature-catalog-index.schema.json) · [`specs/browser-extension/features/NAV.v1.json`](./NAV.v1.json) · [`specs/browser-extension/features/CODE.v1.json`](./CODE.v1.json) · [`specs/browser-extension/features/REVIEW.v1.json`](./REVIEW.v1.json) · [`specs/browser-extension/features/ISSUE.v1.json`](./ISSUE.v1.json) · [`specs/browser-extension/features/SEARCH.v1.json`](./SEARCH.v1.json) · [`specs/browser-extension/features/NOTIFY.v1.json`](./NOTIFY.v1.json) · [`specs/browser-extension/features/CI.v1.json`](./CI.v1.json) · [`specs/browser-extension/features/RELEASE.v1.json`](./RELEASE.v1.json) · [`specs/browser-extension/features/WORK.v1.json`](./WORK.v1.json) · [`specs/browser-extension/features/COPILOT.v1.json`](./COPILOT.v1.json) · [`specs/browser-extension/features/CENTER.v1.json`](./CENTER.v1.json) · [`specs/browser-extension/features/SETUP.v1.json`](./SETUP.v1.json) · [`docs/development/browser-extension.mdx`](../docs/development/browser-extension.mdx) · [`extension/README.md`](../extension/README.md) · [`docs/browser-extension/runtime.mdx`](../docs/browser-extension/runtime.mdx) +- User documentation: [`docs/development/specifications.mdx`](../docs/development/specifications.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) · [`specs/browser-extension/manifest-v1.schema.json`](./browser-extension/manifest-v1.schema.json) · [`specs/browser-extension/status-v1.schema.json`](./browser-extension/status-v1.schema.json) · [`specs/browser-extension/examples/manifest-v1.json`](./browser-extension/examples/manifest-v1.json) · [`specs/browser-extension/examples/status-pr-v1.json`](./browser-extension/examples/status-pr-v1.json) · [`specs/browser-extension/examples/status-issue-v1.json`](./browser-extension/examples/status-issue-v1.json) · [`specs/browser-extension/examples/status-invalid-head-v1.json`](./browser-extension/examples/status-invalid-head-v1.json) · [`specs/browser-extension/feature-catalog.v1.json`](./browser-extension/feature-catalog.v1.json) · [`specs/browser-extension/feature-catalog.schema.json`](./browser-extension/feature-catalog.schema.json) · [`specs/browser-extension/requests-v2.schema.json`](./browser-extension/requests-v2.schema.json) · [`specs/browser-extension/examples/requests-v2.json`](./browser-extension/examples/requests-v2.json) · [`specs/browser-extension/feature-catalog-index.schema.json`](./browser-extension/feature-catalog-index.schema.json) · [`specs/browser-extension/features/NAV.v1.json`](./browser-extension/features/NAV.v1.json) · [`specs/browser-extension/features/CODE.v1.json`](./browser-extension/features/CODE.v1.json) · [`specs/browser-extension/features/REVIEW.v1.json`](./browser-extension/features/REVIEW.v1.json) · [`specs/browser-extension/features/ISSUE.v1.json`](./browser-extension/features/ISSUE.v1.json) · [`specs/browser-extension/features/SEARCH.v1.json`](./browser-extension/features/SEARCH.v1.json) · [`specs/browser-extension/features/NOTIFY.v1.json`](./browser-extension/features/NOTIFY.v1.json) · [`specs/browser-extension/features/CI.v1.json`](./browser-extension/features/CI.v1.json) · [`specs/browser-extension/features/RELEASE.v1.json`](./browser-extension/features/RELEASE.v1.json) · [`specs/browser-extension/features/WORK.v1.json`](./browser-extension/features/WORK.v1.json) · [`specs/browser-extension/features/COPILOT.v1.json`](./browser-extension/features/COPILOT.v1.json) · [`specs/browser-extension/features/CENTER.v1.json`](./browser-extension/features/CENTER.v1.json) · [`specs/browser-extension/features/SETUP.v1.json`](./browser-extension/features/SETUP.v1.json) · [`docs/development/browser-extension.mdx`](../docs/development/browser-extension.mdx) · [`extension/README.md`](../extension/README.md) · [`docs/browser-extension/runtime.mdx`](../docs/browser-extension/runtime.mdx) ## Maintenance contract diff --git a/specs/catalog.json b/specs/catalog.json index e01557b09..52cfc6959 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -1647,9 +1647,9 @@ "id": "issue-start-and-sdd-readiness", "title": "Uniform issue start and pre-branch SDD readiness", "status": "implemented", - "scope": "Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons", + "scope": "Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons and preserve nested catalog specification links", "owner": "Copilot maintainers", - "lastVerified": "2026-10-27", + "lastVerified": "2026-10-29", "specs": [ "specs/issue-start-and-branch-readiness.md", "specs/pre-branch-sdd-gate.md", diff --git a/specs/pre-branch-sdd-gate.md b/specs/pre-branch-sdd-gate.md index 562843a83..7ce01ff16 100644 --- a/specs/pre-branch-sdd-gate.md +++ b/specs/pre-branch-sdd-gate.md @@ -3,7 +3,7 @@ - Status: Implemented — automated verification complete; live provider UX review pending - Date: 2026-09-17 - Catalog capability ID: `issue-start-and-sdd-readiness` -- Last verified: 2026-10-27 (actionable blocked preparation and safe diagnostics, #714) +- Last verified: 2026-10-29 (nested catalog links, #768; actionable blocked preparation and safe diagnostics, #714) - Owners: Copilot maintainers - Scope: clarify, update, validate, and publish the owning SDD before eligible branch-based work - Related issues/PRs: none; local design work; companion SDD `issue-start-and-branch-readiness.md` @@ -1728,3 +1728,74 @@ and remain blocked pending a reviewed compatible adapter. The disabled-by-defaul opt-in, no-deployment policy, remote branch ownership and human approval gates are unchanged. Rollback restores the prior visibility/compatibility defect; retained data and historical publication are never rewritten by this fix. + + +### Nested catalog specification links — #768 (2026-10-29) + +**Observed defect and intentional contract.** The portable validator accepts +nested regular SDDs, but the catalog renderer previously kept only their +basename. For example, `specs/group/example.md` incorrectly linked to +`./example.md`. Both the primary-SDD table and companion evidence links were +affected; equal filenames in separate groups could silently select the wrong +contract. See [#768](https://github.com/vypdev/copilot/issues/768). + +The generated `specs/CATALOG.md` MUST preserve the full path after `specs/` in +all primary and companion specification links. Root-level links MUST remain +compatible, and other evidence MUST retain its repository-relative `../` target. +Registered metadata, source files, titles, companion counts and ownership MUST +remain unchanged. This corrects the existing supported layout contract; it adds +no new layout policy or status/readiness semantics. + +| Registered path (example) | Catalog-relative target | +|---|---| +| `specs/example.md` | `./example.md` | +| `specs/group/example.md` | `./group/example.md` | +| `specs/other/example.md` | `./other/example.md` | +| `docs/guide.mdx` | `../docs/guide.mdx` | + +Text equivalent: remove the leading `specs/` from specification targets and +retain the remaining directory path; other evidence remains relative to the +repository root. Equal basenames in different directories stay distinct. + +**Architecture, configuration and safety.** The existing pure `renderCatalog` +and `renderPathLinks` functions own this presentation mapping. Validation, +filesystem containment, unique ownership, publication allowlists, immutable +revisions, state, adapters and permissions keep their existing responsibilities. +No configuration or schema field changes. Existing invalid catalogs continue to +fail through the same validation/recovery path; this renderer does not admit new +files or read paths. Existing architecture and specification contracts remain +executable gates. + +**UX, recovery and compatibility.** The generated Markdown table and evidence +map keep their labels, ordering and companion counts. Their links now open the +intended nested SDD; for example `[Example](./group/example.md)`. No new pending, +blocked, partial, failure or complete operation state, notification, localization +string or interaction is introduced; existing gate/CLI states apply unchanged. +Regenerating the catalog repairs existing flattened links without moving source +files or changing metadata. Rollback reverts rendering without a data migration; +previously generated nested links would again require repair. Automated target +resolution covers this static-link correction; no new manual UI flow is claimed. + +Allocate **seven additional distinct cases** in +`portable_specification_catalog.test.ts`: root primary, nested primary, nested +primary plus companion, equal basenames in separate directories, deeply nested +primary, root primary with an equal-name nested companion, and unchanged +non-specification evidence links. The six specification fixtures MUST pass the +real catalog validator, assert exact table/evidence targets, and resolve every +rendered relative target to an existing regular file in a temporary repository. +Five nested cases fail before the fix; root and non-spec controls stay green. +All existing unsafe-path and symlink cases remain required. Fixtures use regular +files and execute no consumer toolchain. Existing global/module coverage limits +remain mandatory. + +**Traceability and DoD.** The seven renderer cases cover the mapping and +compatibility requirements; the existing repository-catalog and branch-sync +suites cover generated-output and integration compatibility. Documentation in +`docs/development/specifications.mdx` explains nested links and regeneration; +`docs/issues/pre-branch-sdds.mdx` retains layout/support and recovery guidance. +Catalog metadata/evidence and its generated view are updated with this change. +Acceptance requires all seven cases, unchanged path/security tests, branch-sync +checks, specification generation/validation, typecheck/lint, documentation and +workflow contracts, generated bundles and package smoke, required CI coverage, +platform checks and exact-head review without unresolved findings before merge. +No unresolved design decision or new operator permission blocks this fix. diff --git a/src/infrastructure/specification_catalog_validator.cjs b/src/infrastructure/specification_catalog_validator.cjs index 7e32e203d..a7785b28c 100644 --- a/src/infrastructure/specification_catalog_validator.cjs +++ b/src/infrastructure/specification_catalog_validator.cjs @@ -198,8 +198,8 @@ function renderCatalog(catalog) { const primarySpec = capability.specs[0]; const companionCount = capability.specs.length - 1; const specLabel = companionCount > 0 - ? `[${escapeCell(capability.title)}](./${path.posix.basename(primarySpec)}) + ${companionCount} companion` - : `[${escapeCell(capability.title)}](./${path.posix.basename(primarySpec)})`; + ? `[${escapeCell(capability.title)}](./${primarySpec.slice('specs/'.length)}) + ${companionCount} companion` + : `[${escapeCell(capability.title)}](./${primarySpec.slice('specs/'.length)})`; const evidenceCount = capability.workflows.length + capability.entrypoints.length + capability.code.length @@ -258,7 +258,7 @@ function renderPathLinks(paths) { if (paths.length === 0) return 'Not applicable for this capability.'; return paths.map(relativePath => { const target = relativePath.startsWith('specs/') - ? `./${path.posix.basename(relativePath)}` + ? `./${relativePath.slice('specs/'.length)}` : `../${relativePath}`; return `[\`${relativePath}\`](${target})`; }).join(' · '); diff --git a/src/tooling/__tests__/portable_specification_catalog.test.ts b/src/tooling/__tests__/portable_specification_catalog.test.ts index 985c604e3..7c3076313 100644 --- a/src/tooling/__tests__/portable_specification_catalog.test.ts +++ b/src/tooling/__tests__/portable_specification_catalog.test.ts @@ -1,10 +1,10 @@ -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { PORTABLE_SDD_LAYOUTS, portableSddCapability, writePortableSddEvidence } from '../../testing/portable_sdd_catalog_fixture'; import { canCreateFileSymlink } from '../../testing/file_symlink_capability'; -const { validateCatalog, readCatalog, isSafeRelativePath } = require('../../../scripts/validate-specification-catalog.cjs'); +const { validateCatalog, readCatalog, renderCatalog, isSafeRelativePath } = require('../../../scripts/validate-specification-catalog.cjs'); const symlinkIt = canCreateFileSymlink() ? it : it.skip; describe('portable catalog evidence and filesystem containment', () => { @@ -16,6 +16,45 @@ describe('portable catalog evidence and filesystem containment', () => { }); afterEach(() => rmSync(root, { recursive: true, force: true })); + it.each([ + ['root specification', ['specs/payments.md']], + ['nested primary', ['specs/group/payments.md']], + ['nested primary and companion', ['specs/group/payments.md', 'specs/group/companion.md']], + ['same basename in separate directories', ['specs/group/payments.md', 'specs/other/payments.md']], + ['deeply nested primary', ['specs/group/deep/payments.md']], + ['root primary and same-name nested companion', ['specs/payments.md', 'specs/group/payments.md']], + ])('preserves catalog-relative links for %s', (_name, specs) => { + rmSync(join(root, 'specs/payments.md')); + const capability = { ...portableSddCapability(), specs: specs as string[] }; + writePortableSddEvidence(root, capability); + for (const file of capability.specs) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), '# Proposed specification\n'); + } + const catalog = { version: 1, capabilities: [capability] }; + writeFileSync(join(root, 'specs/catalog.json'), JSON.stringify(catalog)); + expect(validateCatalog(root, catalog)).toEqual([]); + const rendered = renderCatalog(catalog); + const primary = capability.specs[0].slice('specs/'.length); + expect(rendered).toContain(`[Payments](./${primary})`); + if (capability.specs.length > 1) expect(rendered).toContain(' + 1 companion'); + for (const file of capability.specs) { + expect(rendered).toContain(`[\`${file}\`](./${file.slice('specs/'.length)})`); + } + const links = [...rendered.matchAll(/\]\((\.[^)]+)\)/gu)].map(match => match[1]); + expect(links.length).toBeGreaterThan(capability.specs.length); + for (const target of links) expect(statSync(resolve(root, 'specs', target)).isFile()).toBe(true); + }); + + it('keeps non-specification evidence relative to the repository root', () => { + const capability = portableSddCapability(); + writePortableSddEvidence(root, capability); + const rendered = renderCatalog({ version: 1, capabilities: [capability] }); + for (const file of [...capability.entrypoints, ...capability.code, ...capability.tests, ...capability.documentation]) { + expect(rendered).toContain(`[\`${file}\`](../${file})`); + } + }); + it.each(PORTABLE_SDD_LAYOUTS)('accepts existing %s metadata without executing its toolchain', (_name, source, test, doc) => { const capability = { ...portableSddCapability(), entrypoints: [String(source)], code: [String(source)], tests: [String(test)], documentation: [String(doc)] }; writePortableSddEvidence(root, capability);