Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
});
});
Original file line number Diff line number Diff line change
@@ -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 <name> 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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Pure helpers for the deterministic `lookupId` field carried on composition
// stack layers.
//
// Format: `preset:<presetId>:<kind>:<name>` or `extension:<extId>:<kind>:<name>`,
// `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 `<name>` 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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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:<presetId>"` 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/<name>" or a phase id).
export function lookupActiveLayer(id, commandName) {
const compArtifacts = state.snapshot?.composition?.artifacts ?? [];
const activeLayer = lookupActiveLayerForCommand(p);
return activeLayer?.sourcePath ?? null;
Comment thread
nicolehaugen marked this conversation as resolved.
}

// Look up the winning composition layer for a phase, using phase-discovery
// semantics: prefer the "commands/<commandName>" artifact, falling back to
// the phase `id` (either "commands/<name>" 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)) ||
Expand Down