@gordon.gan/specflow 1.1.1 → 1.2.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +6 -0
  2. package/dist/cli/commands/change-archive.js +4 -4
  3. package/dist/cli/commands/change-new.js +5 -5
  4. package/dist/cli/commands/change-phase.js +4 -4
  5. package/dist/cli/commands/change-status.js +4 -4
  6. package/dist/cli/commands/context.d.ts +18 -0
  7. package/dist/cli/commands/context.js +125 -0
  8. package/dist/cli/commands/instructions.d.ts +4 -1
  9. package/dist/cli/commands/instructions.js +58 -9
  10. package/dist/cli/commands/show.d.ts +22 -0
  11. package/dist/cli/commands/show.js +92 -0
  12. package/dist/cli/commands/store.d.ts +16 -0
  13. package/dist/cli/commands/store.js +221 -0
  14. package/dist/cli/commands/validate.d.ts +16 -0
  15. package/dist/cli/commands/validate.js +34 -4
  16. package/dist/cli/commands/workset.d.ts +12 -0
  17. package/dist/cli/commands/workset.js +235 -0
  18. package/dist/cli/index.js +8 -0
  19. package/dist/cli/shared/store-option.d.ts +11 -0
  20. package/dist/cli/shared/store-option.js +40 -0
  21. package/dist/core/artifact-graph/instruction-loader.d.ts +17 -0
  22. package/dist/core/artifact-graph/instruction-loader.js +2 -0
  23. package/dist/core/artifact-graph/types.d.ts +2 -2
  24. package/dist/core/context-assembly.d.ts +9 -0
  25. package/dist/core/context-assembly.js +68 -0
  26. package/dist/core/diagnostics.d.ts +11 -0
  27. package/dist/core/diagnostics.js +18 -0
  28. package/dist/core/file-state.d.ts +23 -0
  29. package/dist/core/file-state.js +101 -0
  30. package/dist/core/global-config.d.ts +26 -0
  31. package/dist/core/global-config.js +77 -0
  32. package/dist/core/opener-launch.d.ts +3 -0
  33. package/dist/core/opener-launch.js +20 -0
  34. package/dist/core/openers.d.ts +23 -0
  35. package/dist/core/openers.js +20 -0
  36. package/dist/core/project-config.d.ts +14 -0
  37. package/dist/core/project-config.js +73 -0
  38. package/dist/core/reference-index.d.ts +8 -0
  39. package/dist/core/reference-index.js +80 -0
  40. package/dist/core/references.d.ts +17 -0
  41. package/dist/core/references.js +51 -0
  42. package/dist/core/relationship-health.d.ts +22 -0
  43. package/dist/core/relationship-health.js +68 -0
  44. package/dist/core/root-selection.d.ts +26 -0
  45. package/dist/core/root-selection.js +197 -0
  46. package/dist/core/store/errors.d.ts +2 -0
  47. package/dist/core/store/errors.js +1 -0
  48. package/dist/core/store/foundation.d.ts +141 -0
  49. package/dist/core/store/foundation.js +79 -0
  50. package/dist/core/store/health.d.ts +13 -0
  51. package/dist/core/store/health.js +117 -0
  52. package/dist/core/store/operations.d.ts +56 -0
  53. package/dist/core/store/operations.js +268 -0
  54. package/dist/core/store/registry.d.ts +15 -0
  55. package/dist/core/store/registry.js +128 -0
  56. package/dist/core/working-set.d.ts +30 -0
  57. package/dist/core/working-set.js +26 -0
  58. package/dist/core/worksets.d.ts +71 -0
  59. package/dist/core/worksets.js +134 -0
  60. package/dist/integrations/shared/skill-renderer.d.ts +6 -0
  61. package/dist/integrations/shared/skill-renderer.js +22 -0
  62. package/package.json +1 -1
  63. package/skills/specflow-apply/SKILL.md +5 -27
@@ -0,0 +1,117 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import yaml from 'js-yaml';
6
+ import { sortDiagnostics } from '../diagnostics.js';
7
+ import { getStoreIdentityPath, StoreIdentitySchema, } from './foundation.js';
8
+ import { hasPlanningContent } from '../root-selection.js';
9
+ const execFileAsync = promisify(execFile);
10
+ async function pathExists(targetPath) {
11
+ try {
12
+ await fs.stat(targetPath);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ async function readIdentity(root) {
20
+ const identityPath = getStoreIdentityPath(root);
21
+ try {
22
+ const raw = yaml.load(await fs.readFile(identityPath, 'utf-8'));
23
+ const parsed = StoreIdentitySchema.safeParse(raw);
24
+ return parsed.success ? parsed.data : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ async function readGitOrigin(checkoutPath) {
31
+ try {
32
+ const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
33
+ cwd: checkoutPath,
34
+ });
35
+ const trimmed = stdout.trim();
36
+ return trimmed.length > 0 ? trimmed : null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export async function inspectStoreEntry(storeId, entry) {
43
+ const diagnostics = [];
44
+ const checkoutPath = entry.backend.local_path;
45
+ if (!(await pathExists(checkoutPath))) {
46
+ diagnostics.push({
47
+ severity: 'error',
48
+ code: 'store_checkout_missing',
49
+ message: `Registered checkout for '${storeId}' does not exist at ${checkoutPath}.`,
50
+ target: `stores.${storeId}`,
51
+ fix: `Relink with specflow store register --id ${storeId} <path> or specflow store unregister ${storeId}.`,
52
+ });
53
+ return diagnostics;
54
+ }
55
+ const identity = await readIdentity(checkoutPath);
56
+ if (!identity) {
57
+ diagnostics.push({
58
+ severity: 'warning',
59
+ code: 'store_identity_missing',
60
+ message: `Store '${storeId}' checkout has no committed identity.`,
61
+ target: `stores.${storeId}`,
62
+ fix: `Run specflow store register --id ${storeId} ${checkoutPath} --yes to adopt identity.`,
63
+ });
64
+ }
65
+ else if (identity.id !== storeId) {
66
+ diagnostics.push({
67
+ severity: 'error',
68
+ code: 'store_identity_mismatch',
69
+ message: `Store '${storeId}' registry ID does not match committed identity '${identity.id}'.`,
70
+ target: `stores.${storeId}`,
71
+ fix: 'Unregister and re-register with the matching store ID.',
72
+ });
73
+ }
74
+ const configPath = path.join(checkoutPath, 'specflow', 'config.yaml');
75
+ if (!(await pathExists(configPath))) {
76
+ diagnostics.push({
77
+ severity: 'warning',
78
+ code: 'store_unhealthy_root',
79
+ message: `Store '${storeId}' checkout is missing specflow/config.yaml.`,
80
+ target: `stores.${storeId}`,
81
+ });
82
+ }
83
+ else if (!(await hasPlanningContent(checkoutPath))) {
84
+ diagnostics.push({
85
+ severity: 'info',
86
+ code: 'store_empty_planning',
87
+ message: `Store '${storeId}' has no baseline specs or active changes yet.`,
88
+ target: `stores.${storeId}`,
89
+ });
90
+ }
91
+ const canonicalRemote = identity?.remote ?? entry.backend.remote;
92
+ if (canonicalRemote) {
93
+ const observedOrigin = await readGitOrigin(checkoutPath);
94
+ if (observedOrigin && observedOrigin !== canonicalRemote) {
95
+ diagnostics.push({
96
+ severity: 'info',
97
+ code: 'store_remote_divergence',
98
+ message: `Store '${storeId}' canonical remote differs from Git origin.`,
99
+ target: `stores.${storeId}`,
100
+ fix: 'Update the committed identity remote or reconcile the checkout manually.',
101
+ });
102
+ }
103
+ }
104
+ return diagnostics;
105
+ }
106
+ export async function inspectAllStoresHealth(registry) {
107
+ const results = await Promise.all(Object.entries(registry.stores).map(async ([id, entry]) => ({
108
+ id,
109
+ root: entry.backend.local_path,
110
+ provenance: entry.provenance,
111
+ diagnostics: [...(await inspectStoreEntry(id, entry))],
112
+ })));
113
+ return results;
114
+ }
115
+ export function flattenStoreDoctorDiagnostics(stores) {
116
+ return sortDiagnostics(stores.flatMap((store) => store.diagnostics));
117
+ }
@@ -0,0 +1,56 @@
1
+ import type { Diagnostic } from '../diagnostics.js';
2
+ import { type StoreIdentity } from './foundation.js';
3
+ export interface StoreSetupInput {
4
+ id: string;
5
+ targetPath: string;
6
+ dataDir: string;
7
+ remote?: string;
8
+ }
9
+ export interface StoreSetupResult {
10
+ readonly root: string;
11
+ readonly identity: StoreIdentity;
12
+ readonly createdArtifacts: readonly string[];
13
+ readonly diagnostics: readonly Diagnostic[];
14
+ }
15
+ export declare function prepareStoreSetup(input: StoreSetupInput): Promise<StoreSetupResult>;
16
+ export interface RemoveManagedStoreInput {
17
+ id: string;
18
+ dataDir: string;
19
+ deleteCheckout?: (checkoutPath: string) => Promise<void>;
20
+ }
21
+ export interface RemoveManagedStoreResult {
22
+ readonly registryRemoved: boolean;
23
+ readonly checkoutDeleted: boolean;
24
+ readonly diagnostics: readonly Diagnostic[];
25
+ }
26
+ export declare function removeManagedStore(input: RemoveManagedStoreInput): Promise<RemoveManagedStoreResult>;
27
+ export declare function listRegisteredStores(dataDir: string): Promise<{
28
+ id: string;
29
+ root: string;
30
+ provenance: "managed" | "external";
31
+ }[]>;
32
+ export interface StoreRegisterInput {
33
+ id: string;
34
+ localPath: string;
35
+ dataDir: string;
36
+ confirmIdentity?: boolean;
37
+ }
38
+ export interface StoreRegisterResult {
39
+ readonly store: {
40
+ id: string;
41
+ root: string;
42
+ };
43
+ readonly alreadyRegistered: boolean;
44
+ readonly createdArtifacts: readonly string[];
45
+ readonly diagnostics: readonly Diagnostic[];
46
+ }
47
+ export declare function prepareStoreRegistration(input: StoreRegisterInput): Promise<StoreRegisterResult>;
48
+ export interface UnregisterStoreInput {
49
+ id: string;
50
+ dataDir: string;
51
+ }
52
+ export interface UnregisterStoreResult {
53
+ readonly removed: boolean;
54
+ readonly diagnostics: readonly Diagnostic[];
55
+ }
56
+ export declare function unregisterStore(input: UnregisterStoreInput): Promise<UnregisterStoreResult>;
@@ -0,0 +1,268 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import { getStoreIdentityPath, StoreError, StoreIdentitySchema, validateStoreId, } from './foundation.js';
5
+ import { registerStoreEntry, unregisterStoreEntry, readRegistry } from './registry.js';
6
+ import { getStoreRegistryPath } from '../global-config.js';
7
+ import { ensureDir } from '../../utils/file-system.js';
8
+ const SPECFLOW_DIR = 'specflow';
9
+ async function pathExists(targetPath) {
10
+ try {
11
+ await fs.stat(targetPath);
12
+ return true;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ async function writeStoreIdentity(root, identity) {
19
+ const identityPath = getStoreIdentityPath(root);
20
+ await ensureDir(path.dirname(identityPath));
21
+ await fs.writeFile(identityPath, yaml.dump(identity, { lineWidth: -1 }), 'utf-8');
22
+ return identityPath;
23
+ }
24
+ async function createPlanningRoot(root) {
25
+ const created = [];
26
+ const specflowDir = path.join(root, SPECFLOW_DIR);
27
+ await ensureDir(path.join(specflowDir, 'changes'));
28
+ await ensureDir(path.join(specflowDir, 'specs'));
29
+ const configPath = path.join(specflowDir, 'config.yaml');
30
+ if (!(await pathExists(configPath))) {
31
+ await fs.writeFile(configPath, 'schema: specflow\n', 'utf-8');
32
+ created.push(configPath);
33
+ }
34
+ created.push(specflowDir);
35
+ return created;
36
+ }
37
+ async function rollbackLedger(ledger) {
38
+ for (const artifact of [...ledger].reverse()) {
39
+ await fs.rm(artifact, { recursive: true, force: true }).catch(() => undefined);
40
+ }
41
+ }
42
+ export async function prepareStoreSetup(input) {
43
+ validateStoreId(input.id);
44
+ const targetPath = path.resolve(input.targetPath);
45
+ if (await pathExists(targetPath)) {
46
+ throw new StoreError('Store setup target already exists.', {
47
+ severity: 'error',
48
+ code: 'store_target_exists',
49
+ message: 'Store setup accepts only a path that does not exist.',
50
+ target: 'store.path',
51
+ fix: 'Choose a missing path or adopt an existing directory with store register.',
52
+ });
53
+ }
54
+ const ledger = [];
55
+ const identity = {
56
+ version: 1,
57
+ id: input.id,
58
+ ...(input.remote ? { remote: input.remote } : {}),
59
+ };
60
+ try {
61
+ await fs.mkdir(targetPath, { recursive: false });
62
+ ledger.push(targetPath);
63
+ const created = await createPlanningRoot(targetPath);
64
+ ledger.push(...created);
65
+ const identityPath = await writeStoreIdentity(targetPath, identity);
66
+ ledger.push(identityPath);
67
+ const registryPath = getStoreRegistryPath(input.dataDir);
68
+ await registerStoreEntry(registryPath, {
69
+ id: input.id,
70
+ localPath: targetPath,
71
+ provenance: 'managed',
72
+ remote: input.remote,
73
+ });
74
+ return {
75
+ root: targetPath,
76
+ identity,
77
+ createdArtifacts: ledger,
78
+ diagnostics: [],
79
+ };
80
+ }
81
+ catch (error) {
82
+ await rollbackLedger(ledger);
83
+ throw error;
84
+ }
85
+ }
86
+ export async function removeManagedStore(input) {
87
+ const registryPath = getStoreRegistryPath(input.dataDir);
88
+ const registry = await readRegistry(registryPath);
89
+ const entry = registry.stores[input.id];
90
+ if (!entry) {
91
+ throw new StoreError(`Store '${input.id}' is not registered.`, {
92
+ severity: 'error',
93
+ code: 'store_not_registered',
94
+ message: `Store '${input.id}' is not registered.`,
95
+ target: `stores.${input.id}`,
96
+ fix: 'Run specflow store list to inspect registered stores.',
97
+ });
98
+ }
99
+ if (entry.provenance !== 'managed') {
100
+ throw new StoreError('Refusing to delete an external checkout.', {
101
+ severity: 'error',
102
+ code: 'store_external_delete_forbidden',
103
+ message: 'External checkouts cannot be deleted by SpecFlow.',
104
+ target: `stores.${input.id}`,
105
+ fix: 'Unregister the store instead of removing it.',
106
+ });
107
+ }
108
+ const checkoutPath = entry.backend.local_path;
109
+ const identityPath = getStoreIdentityPath(checkoutPath);
110
+ let identityMatches = false;
111
+ try {
112
+ const raw = yaml.load(await fs.readFile(identityPath, 'utf-8'));
113
+ const parsed = StoreIdentitySchema.safeParse(raw);
114
+ identityMatches = parsed.success && parsed.data.id === input.id;
115
+ }
116
+ catch {
117
+ identityMatches = false;
118
+ }
119
+ if (!identityMatches) {
120
+ throw new StoreError('Refusing to delete checkout with missing or mismatched identity.', {
121
+ severity: 'error',
122
+ code: 'store_identity_mismatch',
123
+ message: 'Checkout identity does not match the requested store ID.',
124
+ target: `stores.${input.id}`,
125
+ fix: 'Unregister the store instead of removing it.',
126
+ });
127
+ }
128
+ await unregisterStoreEntry(registryPath, input.id);
129
+ const deleteCheckout = input.deleteCheckout ??
130
+ (async (target) => {
131
+ await fs.rm(target, { recursive: true, force: true });
132
+ });
133
+ try {
134
+ await deleteCheckout(checkoutPath);
135
+ return { registryRemoved: true, checkoutDeleted: true, diagnostics: [] };
136
+ }
137
+ catch (error) {
138
+ return {
139
+ registryRemoved: true,
140
+ checkoutDeleted: false,
141
+ diagnostics: [
142
+ {
143
+ severity: 'error',
144
+ code: 'store_delete_failed',
145
+ message: `Managed store was unregistered but checkout deletion failed: ${error instanceof Error ? error.message : String(error)}`,
146
+ target: `stores.${input.id}`,
147
+ fix: `Manually remove ${checkoutPath} or re-register the store if you still need it.`,
148
+ },
149
+ ],
150
+ };
151
+ }
152
+ }
153
+ export async function listRegisteredStores(dataDir) {
154
+ const registry = await readRegistry(getStoreRegistryPath(dataDir));
155
+ return Object.entries(registry.stores).map(([id, entry]) => ({
156
+ id,
157
+ root: entry.backend.local_path,
158
+ provenance: entry.provenance,
159
+ }));
160
+ }
161
+ async function readStoreIdentity(root) {
162
+ const identityPath = getStoreIdentityPath(root);
163
+ try {
164
+ const raw = yaml.load(await fs.readFile(identityPath, 'utf-8'));
165
+ const parsed = StoreIdentitySchema.safeParse(raw);
166
+ return parsed.success ? parsed.data : null;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ async function isHealthySpecflowRoot(root) {
173
+ const configPath = path.join(root, 'specflow', 'config.yaml');
174
+ return pathExists(configPath);
175
+ }
176
+ export async function prepareStoreRegistration(input) {
177
+ validateStoreId(input.id);
178
+ const targetPath = path.resolve(input.localPath);
179
+ if (!(await pathExists(targetPath))) {
180
+ throw new StoreError('Registration path does not exist.', {
181
+ severity: 'error',
182
+ code: 'store_path_missing',
183
+ message: `Path '${targetPath}' does not exist.`,
184
+ target: 'store.path',
185
+ fix: 'Provide an existing checkout path.',
186
+ });
187
+ }
188
+ const stat = await fs.stat(targetPath);
189
+ if (!stat.isDirectory()) {
190
+ throw new StoreError('Registration path must be a directory.', {
191
+ severity: 'error',
192
+ code: 'store_path_not_directory',
193
+ message: 'Store registration requires a directory checkout.',
194
+ target: 'store.path',
195
+ });
196
+ }
197
+ if (!(await isHealthySpecflowRoot(targetPath))) {
198
+ throw new StoreError('Registration path is not a healthy SpecFlow root.', {
199
+ severity: 'error',
200
+ code: 'store_unhealthy_root',
201
+ message: 'Expected specflow/config.yaml under the checkout.',
202
+ target: 'store.path',
203
+ fix: 'Run specflow init in the checkout or choose a valid planning root.',
204
+ });
205
+ }
206
+ const existingIdentity = await readStoreIdentity(targetPath);
207
+ if (existingIdentity && existingIdentity.id !== input.id) {
208
+ throw new StoreError('Committed store identity conflicts with requested ID.', {
209
+ severity: 'error',
210
+ code: 'store_identity_conflict',
211
+ message: `Checkout identity is '${existingIdentity.id}' but registration requested '${input.id}'.`,
212
+ target: 'store.id',
213
+ fix: `Register as '${existingIdentity.id}' or choose a different checkout.`,
214
+ });
215
+ }
216
+ const createdArtifacts = [];
217
+ let identityToWrite = existingIdentity;
218
+ if (!existingIdentity) {
219
+ if (!input.confirmIdentity) {
220
+ throw new StoreError('Identity adoption requires explicit confirmation.', {
221
+ severity: 'error',
222
+ code: 'store_identity_confirmation_required',
223
+ message: 'Non-interactive registration must pass --yes to create store identity.',
224
+ target: 'store.id',
225
+ fix: `Re-run with --yes to create .specflow-store/store.yaml for '${input.id}'.`,
226
+ });
227
+ }
228
+ identityToWrite = { version: 1, id: input.id };
229
+ const identityPath = await writeStoreIdentity(targetPath, identityToWrite);
230
+ createdArtifacts.push(identityPath);
231
+ }
232
+ const registryPath = getStoreRegistryPath(input.dataDir);
233
+ try {
234
+ const { alreadyRegistered } = await registerStoreEntry(registryPath, {
235
+ id: input.id,
236
+ localPath: targetPath,
237
+ provenance: 'external',
238
+ remote: identityToWrite?.remote,
239
+ });
240
+ return {
241
+ store: { id: input.id, root: targetPath },
242
+ alreadyRegistered,
243
+ createdArtifacts,
244
+ diagnostics: [],
245
+ };
246
+ }
247
+ catch (error) {
248
+ for (const artifact of [...createdArtifacts].reverse()) {
249
+ await fs.rm(artifact, { force: true }).catch(() => undefined);
250
+ }
251
+ throw error;
252
+ }
253
+ }
254
+ export async function unregisterStore(input) {
255
+ const registryPath = getStoreRegistryPath(input.dataDir);
256
+ const registry = await readRegistry(registryPath);
257
+ if (!registry.stores[input.id]) {
258
+ throw new StoreError(`Store '${input.id}' is not registered.`, {
259
+ severity: 'error',
260
+ code: 'store_not_registered',
261
+ message: `Store '${input.id}' is not registered.`,
262
+ target: `stores.${input.id}`,
263
+ fix: 'Run specflow store list to inspect registered stores.',
264
+ });
265
+ }
266
+ await unregisterStoreEntry(registryPath, input.id);
267
+ return { removed: true, diagnostics: [] };
268
+ }
@@ -0,0 +1,15 @@
1
+ import { type StoreRegistry } from './foundation.js';
2
+ export declare function readRegistry(registryPath: string): Promise<StoreRegistry>;
3
+ export declare function updateRegistry(registryPath: string, updater: (state: StoreRegistry) => StoreRegistry): Promise<StoreRegistry>;
4
+ export declare function findPathConflict(registry: StoreRegistry, targetPath: string, storeId: string, platform?: NodeJS.Platform): string | null;
5
+ export declare function registerStoreEntry(registryPath: string, input: {
6
+ id: string;
7
+ localPath: string;
8
+ provenance: 'managed' | 'external';
9
+ remote?: string;
10
+ branch?: string;
11
+ }): Promise<{
12
+ registry: StoreRegistry;
13
+ alreadyRegistered: boolean;
14
+ }>;
15
+ export declare function unregisterStoreEntry(registryPath: string, storeId: string): Promise<StoreRegistry>;
@@ -0,0 +1,128 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import { acquireFileLock, releaseFileLock, writeFileAtomically, makeLockErrorFactory, } from '../file-state.js';
5
+ import { StoreRegistrySchema, emptyRegistry, pathsReferToSameCheckout, StoreError, } from './foundation.js';
6
+ function lockPathFor(registryPath) {
7
+ return `${registryPath}.lock`;
8
+ }
9
+ function makeRegistryLockError() {
10
+ return makeLockErrorFactory({
11
+ createSubject: 'the registry lock file',
12
+ busyMessage: 'Store registry is busy.',
13
+ code: 'registry_busy',
14
+ target: 'stores.registry',
15
+ });
16
+ }
17
+ export async function readRegistry(registryPath) {
18
+ try {
19
+ const content = await fs.readFile(registryPath, 'utf-8');
20
+ const parsed = yaml.load(content);
21
+ const result = StoreRegistrySchema.safeParse(parsed);
22
+ if (!result.success) {
23
+ throw new StoreError('Store registry is corrupt.', {
24
+ severity: 'error',
25
+ code: 'registry_corrupt',
26
+ message: 'Store registry failed schema validation.',
27
+ target: 'stores.registry',
28
+ fix: 'Repair or delete the registry file and re-register stores.',
29
+ });
30
+ }
31
+ return result.data;
32
+ }
33
+ catch (error) {
34
+ if (error instanceof StoreError) {
35
+ throw error;
36
+ }
37
+ if (error.code === 'ENOENT') {
38
+ return emptyRegistry();
39
+ }
40
+ throw error;
41
+ }
42
+ }
43
+ async function writeRegistry(registryPath, state) {
44
+ const content = yaml.dump(state, { lineWidth: -1, noRefs: true });
45
+ await writeFileAtomically(registryPath, content);
46
+ }
47
+ export async function updateRegistry(registryPath, updater) {
48
+ const lock = await acquireFileLock({
49
+ lockPath: lockPathFor(registryPath),
50
+ errorFor: makeRegistryLockError(),
51
+ });
52
+ try {
53
+ const current = await readRegistry(registryPath);
54
+ const next = updater(current);
55
+ const validated = StoreRegistrySchema.parse(next);
56
+ await writeRegistry(registryPath, validated);
57
+ return validated;
58
+ }
59
+ finally {
60
+ await releaseFileLock(lock, lockPathFor(registryPath));
61
+ }
62
+ }
63
+ export function findPathConflict(registry, targetPath, storeId, platform = process.platform) {
64
+ for (const [existingId, entry] of Object.entries(registry.stores)) {
65
+ if (existingId === storeId) {
66
+ continue;
67
+ }
68
+ if (pathsReferToSameCheckout(entry.backend.local_path, targetPath, platform)) {
69
+ return existingId;
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+ export async function registerStoreEntry(registryPath, input) {
75
+ const canonicalPath = path.resolve(input.localPath);
76
+ let alreadyRegistered = false;
77
+ const registry = await updateRegistry(registryPath, (state) => {
78
+ const existing = state.stores[input.id];
79
+ if (existing) {
80
+ if (pathsReferToSameCheckout(existing.backend.local_path, canonicalPath)) {
81
+ alreadyRegistered = true;
82
+ return state;
83
+ }
84
+ throw new StoreError(`Store '${input.id}' is already registered at another path.`, {
85
+ severity: 'error',
86
+ code: 'store_id_conflict',
87
+ message: `Store '${input.id}' is already registered elsewhere.`,
88
+ target: `stores.${input.id}`,
89
+ fix: 'Unregister the existing checkout or choose another store ID.',
90
+ });
91
+ }
92
+ const aliasConflict = findPathConflict(state, canonicalPath, input.id);
93
+ if (aliasConflict) {
94
+ throw new StoreError(`Path already registered as '${aliasConflict}'.`, {
95
+ severity: 'error',
96
+ code: 'store_path_conflict',
97
+ message: `The checkout path is already registered under '${aliasConflict}'.`,
98
+ target: 'stores.registry',
99
+ fix: 'Use the existing registration or unregister the conflicting store first.',
100
+ });
101
+ }
102
+ return {
103
+ version: 1,
104
+ stores: {
105
+ ...state.stores,
106
+ [input.id]: {
107
+ provenance: input.provenance,
108
+ backend: {
109
+ type: 'git',
110
+ local_path: canonicalPath,
111
+ ...(input.remote ? { remote: input.remote } : {}),
112
+ ...(input.branch ? { branch: input.branch } : {}),
113
+ },
114
+ },
115
+ },
116
+ };
117
+ });
118
+ return { registry, alreadyRegistered };
119
+ }
120
+ export async function unregisterStoreEntry(registryPath, storeId) {
121
+ return updateRegistry(registryPath, (state) => {
122
+ if (!state.stores[storeId]) {
123
+ return state;
124
+ }
125
+ const { [storeId]: _removed, ...rest } = state.stores;
126
+ return { version: 1, stores: rest };
127
+ });
128
+ }
@@ -0,0 +1,30 @@
1
+ import type { Diagnostic } from './diagnostics.js';
2
+ export interface WorkingSetMember {
3
+ readonly storeId: string;
4
+ readonly path?: string;
5
+ readonly healthy: boolean;
6
+ readonly diagnostics: readonly Diagnostic[];
7
+ readonly fetchRecipe?: string;
8
+ }
9
+ export interface WorkingSet {
10
+ readonly members: readonly WorkingSetMember[];
11
+ }
12
+ export declare function assembleWorkingSet(input: {
13
+ root: {
14
+ storeId: string;
15
+ path: string;
16
+ healthy: boolean;
17
+ };
18
+ references: Array<{
19
+ storeId: string;
20
+ healthy: boolean;
21
+ path?: string;
22
+ diagnostics: Diagnostic[];
23
+ }>;
24
+ }): WorkingSet;
25
+ export declare function buildWorkingSetCodeWorkspaceJson(workingSet: WorkingSet): {
26
+ folders: Array<{
27
+ name: string;
28
+ path: string;
29
+ }>;
30
+ };
@@ -0,0 +1,26 @@
1
+ export function assembleWorkingSet(input) {
2
+ const members = [
3
+ {
4
+ storeId: input.root.storeId,
5
+ path: input.root.path,
6
+ healthy: input.root.healthy,
7
+ diagnostics: [],
8
+ fetchRecipe: `specflow context --store ${input.root.storeId}`,
9
+ },
10
+ ...input.references.map((ref) => ({
11
+ storeId: ref.storeId,
12
+ path: ref.healthy ? ref.path : undefined,
13
+ healthy: ref.healthy,
14
+ diagnostics: ref.diagnostics,
15
+ fetchRecipe: ref.healthy ? `specflow show <spec-id> --type spec --store ${ref.storeId}` : undefined,
16
+ })),
17
+ ];
18
+ return { members };
19
+ }
20
+ export function buildWorkingSetCodeWorkspaceJson(workingSet) {
21
+ return {
22
+ folders: workingSet.members
23
+ .filter((member) => member.healthy && member.path)
24
+ .map((member) => ({ name: member.storeId, path: member.path })),
25
+ };
26
+ }