@gordon.gan/specflow 1.1.0 → 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 (62) 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
@@ -77,8 +77,8 @@ export declare const SchemaYamlSchema: z.ZodObject<{
77
77
  tracks?: string | null | undefined;
78
78
  }>>;
79
79
  }, "strip", z.ZodTypeAny, {
80
- name: string;
81
80
  version: number;
81
+ name: string;
82
82
  artifacts: {
83
83
  id: string;
84
84
  generates: string;
@@ -93,8 +93,8 @@ export declare const SchemaYamlSchema: z.ZodObject<{
93
93
  } | undefined;
94
94
  description?: string | undefined;
95
95
  }, {
96
- name: string;
97
96
  version: number;
97
+ name: string;
98
98
  artifacts: {
99
99
  id: string;
100
100
  generates: string;
@@ -0,0 +1,9 @@
1
+ import type { Diagnostic } from './diagnostics.js';
2
+ import type { ResolvedPlanningRoot } from './root-selection.js';
3
+ import type { StoreRegistry } from './store/foundation.js';
4
+ import { type WorkingSet } from './working-set.js';
5
+ export interface AssembledContext {
6
+ readonly workingSet: WorkingSet;
7
+ readonly diagnostics: readonly Diagnostic[];
8
+ }
9
+ export declare function assembleContextFromRoot(resolved: ResolvedPlanningRoot, registry: StoreRegistry): Promise<AssembledContext>;
@@ -0,0 +1,68 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import yaml from 'js-yaml';
3
+ import { sortDiagnostics } from './diagnostics.js';
4
+ import { parseProjectConfig } from './project-config.js';
5
+ import { normalizeReferences } from './references.js';
6
+ import { inspectStoreEntry } from './store/health.js';
7
+ import { assembleWorkingSet } from './working-set.js';
8
+ export async function assembleContextFromRoot(resolved, registry) {
9
+ const configPath = `${resolved.root}/specflow/config.yaml`;
10
+ let parsedConfig;
11
+ try {
12
+ parsedConfig = parseProjectConfig(yaml.load(readFileSync(configPath, 'utf-8')));
13
+ }
14
+ catch {
15
+ parsedConfig = parseProjectConfig({});
16
+ }
17
+ const rootStoreId = resolved.storeId ?? resolved.root.split('/').pop() ?? 'root';
18
+ const references = normalizeReferences(parsedConfig.references, rootStoreId);
19
+ const diagnostics = [...resolved.diagnostics, ...parsedConfig.diagnostics];
20
+ const referenceMembers = await Promise.all(references.map(async (ref) => {
21
+ const entry = registry.stores[ref.id];
22
+ if (!entry) {
23
+ return {
24
+ storeId: ref.id,
25
+ healthy: false,
26
+ diagnostics: [
27
+ {
28
+ severity: 'warning',
29
+ code: 'reference_unregistered',
30
+ message: `Referenced store '${ref.id}' is not registered.`,
31
+ target: `references.${ref.id}`,
32
+ fix: `Run specflow store register --id ${ref.id} <path> --yes`,
33
+ },
34
+ ],
35
+ };
36
+ }
37
+ const storeDiagnostics = await inspectStoreEntry(ref.id, entry);
38
+ const unhealthy = storeDiagnostics.some((d) => d.severity === 'error');
39
+ if (unhealthy) {
40
+ return {
41
+ storeId: ref.id,
42
+ healthy: false,
43
+ diagnostics: [...storeDiagnostics],
44
+ };
45
+ }
46
+ return {
47
+ storeId: ref.id,
48
+ healthy: true,
49
+ path: entry.backend.local_path,
50
+ diagnostics: storeDiagnostics.filter((d) => d.severity !== 'error'),
51
+ };
52
+ }));
53
+ diagnostics.push(...referenceMembers.flatMap((member) => member.diagnostics));
54
+ const rootDiagnostics = resolved.storeId && registry.stores[resolved.storeId]
55
+ ? await inspectStoreEntry(resolved.storeId, registry.stores[resolved.storeId])
56
+ : [];
57
+ const rootHealthy = !rootDiagnostics.some((d) => d.severity === 'error');
58
+ diagnostics.push(...rootDiagnostics);
59
+ const workingSet = assembleWorkingSet({
60
+ root: {
61
+ storeId: rootStoreId,
62
+ path: resolved.root,
63
+ healthy: rootHealthy,
64
+ },
65
+ references: referenceMembers,
66
+ });
67
+ return { workingSet, diagnostics: sortDiagnostics(diagnostics) };
68
+ }
@@ -0,0 +1,11 @@
1
+ export type DiagnosticSeverity = 'error' | 'warning' | 'info';
2
+ export interface Diagnostic {
3
+ readonly severity: DiagnosticSeverity;
4
+ readonly code: string;
5
+ readonly message: string;
6
+ readonly target?: string;
7
+ readonly fix?: string;
8
+ }
9
+ export declare function compareDiagnostics(a: Diagnostic, b: Diagnostic): number;
10
+ export declare function sortDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[];
11
+ export declare function hasErrorDiagnostic(diagnostics: readonly Diagnostic[]): boolean;
@@ -0,0 +1,18 @@
1
+ const SEVERITY_RANK = {
2
+ error: 0,
3
+ warning: 1,
4
+ info: 2,
5
+ };
6
+ export function compareDiagnostics(a, b) {
7
+ const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
8
+ if (bySeverity !== 0) {
9
+ return bySeverity;
10
+ }
11
+ return a.code.localeCompare(b.code);
12
+ }
13
+ export function sortDiagnostics(diagnostics) {
14
+ return [...diagnostics].sort(compareDiagnostics);
15
+ }
16
+ export function hasErrorDiagnostic(diagnostics) {
17
+ return diagnostics.some((d) => d.severity === 'error');
18
+ }
@@ -0,0 +1,23 @@
1
+ import * as nodeFs from 'node:fs';
2
+ export type FileLockErrorKind = 'create-failed' | 'timeout';
3
+ export interface FileLockErrorInfo {
4
+ lockPath: string;
5
+ cause?: unknown;
6
+ }
7
+ export interface FileLockOptions {
8
+ lockPath: string;
9
+ errorFor: (kind: FileLockErrorKind, info: FileLockErrorInfo) => Error;
10
+ }
11
+ export interface LockErrorData {
12
+ createSubject: string;
13
+ busyMessage: string;
14
+ code: string;
15
+ target: string;
16
+ }
17
+ export declare function makeLockErrorFactory(data: LockErrorData): (kind: FileLockErrorKind, info: FileLockErrorInfo) => Error;
18
+ export declare function isNodeErrorCode(error: unknown, code: string): boolean;
19
+ export declare function pathIsFile(filePath: string): Promise<boolean>;
20
+ export declare function pathIsDirectory(dirPath: string): Promise<boolean>;
21
+ export declare function writeFileAtomically(filePath: string, content: string): Promise<void>;
22
+ export declare function acquireFileLock(options: FileLockOptions): Promise<nodeFs.promises.FileHandle>;
23
+ export declare function releaseFileLock(lock: nodeFs.promises.FileHandle, lockPath: string): Promise<void>;
@@ -0,0 +1,101 @@
1
+ import * as nodeFs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { ensureDir } from '../utils/file-system.js';
4
+ const fs = nodeFs.promises;
5
+ export function makeLockErrorFactory(data) {
6
+ return (kind, info) => {
7
+ const diagnostic = {
8
+ severity: 'error',
9
+ code: data.code,
10
+ message: kind === 'timeout' ? data.busyMessage : `Cannot create ${data.createSubject}.`,
11
+ target: data.target,
12
+ fix: kind === 'timeout'
13
+ ? `Retry shortly; if this persists, delete the stale lock file ${info.lockPath}.`
14
+ : `Check permissions on ${path.dirname(info.lockPath)}.`,
15
+ };
16
+ const error = new Error(diagnostic.message);
17
+ error.diagnostics = [diagnostic];
18
+ if (kind === 'create-failed' && info.cause) {
19
+ error.cause = info.cause;
20
+ }
21
+ return error;
22
+ };
23
+ }
24
+ const STALE_LOCK_THRESHOLD_MS = 30_000;
25
+ const LOCK_DEADLINE_MS = 5_000;
26
+ const LOCK_POLL_MS = 25;
27
+ export function isNodeErrorCode(error, code) {
28
+ return (typeof error === 'object' &&
29
+ error !== null &&
30
+ 'code' in error &&
31
+ error.code === code);
32
+ }
33
+ export async function pathIsFile(filePath) {
34
+ try {
35
+ return (await fs.stat(filePath)).isFile();
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ export async function pathIsDirectory(dirPath) {
42
+ try {
43
+ return (await fs.stat(dirPath)).isDirectory();
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ async function sleep(milliseconds) {
50
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
51
+ }
52
+ export async function writeFileAtomically(filePath, content) {
53
+ const dirPath = path.dirname(filePath);
54
+ await ensureDir(dirPath);
55
+ const tempPath = path.join(dirPath, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
56
+ try {
57
+ await fs.writeFile(tempPath, content, 'utf-8');
58
+ await fs.rename(tempPath, filePath);
59
+ }
60
+ catch (error) {
61
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
62
+ throw error;
63
+ }
64
+ }
65
+ export async function acquireFileLock(options) {
66
+ const { lockPath, errorFor } = options;
67
+ const lockDir = path.dirname(lockPath);
68
+ await ensureDir(lockDir);
69
+ const deadline = Date.now() + LOCK_DEADLINE_MS;
70
+ while (true) {
71
+ try {
72
+ return await fs.open(lockPath, 'wx');
73
+ }
74
+ catch (error) {
75
+ if (!isNodeErrorCode(error, 'EEXIST')) {
76
+ throw errorFor('create-failed', { lockPath, cause: error });
77
+ }
78
+ let staleStolen = false;
79
+ try {
80
+ const lockStat = await fs.stat(lockPath);
81
+ if (Date.now() - lockStat.mtimeMs > STALE_LOCK_THRESHOLD_MS) {
82
+ await fs.rm(lockPath, { force: true });
83
+ staleStolen = true;
84
+ }
85
+ }
86
+ catch {
87
+ // Holder released between open and stat — retry within deadline.
88
+ }
89
+ if (!staleStolen) {
90
+ if (Date.now() >= deadline) {
91
+ throw errorFor('timeout', { lockPath });
92
+ }
93
+ await sleep(LOCK_POLL_MS);
94
+ }
95
+ }
96
+ }
97
+ }
98
+ export async function releaseFileLock(lock, lockPath) {
99
+ await lock.close().catch(() => undefined);
100
+ await fs.rm(lockPath, { force: true }).catch(() => undefined);
101
+ }
@@ -0,0 +1,26 @@
1
+ export declare const GLOBAL_CONFIG_DIR_NAME = "specflow";
2
+ export declare const GLOBAL_CONFIG_FILE_NAME = "config.json";
3
+ export declare const GLOBAL_DATA_DIR_NAME = "specflow";
4
+ export interface GlobalConfig {
5
+ defaultStore?: string;
6
+ }
7
+ export interface GlobalConfigDirOptions {
8
+ env?: NodeJS.ProcessEnv;
9
+ platform?: NodeJS.Platform;
10
+ homedir?: string;
11
+ }
12
+ export interface GlobalDataDirOptions {
13
+ env?: NodeJS.ProcessEnv;
14
+ platform?: NodeJS.Platform;
15
+ homedir?: string;
16
+ }
17
+ export interface GlobalConfigLoadOptions {
18
+ configPath?: string;
19
+ }
20
+ export declare function getGlobalConfigDir(options?: GlobalConfigDirOptions): string;
21
+ export declare function getGlobalDataDir(options?: GlobalDataDirOptions): string;
22
+ export declare function getGlobalConfigPath(options?: GlobalConfigDirOptions): string;
23
+ export declare function getGlobalConfig(options?: GlobalConfigLoadOptions & GlobalConfigDirOptions): GlobalConfig;
24
+ export declare function saveGlobalConfig(config: GlobalConfig, options?: GlobalConfigDirOptions): void;
25
+ export declare function getStoreRegistryPath(dataDir?: string, options?: GlobalDataDirOptions): string;
26
+ export declare function getWorksetsStatePath(dataDir?: string, options?: GlobalDataDirOptions): string;
@@ -0,0 +1,77 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ export const GLOBAL_CONFIG_DIR_NAME = 'specflow';
5
+ export const GLOBAL_CONFIG_FILE_NAME = 'config.json';
6
+ export const GLOBAL_DATA_DIR_NAME = 'specflow';
7
+ const DEFAULT_CONFIG = {};
8
+ function joinGlobalDataPath(platform, ...segments) {
9
+ return platform === 'win32' ? path.win32.join(...segments) : path.posix.join(...segments);
10
+ }
11
+ export function getGlobalConfigDir(options = {}) {
12
+ const env = options.env ?? process.env;
13
+ const platform = options.platform ?? os.platform();
14
+ const homedir = options.homedir ?? os.homedir();
15
+ const xdgConfigHome = env.XDG_CONFIG_HOME;
16
+ if (xdgConfigHome) {
17
+ return joinGlobalDataPath(platform, xdgConfigHome, GLOBAL_CONFIG_DIR_NAME);
18
+ }
19
+ if (platform === 'win32') {
20
+ const appData = env.APPDATA;
21
+ if (appData) {
22
+ return joinGlobalDataPath(platform, appData, GLOBAL_CONFIG_DIR_NAME);
23
+ }
24
+ return joinGlobalDataPath(platform, homedir, 'AppData', 'Roaming', GLOBAL_CONFIG_DIR_NAME);
25
+ }
26
+ return joinGlobalDataPath(platform, homedir, '.config', GLOBAL_CONFIG_DIR_NAME);
27
+ }
28
+ export function getGlobalDataDir(options = {}) {
29
+ const env = options.env ?? process.env;
30
+ const platform = options.platform ?? os.platform();
31
+ const homedir = options.homedir ?? os.homedir();
32
+ const xdgDataHome = env.XDG_DATA_HOME;
33
+ if (xdgDataHome) {
34
+ return joinGlobalDataPath(platform, xdgDataHome, GLOBAL_DATA_DIR_NAME);
35
+ }
36
+ if (platform === 'win32') {
37
+ const localAppData = env.LOCALAPPDATA;
38
+ if (localAppData) {
39
+ return joinGlobalDataPath(platform, localAppData, GLOBAL_DATA_DIR_NAME);
40
+ }
41
+ return joinGlobalDataPath(platform, homedir, 'AppData', 'Local', GLOBAL_DATA_DIR_NAME);
42
+ }
43
+ return joinGlobalDataPath(platform, homedir, '.local', 'share', GLOBAL_DATA_DIR_NAME);
44
+ }
45
+ export function getGlobalConfigPath(options = {}) {
46
+ return path.join(getGlobalConfigDir(options), GLOBAL_CONFIG_FILE_NAME);
47
+ }
48
+ export function getGlobalConfig(options = {}) {
49
+ const configPath = options.configPath ?? getGlobalConfigPath(options);
50
+ try {
51
+ if (!fs.existsSync(configPath)) {
52
+ return { ...DEFAULT_CONFIG };
53
+ }
54
+ const content = fs.readFileSync(configPath, 'utf-8');
55
+ const parsed = JSON.parse(content);
56
+ return { ...DEFAULT_CONFIG, ...parsed };
57
+ }
58
+ catch {
59
+ return { ...DEFAULT_CONFIG };
60
+ }
61
+ }
62
+ export function saveGlobalConfig(config, options = {}) {
63
+ const configDir = getGlobalConfigDir(options);
64
+ const configPath = path.join(configDir, GLOBAL_CONFIG_FILE_NAME);
65
+ if (!fs.existsSync(configDir)) {
66
+ fs.mkdirSync(configDir, { recursive: true });
67
+ }
68
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
69
+ }
70
+ export function getStoreRegistryPath(dataDir, options = {}) {
71
+ const base = dataDir ?? getGlobalDataDir(options);
72
+ return path.join(base, 'stores', 'registry.yaml');
73
+ }
74
+ export function getWorksetsStatePath(dataDir, options = {}) {
75
+ const base = dataDir ?? getGlobalDataDir(options);
76
+ return path.join(base, 'worksets', 'worksets.yaml');
77
+ }
@@ -0,0 +1,3 @@
1
+ import type { OpenerDefinition } from './openers.js';
2
+ export declare function isOpenerAvailable(opener: OpenerDefinition): boolean;
3
+ export declare function launchOpener(opener: OpenerDefinition, workspacePath: string, primaryPath: string): void;
@@ -0,0 +1,20 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ export function isOpenerAvailable(opener) {
3
+ const result = spawnSync(process.platform === 'win32' ? 'where' : 'which', [opener.command], {
4
+ stdio: 'ignore',
5
+ });
6
+ return result.status === 0;
7
+ }
8
+ export function launchOpener(opener, workspacePath, primaryPath) {
9
+ const child = spawnSync(opener.command, [workspacePath], {
10
+ cwd: primaryPath,
11
+ shell: false,
12
+ stdio: 'inherit',
13
+ });
14
+ if (child.error) {
15
+ throw child.error;
16
+ }
17
+ if (child.status !== 0 && child.status !== null) {
18
+ throw new Error(`${opener.command} exited with status ${child.status}.`);
19
+ }
20
+ }
@@ -0,0 +1,23 @@
1
+ export type OpenerStyle = 'workspace-file';
2
+ export interface OpenerDefinition {
3
+ readonly id: string;
4
+ readonly label: string;
5
+ readonly style: OpenerStyle;
6
+ readonly command: string;
7
+ }
8
+ export declare const BUILTIN_OPENERS: readonly OpenerDefinition[];
9
+ export declare function findOpener(table: readonly OpenerDefinition[], id: string): OpenerDefinition | null;
10
+ export interface WorksetMemberInput {
11
+ readonly name: string;
12
+ readonly path: string;
13
+ }
14
+ export interface LaunchCommand {
15
+ readonly executable: string;
16
+ readonly args: string[];
17
+ readonly cwd: string;
18
+ }
19
+ export declare function buildLaunchCommand(opener: OpenerDefinition, input: {
20
+ members: WorksetMemberInput[];
21
+ codeWorkspacePath: string;
22
+ }): LaunchCommand;
23
+ export declare function isCliAgentOpener(id: string): boolean;
@@ -0,0 +1,20 @@
1
+ export const BUILTIN_OPENERS = [
2
+ { id: 'cursor', label: 'Cursor', style: 'workspace-file', command: 'cursor' },
3
+ { id: 'code', label: 'VS Code', style: 'workspace-file', command: 'code' },
4
+ ];
5
+ export function findOpener(table, id) {
6
+ return table.find((opener) => opener.id === id) ?? null;
7
+ }
8
+ export function buildLaunchCommand(opener, input) {
9
+ if (input.members.length === 0) {
10
+ throw new Error('buildLaunchCommand requires at least one member.');
11
+ }
12
+ return {
13
+ executable: opener.command,
14
+ args: [input.codeWorkspacePath],
15
+ cwd: input.members[0].path,
16
+ };
17
+ }
18
+ export function isCliAgentOpener(id) {
19
+ return id === 'claude' || id === 'codex';
20
+ }
@@ -0,0 +1,14 @@
1
+ import type { Diagnostic } from './diagnostics.js';
2
+ export interface NormalizedReference {
3
+ readonly id: string;
4
+ readonly remote?: string;
5
+ }
6
+ export interface ParsedProjectConfig {
7
+ readonly schema: string;
8
+ readonly context?: string;
9
+ readonly store?: string;
10
+ readonly references: readonly NormalizedReference[];
11
+ readonly diagnostics: readonly Diagnostic[];
12
+ }
13
+ export declare function parseProjectConfig(raw: unknown): ParsedProjectConfig;
14
+ export declare function loadProjectConfigFromObject(raw: unknown): ParsedProjectConfig;
@@ -0,0 +1,73 @@
1
+ import { z } from 'zod';
2
+ const KEBAB_CASE_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
3
+ const ReferenceObjectSchema = z
4
+ .object({
5
+ id: z.string().regex(KEBAB_CASE_ID),
6
+ remote: z.string().url().optional(),
7
+ })
8
+ .strict();
9
+ function referenceDiagnostic(message, target) {
10
+ return {
11
+ severity: 'warning',
12
+ code: 'invalid_reference',
13
+ message,
14
+ target,
15
+ fix: 'Fix or remove the invalid reference entry in specflow/config.yaml.',
16
+ };
17
+ }
18
+ export function parseProjectConfig(raw) {
19
+ const diagnostics = [];
20
+ const record = typeof raw === 'object' && raw !== null ? raw : {};
21
+ const schema = typeof record.schema === 'string' ? record.schema : 'specflow';
22
+ const context = typeof record.context === 'string' ? record.context : undefined;
23
+ let store;
24
+ if (record.store !== undefined) {
25
+ if (typeof record.store === 'string' && KEBAB_CASE_ID.test(record.store)) {
26
+ store = record.store;
27
+ }
28
+ else {
29
+ diagnostics.push({
30
+ severity: 'error',
31
+ code: 'invalid_store_pointer',
32
+ message: 'Project store pointer must be a kebab-case store ID.',
33
+ target: 'config.store',
34
+ fix: 'Set store to a valid kebab-case ID such as platform-contracts.',
35
+ });
36
+ }
37
+ }
38
+ const references = [];
39
+ const seen = new Set();
40
+ if (Array.isArray(record.references)) {
41
+ for (const [index, entry] of record.references.entries()) {
42
+ if (typeof entry === 'string') {
43
+ if (!KEBAB_CASE_ID.test(entry)) {
44
+ diagnostics.push(referenceDiagnostic(`Invalid reference ID '${entry}'.`, `config.references[${index}]`));
45
+ continue;
46
+ }
47
+ if (seen.has(entry)) {
48
+ continue;
49
+ }
50
+ seen.add(entry);
51
+ references.push({ id: entry });
52
+ continue;
53
+ }
54
+ const parsed = ReferenceObjectSchema.safeParse(entry);
55
+ if (!parsed.success) {
56
+ diagnostics.push(referenceDiagnostic(parsed.error.message, `config.references[${index}]`));
57
+ continue;
58
+ }
59
+ if (seen.has(parsed.data.id)) {
60
+ continue;
61
+ }
62
+ seen.add(parsed.data.id);
63
+ references.push({
64
+ id: parsed.data.id,
65
+ ...(parsed.data.remote ? { remote: parsed.data.remote } : {}),
66
+ });
67
+ }
68
+ }
69
+ return { schema, context, store, references, diagnostics };
70
+ }
71
+ export function loadProjectConfigFromObject(raw) {
72
+ return parseProjectConfig(raw);
73
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReferencedStoresSection } from './artifact-graph/instruction-loader.js';
2
+ import type { NormalizedReference } from './project-config.js';
3
+ import type { StoreRegistry } from './store/foundation.js';
4
+ export declare function buildReferencedStoreIndex(input: {
5
+ references: readonly NormalizedReference[];
6
+ registry: StoreRegistry;
7
+ rootStoreId?: string;
8
+ }): Promise<ReferencedStoresSection>;
@@ -0,0 +1,80 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { sortDiagnostics } from './diagnostics.js';
4
+ import { buildReferenceIndex, normalizeReferences, sanitizeReferenceField } from './references.js';
5
+ import { inspectStoreEntry } from './store/health.js';
6
+ function extractPurpose(content) {
7
+ const match = content.match(/^## Purpose\s*\n+([^\n#]+)/m);
8
+ return match?.[1]?.trim() ?? 'No purpose summary available.';
9
+ }
10
+ async function listBaselineSpecs(specsDir) {
11
+ try {
12
+ const entries = await fs.readdir(specsDir, { withFileTypes: true });
13
+ const specs = [];
14
+ for (const entry of entries) {
15
+ if (!entry.isDirectory()) {
16
+ continue;
17
+ }
18
+ const specPath = path.join(specsDir, entry.name, 'spec.md');
19
+ try {
20
+ const content = await fs.readFile(specPath, 'utf-8');
21
+ specs.push({ specId: entry.name, purpose: extractPurpose(content) });
22
+ }
23
+ catch {
24
+ // skip unreadable specs
25
+ }
26
+ }
27
+ return specs.sort((a, b) => a.specId.localeCompare(b.specId));
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ export async function buildReferencedStoreIndex(input) {
34
+ const normalized = normalizeReferences(input.references, input.rootStoreId);
35
+ const indexEntries = [];
36
+ const diagnostics = [];
37
+ for (const ref of normalized) {
38
+ const entry = input.registry.stores[ref.id];
39
+ if (!entry) {
40
+ diagnostics.push({
41
+ severity: 'warning',
42
+ code: 'reference_unregistered',
43
+ message: `Referenced store '${ref.id}' is not registered.`,
44
+ target: `references.${ref.id}`,
45
+ fix: `Run specflow store register --id ${ref.id} <path> --yes`,
46
+ });
47
+ continue;
48
+ }
49
+ const storeDiagnostics = await inspectStoreEntry(ref.id, entry);
50
+ const blocking = storeDiagnostics.some((d) => d.severity === 'error');
51
+ if (blocking) {
52
+ diagnostics.push(...storeDiagnostics.filter((d) => d.severity === 'error'));
53
+ continue;
54
+ }
55
+ const specs = await listBaselineSpecs(path.join(entry.backend.local_path, 'specflow', 'specs'));
56
+ if (specs.length === 0) {
57
+ diagnostics.push({
58
+ severity: 'info',
59
+ code: 'reference_no_specs',
60
+ message: `Referenced store '${ref.id}' has no baseline specs to index.`,
61
+ target: `references.${ref.id}`,
62
+ });
63
+ continue;
64
+ }
65
+ for (const spec of specs) {
66
+ indexEntries.push({
67
+ storeId: ref.id,
68
+ specId: spec.specId,
69
+ purpose: sanitizeReferenceField(spec.purpose),
70
+ fetchCommand: `specflow show ${sanitizeReferenceField(spec.specId)} --type spec --store ${ref.id}`,
71
+ });
72
+ }
73
+ }
74
+ const index = buildReferenceIndex(indexEntries);
75
+ return {
76
+ rendered: index.rendered,
77
+ truncated: index.truncated,
78
+ diagnostics: sortDiagnostics([...diagnostics, ...index.diagnostics]),
79
+ };
80
+ }
@@ -0,0 +1,17 @@
1
+ import type { Diagnostic } from './diagnostics.js';
2
+ import type { NormalizedReference } from './project-config.js';
3
+ export declare const REFERENCE_INDEX_BUDGET_BYTES: number;
4
+ export interface ReferenceEntry {
5
+ readonly storeId: string;
6
+ readonly specId: string;
7
+ readonly purpose: string;
8
+ readonly fetchCommand: string;
9
+ }
10
+ export interface ReferenceIndexResult {
11
+ readonly rendered: string;
12
+ readonly truncated: boolean;
13
+ readonly diagnostics: readonly Diagnostic[];
14
+ }
15
+ export declare function normalizeReferences(references: readonly NormalizedReference[], rootStoreId?: string): NormalizedReference[];
16
+ export declare function sanitizeReferenceField(value: string, maxLength?: number): string;
17
+ export declare function buildReferenceIndex(entries: readonly ReferenceEntry[]): ReferenceIndexResult;