mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-11-07 09:14:38 +08:00
* fix(skills): stop workflows from adopting a project that never ran init Generated skills and commands are installed once per machine and offered in every repository the agent opens, including ones with no OpenSpec at all. Nothing stopped the workflow there: root resolution falls back to an implicit root at the current directory, so `openspec new change` quietly creates `openspec/` in whatever repo the agent happened to be standing in (#1645). Two changes, both in the generated instructions: - Every workflow now carries a shared project check. Before the first step that writes, the agent reads `root.source` from `openspec status --json`; `implicit` (or a `No OpenSpec root found` error) means the project is not set up, and the agent stops and asks the user whether to run `openspec init`, target a store, or drop OpenSpec for that request. It may not initialize the project on its own or let a command create the root as a side effect. - Every deployed skill description now names OpenSpec. Hosts pick skills by description, and "Enter explore mode - a thinking partner..." reads as a generic offer in a repository that has never heard of OpenSpec. Closes #1645 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(changeset): note the uninitialized-project guard Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(skills): let onboarding run init once the user asks for it The guard read as an absolute ban on `openspec init`, which contradicts the option it offers one sentence earlier and the onboard workflow's job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(troubleshooting): explain an OpenSpec workflow starting in an unset-up project Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(skills): check the root with a command that never fabricates one `openspec status --json` demands --change once a project has changes, so the guard's own check could fail in exactly the projects it should wave through. `openspec list --json` answers in one shape everywhere: a root object when the project is set up, `root: null` both when nothing is set up and when only stores are registered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(skills): pin the guard against every write, not the first fence CodeRabbit's point: checking only the first ```bash fence would miss a write outside a fence. Assert instead that nothing preceding the guard runs a command or writes, and that the guard sits directly under the store-selection guidance - both fail when the guard is moved down a workflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(new-change): say when the command had to create the root itself The generated workflows now check for a root before writing, but the guard is instructions - an agent that ignores it, or a human running the CLI directly, still turned an unset-up directory into an OpenSpec project without a word. Creating the root stays zero-config; it is no longer silent. Human output only: --json is unchanged, and `root.source` already carried the same fact for programmatic callers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(skills): tell agents the root check's non-zero exit is the answer `openspec list --json` exits 1 when there is no root. An agent that reads that as a broken CLI is one step from hand-creating `openspec/` instead, which is the failure the guard exists to prevent. Also drops a vacuous assertion: the notice test now checks that the note names the directory it created and that the change really landed there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(skills): read the guard back and untangle its two 'in that case' clauses Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(skills): carry the store flag into the root check; pin the notice path exactly CodeRabbit, both valid: - With a store selected the store IS the root, so the check has to run as `openspec list --json --store <id>`. The store-selection paragraph above already says to append the flag to every command it lists, but leaving it implicit here invited a check against the wrong directory. - The notice assertion matched any `openspec/` suffix. It now pins the exact rendered path, and a new case runs the command from a subdirectory to show the note names the directory actually adopted (and that the repo above it is left alone). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: move the no-root contract to the canonical docs-lab pages alfred-openspec on #1787: docs-lab/README.md makes docs-lab/ canonical and the old docs/ tree legacy, and the canonical pages were stale in the two places the review named. - docs-lab/reference/cli.md, 'openspec new': documents the implicit-root notice after the 'Next:' line, with the exact output the CLI prints, that it goes to stdout and never appears with --json, and that JSON carries the same fact as root.source: implicit. Verified against a real run in an empty directory with an isolated HOME. - docs-lab/reference/skills.md: states the shared response and stop behavior once, above the index table, since it now holds for every skill: confirm the resolved root before the first write, stop when there is none, offer init, a store, or dropping OpenSpec, wait for the answer, never create openspec/ on its own. Drops the legacy docs/troubleshooting.md addition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(skills): make the no-root answer depend on how the workflow was reached alfred-openspec's product call on #1787. One answer could not serve both arrivals: #1645 asks the workflow to get out of the way ('it can go through the normal general propose not the openspec'), while a user who typed the skill's name is owed an answer about OpenSpec. The guard now branches after the same `openspec list --json` check: - Auto-selected: the model picked this workflow without the user naming OpenSpec, naming the skill, or running its command. Drop OpenSpec and answer the request normally, with no setup question and no mention of OpenSpec. - Explicit OpenSpec request: stop before writing and ask whether to run `openspec init`, target a store, or continue without OpenSpec, then wait. Neither branch may create the root as a side effect, stated once for both. One text serves both surfaces rather than a command-only variant, because apply-change and onboard render a single body into the skill and the command alike; a command-only constant would mean threading a surface flag through bodies that deliberately have none (#1515). The bullets scope themselves instead, and a slash command is an explicit invocation, so only the ask branch can apply there. A test pins that branch reaching every generated opsx command. Four regressions: the auto-selected branch (asserting it does not mention `openspec init` or `--store`), the explicit branch, the explicit branch's presence in every command file, and the shared no-side-effect rule. docs-lab/reference/skills.md said every no-root invocation asks. It now carries the same two branches as scan anchors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(skills): keep the root guard off store-only projects and fix its docs A store-only project whose `store:` line names a store this machine has not registered reports `"root": null` from `openspec list --json`, so the guard read a real OpenSpec project as uninitialized. The guard now checks for the `Declared in` status message first and shows the store error instead. A stale global defaultStore reports the same codes in unrelated repositories, which is why the message prefix, not the code, decides. Propose's context step from #1657 offered `openspec init` on `no_openspec_root` regardless of how the workflow was reached. It now defers to the project check, so an auto-selected propose skill stays silent. The changeset names both no-root branches, and the `new change --json` docs separate the initialized example from the verified `implicit` one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(skills): keep the root guard off projects with a malformed store line A config-only project whose `store:` line is malformed reports `"root": null` with an `Invalid store declaration in` message, not `Declared in`, so the project check read it as never initialized and would drop OpenSpec or offer `openspec init` there. The guard now names both prefixes, and a root-selection test pins that every declaration failure starts with one of them while a stale global defaultStore starts with neither. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(skills): check guard ordering in the skill body, not its frontmatter A skill's YAML frontmatter is metadata a host reads to choose the skill, not instructions the agent runs, so a description that quotes a command name must not trip the ordering check. Scope the scan to the text after the closing frontmatter delimiter; a command injected into the body ahead of the guard still fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
652 lines
25 KiB
TypeScript
652 lines
25 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
resolveOpenSpecRoot,
|
|
RootSelectionError,
|
|
} from '../../src/core/root-selection.js';
|
|
import {
|
|
writeStoreMetadataState,
|
|
writeStoreRegistryState,
|
|
} from '../../src/core/store/foundation.js';
|
|
import { saveGlobalConfig } from '../../src/core/global-config.js';
|
|
|
|
describe('resolveOpenSpecRoot', () => {
|
|
let tempDir: string;
|
|
let globalDataDir: string;
|
|
let savedXdgDataHome: string | undefined;
|
|
let savedXdgConfigHome: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.realpathSync.native(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-root-selection-'))
|
|
);
|
|
globalDataDir = path.join(tempDir, 'global-data');
|
|
// Backstop: store calls below thread `globalDataDir`, but if a future
|
|
// edit forgets one, the path resolver falls back to XDG_DATA_HOME and
|
|
// then to the real ~/.local/share/openspec. Pin XDG at the temp dir so
|
|
// a missed arg can never pollute the developer's home registry.
|
|
savedXdgDataHome = process.env.XDG_DATA_HOME;
|
|
process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg');
|
|
// Root resolution now reads the global config for `defaultStore`. Pin
|
|
// XDG_CONFIG_HOME at an empty temp dir so tests never see the
|
|
// developer's real ~/.config/openspec/config.json.
|
|
savedXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
process.env.XDG_CONFIG_HOME = path.join(tempDir, 'xdg-config');
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedXdgDataHome === undefined) {
|
|
delete process.env.XDG_DATA_HOME;
|
|
} else {
|
|
process.env.XDG_DATA_HOME = savedXdgDataHome;
|
|
}
|
|
if (savedXdgConfigHome === undefined) {
|
|
delete process.env.XDG_CONFIG_HOME;
|
|
} else {
|
|
process.env.XDG_CONFIG_HOME = savedXdgConfigHome;
|
|
}
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function setDefaultStore(id: string): void {
|
|
saveGlobalConfig({ defaultStore: id });
|
|
}
|
|
|
|
function mkdir(relativePath: string): string {
|
|
const dir = path.join(tempDir, relativePath);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
function createOpenSpecRoot(rootDir: string): void {
|
|
fs.mkdirSync(path.join(rootDir, 'openspec', 'specs'), { recursive: true });
|
|
fs.mkdirSync(path.join(rootDir, 'openspec', 'changes', 'archive'), { recursive: true });
|
|
fs.writeFileSync(path.join(rootDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n');
|
|
}
|
|
|
|
async function registerStore(
|
|
id: string,
|
|
options: { healthyRoot?: boolean; metadataId?: string | null } = {}
|
|
): Promise<string> {
|
|
const storeRoot = mkdir(`stores/${id}`);
|
|
if (options.healthyRoot !== false) {
|
|
createOpenSpecRoot(storeRoot);
|
|
}
|
|
if (options.metadataId !== null) {
|
|
await writeStoreMetadataState(storeRoot, {
|
|
version: 1,
|
|
id: options.metadataId ?? id,
|
|
});
|
|
}
|
|
|
|
const existing = fs.existsSync(path.join(globalDataDir, 'stores', 'registry.yaml'));
|
|
const registryStores = existing
|
|
? (await import('../../src/core/store/foundation.js').then((m) =>
|
|
m.readStoreRegistryState({ globalDataDir })
|
|
))?.stores ?? {}
|
|
: {};
|
|
|
|
await writeStoreRegistryState(
|
|
{
|
|
version: 1,
|
|
stores: {
|
|
...registryStores,
|
|
[id]: { backend: { type: 'git', local_path: storeRoot } },
|
|
},
|
|
},
|
|
{ globalDataDir }
|
|
);
|
|
|
|
return storeRoot;
|
|
}
|
|
|
|
async function expectRootSelectionError(
|
|
promise: Promise<unknown>,
|
|
code: string
|
|
): Promise<RootSelectionError> {
|
|
let caught: unknown;
|
|
try {
|
|
await promise;
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
expect(caught).toBeInstanceOf(RootSelectionError);
|
|
const error = caught as RootSelectionError;
|
|
expect(error.diagnostic.code).toBe(code);
|
|
return error;
|
|
}
|
|
|
|
it('resolves a selected store to its healthy OpenSpec root', async () => {
|
|
const storeRoot = await registerStore('team-context');
|
|
|
|
const root = await resolveOpenSpecRoot({ store: 'team-context', globalDataDir });
|
|
|
|
expect(root.source).toBe('store');
|
|
expect(root.storeId).toBe('team-context');
|
|
expect(root.path).toBe(storeRoot);
|
|
expect(root.changesDir).toBe(path.join(storeRoot, 'openspec', 'changes'));
|
|
expect(root.specsDir).toBe(path.join(storeRoot, 'openspec', 'specs'));
|
|
expect(root.archiveDir).toBe(path.join(storeRoot, 'openspec', 'changes', 'archive'));
|
|
expect(root.defaultSchema).toBe('spec-driven');
|
|
});
|
|
|
|
it('rejects an unknown store id and lists registered ids', async () => {
|
|
await registerStore('team-context');
|
|
|
|
const error = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ store: 'team-contxt', globalDataDir }),
|
|
'unknown_store'
|
|
);
|
|
expect(error.message).toContain("'team-contxt'");
|
|
expect(error.message).toContain('team-context');
|
|
});
|
|
|
|
it('rejects --store when no stores are registered without suggesting --store-path', async () => {
|
|
const error = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ store: 'team-context', globalDataDir }),
|
|
'no_registered_stores'
|
|
);
|
|
expect(error.message).not.toContain('--store-path');
|
|
expect(error.diagnostic.fix).not.toContain('--store-path');
|
|
});
|
|
|
|
it('rejects an invalid store id format before registry lookup', async () => {
|
|
// No registry exists at all; format validation must win.
|
|
const error = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ store: 'Bad/Id', globalDataDir }),
|
|
'invalid_store_id'
|
|
);
|
|
expect(error.message).toContain('Store id');
|
|
});
|
|
|
|
it('rejects an unhealthy store root without repairing it', async () => {
|
|
const storeRoot = await registerStore('team-context', { healthyRoot: false });
|
|
|
|
const error = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ store: 'team-context', globalDataDir }),
|
|
'unhealthy_store_root'
|
|
);
|
|
expect(error.diagnostic.fix).toContain('store doctor');
|
|
// No scaffolding or repair happened.
|
|
expect(fs.existsSync(path.join(storeRoot, 'openspec'))).toBe(false);
|
|
});
|
|
|
|
it('rejects a store whose metadata id does not match the registry id', async () => {
|
|
await registerStore('team-context', { metadataId: 'other-context' });
|
|
|
|
const error = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ store: 'team-context', globalDataDir }),
|
|
'store_identity_mismatch'
|
|
);
|
|
expect(error.message).toContain('other-context');
|
|
expect(error.diagnostic.fix).toContain('store doctor');
|
|
});
|
|
|
|
it('rejects a store with missing identity metadata before root-health checks', async () => {
|
|
// Root is also unhealthy; the identity failure must win.
|
|
await registerStore('team-context', { healthyRoot: false, metadataId: null });
|
|
|
|
const error = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ store: 'team-context', globalDataDir }),
|
|
'store_identity_mismatch'
|
|
);
|
|
expect(error.diagnostic.fix).toContain('store doctor');
|
|
});
|
|
|
|
it('rejects --store-path deliberately with register guidance', async () => {
|
|
const error = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ storePath: '/somewhere', globalDataDir }),
|
|
'store_path_not_supported'
|
|
);
|
|
expect(error.message).toContain('store register');
|
|
expect(error.message).toContain('--store <id>');
|
|
});
|
|
|
|
it('resolves the nearest openspec root without --store', async () => {
|
|
const repoRoot = mkdir('app-repo');
|
|
createOpenSpecRoot(repoRoot);
|
|
const nested = mkdir('app-repo/src/deep');
|
|
|
|
const root = await resolveOpenSpecRoot({ startPath: nested, globalDataDir });
|
|
|
|
expect(root.source).toBe('nearest');
|
|
expect(root.path).toBe(repoRoot);
|
|
});
|
|
|
|
it('ignores leftover workspace view state when a nearest root exists', async () => {
|
|
const workspaceDir = mkdir('workspace');
|
|
fs.mkdirSync(path.join(workspaceDir, '.openspec-workspace'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(workspaceDir, '.openspec-workspace', 'view.yaml'),
|
|
'version: 1\nname: platform\ncontext: null\nlinks: {}\n'
|
|
);
|
|
const repoRoot = mkdir('workspace/app-repo');
|
|
createOpenSpecRoot(repoRoot);
|
|
const nested = mkdir('workspace/app-repo/src');
|
|
|
|
const root = await resolveOpenSpecRoot({ startPath: nested, globalDataDir });
|
|
|
|
expect(root.source).toBe('nearest');
|
|
expect(root.path).toBe(repoRoot);
|
|
expect(root.changesDir).toBe(path.join(repoRoot, 'openspec', 'changes'));
|
|
expect(root.defaultSchema).toBe('spec-driven');
|
|
});
|
|
|
|
it('treats workspace state alone as no root at all', async () => {
|
|
const workspaceDir = mkdir('workspace-only');
|
|
fs.mkdirSync(path.join(workspaceDir, '.openspec-workspace'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(workspaceDir, '.openspec-workspace', 'view.yaml'),
|
|
'version: 1\nname: platform\ncontext: null\nlinks: {}\n'
|
|
);
|
|
|
|
const root = await resolveOpenSpecRoot({ startPath: workspaceDir, globalDataDir });
|
|
|
|
expect(root.source).toBe('implicit');
|
|
expect(root.path).toBe(workspaceDir);
|
|
});
|
|
|
|
it('fails with a store-selection hint when no root exists but stores are registered', async () => {
|
|
await registerStore('team-context');
|
|
const appRepo = mkdir('plain-app');
|
|
|
|
const error = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ startPath: appRepo, globalDataDir }),
|
|
'no_root_with_registered_stores'
|
|
);
|
|
expect(error.message).toContain('team-context');
|
|
expect(error.message).toContain('--store <id>');
|
|
expect(error.message).toContain('openspec init');
|
|
// No scaffolding happened.
|
|
expect(fs.existsSync(path.join(appRepo, 'openspec'))).toBe(false);
|
|
});
|
|
|
|
it('allows an implicit root only when requested', async () => {
|
|
const appRepo = mkdir('implicit-app');
|
|
|
|
const implicitRoot = await resolveOpenSpecRoot({ startPath: appRepo, globalDataDir });
|
|
expect(implicitRoot.source).toBe('implicit');
|
|
expect(implicitRoot.path).toBe(appRepo);
|
|
|
|
await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ startPath: appRepo, globalDataDir, allowImplicitRoot: false }),
|
|
'no_openspec_root'
|
|
);
|
|
});
|
|
|
|
it('prefers the selected store over a nearby root and leftover workspace state', async () => {
|
|
const storeRoot = await registerStore('team-context');
|
|
const repoRoot = mkdir('local-repo');
|
|
createOpenSpecRoot(repoRoot);
|
|
fs.mkdirSync(path.join(repoRoot, '.openspec-workspace'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(repoRoot, '.openspec-workspace', 'view.yaml'),
|
|
'version: 1\nname: platform\ncontext: null\nlinks: {}\n'
|
|
);
|
|
|
|
const root = await resolveOpenSpecRoot({
|
|
store: 'team-context',
|
|
startPath: repoRoot,
|
|
globalDataDir,
|
|
});
|
|
|
|
expect(root.source).toBe('store');
|
|
expect(root.path).toBe(storeRoot);
|
|
});
|
|
|
|
describe('declared store fallback (3.2)', () => {
|
|
function createPointerDir(relativePath: string, configBody: string): string {
|
|
const dir = mkdir(relativePath);
|
|
fs.mkdirSync(path.join(dir, 'openspec'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'openspec', 'config.yaml'), configBody);
|
|
return dir;
|
|
}
|
|
|
|
it('resolves a config-only pointer to the declared store', async () => {
|
|
const storeRoot = await registerStore('team-context');
|
|
const pointerDir = createPointerDir('app-repo', 'store: team-context\n');
|
|
|
|
const root = await resolveOpenSpecRoot({ startPath: pointerDir, globalDataDir });
|
|
|
|
expect(root.source).toBe('declared');
|
|
expect(root.storeId).toBe('team-context');
|
|
expect(root.path).toBe(storeRoot);
|
|
// The pointer dir is untouched.
|
|
expect(fs.existsSync(path.join(pointerDir, 'openspec', 'specs'))).toBe(false);
|
|
expect(fs.existsSync(path.join(pointerDir, 'openspec', 'changes'))).toBe(false);
|
|
});
|
|
|
|
it('lets explicit --store beat the pointer with source store', async () => {
|
|
await registerStore('team-context');
|
|
const otherRoot = await registerStore('other-context');
|
|
const pointerDir = createPointerDir('app-repo', 'store: team-context\n');
|
|
|
|
const root = await resolveOpenSpecRoot({
|
|
startPath: pointerDir,
|
|
store: 'other-context',
|
|
globalDataDir,
|
|
});
|
|
|
|
expect(root.source).toBe('store');
|
|
expect(root.path).toBe(otherRoot);
|
|
});
|
|
|
|
it('never overrides a real root and warns once about the ignored pointer', async () => {
|
|
await registerStore('team-context');
|
|
const repo = mkdir('real-repo');
|
|
createOpenSpecRoot(repo);
|
|
fs.writeFileSync(
|
|
path.join(repo, 'openspec', 'config.yaml'),
|
|
'schema: spec-driven\nstore: team-context\n'
|
|
);
|
|
|
|
const warnings: string[] = [];
|
|
const original = console.error;
|
|
console.error = (message: string) => warnings.push(String(message));
|
|
try {
|
|
const root = await resolveOpenSpecRoot({ startPath: repo, globalDataDir });
|
|
expect(root.source).toBe('nearest');
|
|
expect(root.path).toBe(repo);
|
|
expect(root.storeId).toBeUndefined();
|
|
} finally {
|
|
console.error = original;
|
|
}
|
|
|
|
expect(warnings).toHaveLength(1);
|
|
expect(warnings[0]).toContain("declares store 'team-context'");
|
|
expect(warnings[0]).toContain('the declaration is ignored');
|
|
});
|
|
|
|
it('keeps config-only directories without a pointer as plain roots', async () => {
|
|
await registerStore('team-context');
|
|
const dir = createPointerDir('plain-config-only', 'schema: spec-driven\n');
|
|
|
|
const warnings: string[] = [];
|
|
const original = console.error;
|
|
console.error = (message: string) => warnings.push(String(message));
|
|
try {
|
|
const root = await resolveOpenSpecRoot({ startPath: dir, globalDataDir });
|
|
expect(root.source).toBe('nearest');
|
|
expect(root.path).toBe(dir);
|
|
} finally {
|
|
console.error = original;
|
|
}
|
|
expect(warnings).toEqual([]);
|
|
});
|
|
|
|
it('errors on malformed pointers instead of falling through to local writes', async () => {
|
|
const nonString = createPointerDir('bad-type', 'store: [a, b]\n');
|
|
const error = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ startPath: nonString, globalDataDir }),
|
|
'invalid_store_pointer'
|
|
);
|
|
expect(error.message).toContain(path.join(nonString, 'openspec', 'config.yaml'));
|
|
expect(error.message).toContain('the store key must be a single store id string');
|
|
expect(fs.existsSync(path.join(nonString, 'openspec', 'changes'))).toBe(false);
|
|
|
|
const unparseable = createPointerDir('bad-yaml', 'store: [unclosed');
|
|
const yamlError = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ startPath: unparseable, globalDataDir }),
|
|
'invalid_store_pointer'
|
|
);
|
|
// The unparseable case names the real problem, not a phantom key.
|
|
expect(yamlError.message).toContain('could not be read as YAML');
|
|
expect(yamlError.diagnostic.fix).toContain('Fix the YAML syntax');
|
|
|
|
// A config that parses to a non-mapping scalar has no pointer at
|
|
// all: plain root, no error (readProjectConfig owns that warning).
|
|
const scalar = createPointerDir('scalar-config', 'just a string');
|
|
const scalarRoot = await resolveOpenSpecRoot({ startPath: scalar, globalDataDir });
|
|
expect(scalarRoot.source).toBe('nearest');
|
|
});
|
|
|
|
it('treats empty and comments-only configs as plain roots, not malformed pointers', async () => {
|
|
// The documented conversion path comments the line out; that must
|
|
// not strand every command behind invalid_store_pointer.
|
|
const empty = createPointerDir('empty-config', '');
|
|
const emptyRoot = await resolveOpenSpecRoot({ startPath: empty, globalDataDir });
|
|
expect(emptyRoot.source).toBe('nearest');
|
|
expect(emptyRoot.path).toBe(empty);
|
|
|
|
const commented = createPointerDir('commented-config', '# store: team-context\n');
|
|
const commentedRoot = await resolveOpenSpecRoot({ startPath: commented, globalDataDir });
|
|
expect(commentedRoot.source).toBe('nearest');
|
|
expect(commentedRoot.path).toBe(commented);
|
|
});
|
|
|
|
it('prefixes every taxonomy error with the declaration origin, fix unprefixed', async () => {
|
|
const cases: Array<[string, string, () => Promise<unknown>]> = [];
|
|
|
|
const unknownDir = createPointerDir('unknown-pointer', 'store: ghost-context\n');
|
|
await registerStore('team-context');
|
|
cases.push([
|
|
'unknown_store',
|
|
path.join(unknownDir, 'openspec', 'config.yaml'),
|
|
() => resolveOpenSpecRoot({ startPath: unknownDir, globalDataDir }),
|
|
]);
|
|
|
|
const invalidDir = createPointerDir('invalid-pointer', 'store: "BAD ID"\n');
|
|
cases.push([
|
|
'invalid_store_id',
|
|
path.join(invalidDir, 'openspec', 'config.yaml'),
|
|
() => resolveOpenSpecRoot({ startPath: invalidDir, globalDataDir }),
|
|
]);
|
|
|
|
await registerStore('hollow-context', { healthyRoot: false });
|
|
const unhealthyDir = createPointerDir('unhealthy-pointer', 'store: hollow-context\n');
|
|
cases.push([
|
|
'unhealthy_store_root',
|
|
path.join(unhealthyDir, 'openspec', 'config.yaml'),
|
|
() => resolveOpenSpecRoot({ startPath: unhealthyDir, globalDataDir }),
|
|
]);
|
|
|
|
await registerStore('mismatched-context', { metadataId: 'someone-else' });
|
|
const mismatchDir = createPointerDir('mismatch-pointer', 'store: mismatched-context\n');
|
|
cases.push([
|
|
'store_identity_mismatch',
|
|
path.join(mismatchDir, 'openspec', 'config.yaml'),
|
|
() => resolveOpenSpecRoot({ startPath: mismatchDir, globalDataDir }),
|
|
]);
|
|
|
|
for (const [code, origin, run] of cases) {
|
|
const error = await expectRootSelectionError(run(), code);
|
|
expect(error.message).toContain(`Declared in ${origin}: `);
|
|
expect(error.diagnostic.fix).not.toContain('Declared in');
|
|
}
|
|
});
|
|
|
|
it('prefixes no_registered_stores when nothing is registered', async () => {
|
|
const pointerDir = createPointerDir('lonely-pointer', 'store: team-context\n');
|
|
|
|
const error = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ startPath: pointerDir, globalDataDir }),
|
|
'no_registered_stores'
|
|
);
|
|
expect(error.message).toContain('Declared in ');
|
|
});
|
|
|
|
it('resolves one hop only - a store with its own pointer is the destination', async () => {
|
|
const storeRoot = await registerStore('team-context');
|
|
fs.writeFileSync(
|
|
path.join(storeRoot, 'openspec', 'config.yaml'),
|
|
'schema: spec-driven\nstore: somewhere-else\n'
|
|
);
|
|
const pointerDir = createPointerDir('app-repo', 'store: team-context\n');
|
|
|
|
const warnings: string[] = [];
|
|
const original = console.error;
|
|
console.error = (message: string) => warnings.push(String(message));
|
|
try {
|
|
const root = await resolveOpenSpecRoot({ startPath: pointerDir, globalDataDir });
|
|
expect(root.path).toBe(storeRoot);
|
|
expect(root.storeId).toBe('team-context');
|
|
} finally {
|
|
console.error = original;
|
|
}
|
|
});
|
|
|
|
it('names a .yml origin when that file was read', async () => {
|
|
const dir = mkdir('yml-pointer');
|
|
fs.mkdirSync(path.join(dir, 'openspec'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'openspec', 'config.yml'), 'store: ghost\n');
|
|
|
|
const error = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ startPath: dir, globalDataDir }),
|
|
'no_registered_stores'
|
|
);
|
|
expect(error.message).toContain(path.join(dir, 'openspec', 'config.yml'));
|
|
});
|
|
|
|
// The generated workflows' project check (#1645) reads `root: null` as
|
|
// "never initialized" unless the status message starts with a prefix it
|
|
// names. Every failure of a project's own `store:` declaration must carry
|
|
// one of those prefixes, or the guard drops OpenSpec (or offers `openspec
|
|
// init`) in a project that already uses it. A stale global defaultStore in
|
|
// an unrelated repository must carry neither.
|
|
it('starts every declaration failure with a prefix the project check names', async () => {
|
|
const { PROJECT_ROOT_GUARD } = await import(
|
|
'../../src/core/templates/workflows/project-root.js'
|
|
);
|
|
const prefixes = ['Declared in ', 'Invalid store declaration in '];
|
|
for (const prefix of prefixes) {
|
|
expect(PROJECT_ROOT_GUARD).toContain(`\`${prefix.trim()}\``);
|
|
}
|
|
const namedByGuard = (message: string) =>
|
|
prefixes.some((prefix) => message.startsWith(prefix));
|
|
|
|
const failures = [
|
|
createPointerDir('guard-unregistered', 'store: ghost\n'),
|
|
createPointerDir('guard-bad-type', 'store: [a, b]\n'),
|
|
createPointerDir('guard-bad-yaml', 'store: [unclosed'),
|
|
createPointerDir('guard-bad-id', 'store: "BAD ID"\n'),
|
|
];
|
|
for (const dir of failures) {
|
|
const error = await resolveOpenSpecRoot({ startPath: dir, globalDataDir }).catch(
|
|
(caught: unknown) => caught
|
|
);
|
|
expect(error, dir).toBeInstanceOf(RootSelectionError);
|
|
expect(namedByGuard((error as RootSelectionError).message), dir).toBe(true);
|
|
}
|
|
|
|
setDefaultStore('ghost-plans');
|
|
const stale = await resolveOpenSpecRoot({
|
|
startPath: mkdir('guard-unrelated-repo'),
|
|
globalDataDir,
|
|
}).catch((caught: unknown) => caught);
|
|
expect(stale).toBeInstanceOf(RootSelectionError);
|
|
expect(namedByGuard((stale as RootSelectionError).message)).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('skips openspec/ directories that are neither planning-shaped nor configured (the ~/openspec layout)', async () => {
|
|
// The recommended store layout: $HOME/openspec/<store>. $HOME must
|
|
// NOT become a nearest root for everything under the home tree.
|
|
await registerStore('team-context');
|
|
const fakeHome = path.join(tempDir, 'fake-home');
|
|
fs.mkdirSync(path.join(fakeHome, 'openspec', 'team-context'), { recursive: true });
|
|
const scratch = path.join(fakeHome, 'projects', 'scratch');
|
|
fs.mkdirSync(scratch, { recursive: true });
|
|
|
|
// No qualifying root anywhere: the registered-store hint fires (the
|
|
// exact guidance the phantom $HOME root used to shadow). The
|
|
// isolated globalDataDir keeps this off the machine's real registry.
|
|
await expect(
|
|
resolveOpenSpecRoot({ startPath: scratch, globalDataDir })
|
|
).rejects.toMatchObject({
|
|
diagnostic: expect.objectContaining({ code: 'no_root_with_registered_stores' }),
|
|
});
|
|
});
|
|
|
|
describe('global defaultStore fallback (#1359)', () => {
|
|
it('resolves the global defaultStore when no local root or pointer exists', async () => {
|
|
const storeRoot = await registerStore('team-plans');
|
|
setDefaultStore('team-plans');
|
|
const scratch = mkdir('no-root-here');
|
|
|
|
const root = await resolveOpenSpecRoot({ startPath: scratch, globalDataDir });
|
|
|
|
expect(root.source).toBe('global_default');
|
|
expect(root.storeId).toBe('team-plans');
|
|
expect(root.path).toBe(storeRoot);
|
|
});
|
|
|
|
it('lets a nearest local root win over the global default', async () => {
|
|
await registerStore('team-plans');
|
|
setDefaultStore('team-plans');
|
|
const localRoot = mkdir('app');
|
|
createOpenSpecRoot(localRoot);
|
|
const nested = path.join(localRoot, 'src');
|
|
fs.mkdirSync(nested, { recursive: true });
|
|
|
|
const root = await resolveOpenSpecRoot({ startPath: nested, globalDataDir });
|
|
|
|
expect(root.source).toBe('nearest');
|
|
expect(root.path).toBe(localRoot);
|
|
expect(root.storeId).toBeUndefined();
|
|
});
|
|
|
|
it('lets a project-level store pointer win over the global default', async () => {
|
|
const pointed = await registerStore('team-plans');
|
|
await registerStore('other-plans');
|
|
setDefaultStore('other-plans');
|
|
const pointerDir = mkdir('app-repo');
|
|
fs.mkdirSync(path.join(pointerDir, 'openspec'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pointerDir, 'openspec', 'config.yaml'),
|
|
'store: team-plans\n'
|
|
);
|
|
|
|
const root = await resolveOpenSpecRoot({ startPath: pointerDir, globalDataDir });
|
|
|
|
expect(root.source).toBe('declared');
|
|
expect(root.storeId).toBe('team-plans');
|
|
expect(root.path).toBe(pointed);
|
|
});
|
|
|
|
it('lets explicit --store win over the global default', async () => {
|
|
const chosen = await registerStore('team-plans');
|
|
await registerStore('other-plans');
|
|
setDefaultStore('other-plans');
|
|
const scratch = mkdir('no-root-here');
|
|
|
|
const root = await resolveOpenSpecRoot({
|
|
startPath: scratch,
|
|
store: 'team-plans',
|
|
globalDataDir,
|
|
});
|
|
|
|
expect(root.source).toBe('store');
|
|
expect(root.storeId).toBe('team-plans');
|
|
expect(root.path).toBe(chosen);
|
|
});
|
|
|
|
it('degrades a stale defaultStore to an error that names how to clear it', async () => {
|
|
await registerStore('team-plans');
|
|
setDefaultStore('ghost-plans');
|
|
const scratch = mkdir('no-root-here');
|
|
|
|
const error = await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ startPath: scratch, globalDataDir }),
|
|
'unknown_store'
|
|
);
|
|
expect(error.message).toContain("Global defaultStore 'ghost-plans'");
|
|
expect(error.diagnostic.fix).toContain('openspec config unset defaultStore');
|
|
});
|
|
|
|
it('falls through to the registered-store hint when no default is set', async () => {
|
|
await registerStore('team-plans');
|
|
const scratch = mkdir('no-root-here');
|
|
|
|
await expectRootSelectionError(
|
|
resolveOpenSpecRoot({ startPath: scratch, globalDataDir }),
|
|
'no_root_with_registered_stores'
|
|
);
|
|
});
|
|
});
|
|
|
|
});
|