diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/artifact-cli.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/artifact-cli.test.mjs index 66a0ddd..5b96a5a 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/artifact-cli.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/artifact-cli.test.mjs @@ -246,7 +246,6 @@ describe("buildCompositionFromCli", () => { assert.equal(winner.hidden, false); assert.equal(winner.manifestPath, ".specify/presets/compliance/preset.yml"); assert.equal(winner.lookupId, "preset:compliance:command:speckit.plan"); - // Hidden built-in layer. const built = cmd.stack[1]; assert.equal(built.layer, "core"); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/compute-provider-contributions.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/compute-provider-contributions.test.mjs new file mode 100644 index 0000000..c626f43 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/compute-provider-contributions.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { computeProviderContributions } from "../ui/composition.js"; + +// computeProviderContributions buckets stack layers by provider id, tallying +// "customized" (core-inventory overrides) vs "added" (new) contributions. +// Bucket key resolution: prefer parseLookupId(layer.lookupId)?.providerId, +// falling back to sourceId for wizard-synthesized hook layers (which carry +// lookupId: null). + +describe("computeProviderContributions", () => { + test("buckets a preset winner by lookupId providerId", () => { + const artifacts = [ + { + id: "commands/speckit.plan", + kind: "command", + stack: [ + { layer: "preset", presetId: "compliance", lookupId: "preset:compliance:command:speckit.plan" }, + ], + }, + ]; + const contributions = computeProviderContributions(artifacts); + assert.ok(contributions.has("compliance")); + }); + + test("buckets an extension layer by lookupId providerId", () => { + const artifacts = [ + { + id: "templates/spec.md", + kind: "template", + stack: [ + { layer: "extension", extensionId: "foo", lookupId: "extension:foo:template:spec.md" }, + ], + }, + ]; + const contributions = computeProviderContributions(artifacts); + assert.ok(contributions.has("foo")); + }); + + test("falls back to sourceId for hook-synthetic layers with lookupId: null", () => { + const artifacts = [ + { + id: "commands/some-hook-command", + kind: "hook", + hookBindings: [{ phase: "after_specify" }], + stack: [ + { layer: "extension", sourceId: "hooks-ext", lookupId: null }, + ], + }, + ]; + const contributions = computeProviderContributions(artifacts); + assert.ok(contributions.has("hooks-ext")); + }); + + test("lookupId wins when a legacy presetId is also present", () => { + const artifacts = [ + { + id: "commands/speckit.plan", + kind: "command", + stack: [ + { + layer: "preset", + presetId: "legacy-id", + lookupId: "preset:compliance:command:speckit.plan", + }, + ], + }, + ]; + const contributions = computeProviderContributions(artifacts); + assert.ok(contributions.has("compliance")); + assert.ok(!contributions.has("legacy-id")); + }); +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/lookup-id.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/lookup-id.test.mjs new file mode 100644 index 0000000..8c199d7 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/lookup-id.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { parseLookupId } from "../ui/lookup-id.mjs"; + +describe("parseLookupId", () => { + test("parses a preset command lookupId", () => { + assert.deepEqual( + parseLookupId("preset:compliance:command:speckit.plan"), + { providerKind: "preset", providerId: "compliance", kind: "command", name: "speckit.plan" }, + ); + }); + + test("parses an extension template lookupId", () => { + assert.deepEqual( + parseLookupId("extension:foo:template:spec.md"), + { providerKind: "extension", providerId: "foo", kind: "template", name: "spec.md" }, + ); + }); + + test("treats colons in as opaque tail", () => { + const parsed = parseLookupId("preset:x:command:has:colons:in:name"); + assert.equal(parsed.name, "has:colons:in:name"); + assert.equal(parsed.providerId, "x"); + assert.equal(parsed.kind, "command"); + }); + + test("returns null for null/empty/garbage/unknown-provider-kind input", () => { + assert.equal(parseLookupId(null), null); + assert.equal(parseLookupId(""), null); + assert.equal(parseLookupId("garbage"), null); + assert.equal(parseLookupId("core:x:y:z"), null); + assert.equal(parseLookupId(undefined), null); + assert.equal(parseLookupId("preset:x:y"), null); + }); +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/composition.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/composition.js index 41d4ff0..21ba34f 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/composition.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/composition.js @@ -13,6 +13,7 @@ import { renderCompositionArtifacts, setArtifactRowsDeps, } from "./composition-artifacts.js"; +import { parseLookupId } from "./lookup-id.mjs"; // -------- Section: composition/layers.mjs -------- // Single source of truth for the composition layer-stack order. @@ -169,9 +170,9 @@ export function computeProviderContributions(artifacts) { const seen = new Set(); for (const layer of a.stack ?? []) { if (layer.layer !== "preset" && layer.layer !== "extension") continue; - const id = layer.layer === "extension" - ? layer.sourceId - : layer.presetId; + // Hook layers are synthesized locally and have no CLI lookupId. + const id = parseLookupId(layer.lookupId)?.providerId + ?? layer.sourceId; if (!id || seen.has(id)) continue; seen.add(id); let bucket = out.get(id); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/lookup-id.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/lookup-id.mjs new file mode 100644 index 0000000..fbecb0c --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/lookup-id.mjs @@ -0,0 +1,26 @@ +// Pure helpers for the deterministic `lookupId` field carried on composition +// stack layers. +// +// Format: `preset:::` or `extension:::`, +// `null` for core (built-in) layers. Stable across reinstalls; NOT a CLI +// round-trip key (do not send it back to `specify` commands). + +const KNOWN_PROVIDER_KINDS = new Set(["preset", "extension"]); + +// Parse a `lookupId` string into its constituent parts. Returns `null` for +// anything that isn't a recognized `preset:`/`extension:` lookupId (including +// `null`, empty string, garbage, or an unknown provider kind like `core:...`). +// +// Colons inside `` are legal — everything after the third colon is +// treated as the opaque `name` tail (locked decision: do not split further). +export function parseLookupId(lookupId) { + if (typeof lookupId !== "string" || lookupId.length === 0) return null; + const parts = lookupId.split(":"); + if (parts.length < 4) return null; + const [providerKind, providerId, kind, ...nameParts] = parts; + if (!KNOWN_PROVIDER_KINDS.has(providerKind)) return null; + if (!providerId || !kind) return null; + const name = nameParts.join(":"); + if (!name) return null; + return { providerKind, providerId, kind, name }; +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js index 10215e1..f901a39 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js @@ -873,26 +873,24 @@ export function renderMoreCommandsPanel() { } // Resolve the on-disk markdown path for a command tile, when known. -// Priority: -// 1. composition activeLayer.sourcePath (accurate — includes preset overrides). -// 2. derived preset path from `p.source` + `p.commandName`. -// Returns null when the file isn't on disk (e.g. synthesized core-only commands). +// The artifact CLI supplies the winning layer's sourcePath. Returns null when +// the command has no composition entry or no on-disk source. export function commandSourcePath(p) { if (!p) return null; - const activeLayer = lookupActiveLayer(p.id, p.commandName); - if (activeLayer?.sourcePath) return activeLayer.sourcePath; - // Derive from `source: "preset:"` for preset-only commands - // that don't have composition entries (game-narrative extras). - if (typeof p.source === "string" && p.source.startsWith("preset:") && p.commandName) { - const presetId = p.source.slice("preset:".length).split(":")[0]; - return `.specify/presets/${presetId}/commands/${p.commandName}.md`; - } - return null; -} - -// Look up the winning composition layer for a command id (either "commands/" or a phase id). -export function lookupActiveLayer(id, commandName) { - const compArtifacts = state.snapshot?.composition?.artifacts ?? []; + const activeLayer = lookupActiveLayerForCommand(p); + return activeLayer?.sourcePath ?? null; +} + +// Look up the winning composition layer for a phase, using phase-discovery +// semantics: prefer the "commands/" artifact, falling back to +// the phase `id` (either "commands/" or a bare phase id). +// `snapshot` defaults to the global state snapshot for UI-only callers; +// snapshot-pure callers (e.g. resolvePipelineEntry) must pass their own so +// the resolved layer comes from the same snapshot as the rest of the data. +export function lookupActiveLayerForCommand(p, snapshot = state.snapshot) { + const id = p?.id; + const commandName = p?.commandName; + const compArtifacts = snapshot?.composition?.artifacts ?? []; const cmdLookupId = commandName ? `commands/${commandName}` : null; const compArtifact = (cmdLookupId && compArtifacts.find((a) => a.id === cmdLookupId)) ||