Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions build/cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(' · ');
Expand Down
6 changes: 3 additions & 3 deletions build/github_action/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(' · ');
Expand Down
8 changes: 8 additions & 0 deletions docs/development/specifications.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions specs/CATALOG.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions specs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 72 additions & 1 deletion specs/pre-branch-sdd-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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.
6 changes: 3 additions & 3 deletions src/infrastructure/specification_catalog_validator.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(' · ');
Expand Down
45 changes: 42 additions & 3 deletions src/tooling/__tests__/portable_specification_catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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);
Expand Down
Loading