@gordon.gan/specflow 1.1.1 → 1.2.0-beta.1
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.
- package/README.md +34 -8
- package/dist/cli/commands/change-archive.js +4 -4
- package/dist/cli/commands/change-new.js +5 -5
- package/dist/cli/commands/change-phase.js +4 -4
- package/dist/cli/commands/change-status.js +4 -4
- package/dist/cli/commands/context.d.ts +18 -0
- package/dist/cli/commands/context.js +125 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +20 -6
- package/dist/cli/commands/instructions.d.ts +4 -1
- package/dist/cli/commands/instructions.js +62 -9
- package/dist/cli/commands/show.d.ts +22 -0
- package/dist/cli/commands/show.js +92 -0
- package/dist/cli/commands/store.d.ts +16 -0
- package/dist/cli/commands/store.js +221 -0
- package/dist/cli/commands/validate.d.ts +16 -0
- package/dist/cli/commands/validate.js +34 -4
- package/dist/cli/commands/workset.d.ts +12 -0
- package/dist/cli/commands/workset.js +235 -0
- package/dist/cli/index.js +8 -0
- package/dist/cli/shared/store-option.d.ts +11 -0
- package/dist/cli/shared/store-option.js +40 -0
- package/dist/core/artifact-graph/instruction-loader.d.ts +23 -0
- package/dist/core/artifact-graph/instruction-loader.js +6 -0
- package/dist/core/artifact-graph/types.d.ts +2 -2
- package/dist/core/artifact-language.d.ts +7 -0
- package/dist/core/artifact-language.js +33 -0
- package/dist/core/context-assembly.d.ts +9 -0
- package/dist/core/context-assembly.js +68 -0
- package/dist/core/diagnostics.d.ts +11 -0
- package/dist/core/diagnostics.js +18 -0
- package/dist/core/file-state.d.ts +23 -0
- package/dist/core/file-state.js +101 -0
- package/dist/core/global-config.d.ts +26 -0
- package/dist/core/global-config.js +77 -0
- package/dist/core/opener-launch.d.ts +3 -0
- package/dist/core/opener-launch.js +20 -0
- package/dist/core/openers.d.ts +23 -0
- package/dist/core/openers.js +20 -0
- package/dist/core/project-config.d.ts +16 -0
- package/dist/core/project-config.js +104 -0
- package/dist/core/reference-index.d.ts +8 -0
- package/dist/core/reference-index.js +80 -0
- package/dist/core/references.d.ts +17 -0
- package/dist/core/references.js +51 -0
- package/dist/core/relationship-health.d.ts +22 -0
- package/dist/core/relationship-health.js +68 -0
- package/dist/core/root-selection.d.ts +26 -0
- package/dist/core/root-selection.js +197 -0
- package/dist/core/store/errors.d.ts +2 -0
- package/dist/core/store/errors.js +1 -0
- package/dist/core/store/foundation.d.ts +141 -0
- package/dist/core/store/foundation.js +79 -0
- package/dist/core/store/health.d.ts +13 -0
- package/dist/core/store/health.js +117 -0
- package/dist/core/store/operations.d.ts +56 -0
- package/dist/core/store/operations.js +268 -0
- package/dist/core/store/registry.d.ts +15 -0
- package/dist/core/store/registry.js +128 -0
- package/dist/core/working-set.d.ts +30 -0
- package/dist/core/working-set.js +26 -0
- package/dist/core/worksets.d.ts +71 -0
- package/dist/core/worksets.js +134 -0
- package/dist/integrations/shared/skill-renderer.d.ts +6 -0
- package/dist/integrations/shared/skill-renderer.js +22 -0
- package/package.json +1 -1
- package/prompts/shared/artifact-language.md +30 -0
- package/skills/specflow-apply/SKILL.md +12 -27
- package/skills/specflow-explore/SKILL.md +6 -0
- package/skills/specflow-propose/SKILL.md +6 -0
- package/skills/specflow-refine/SKILL.md +6 -0
- package/skills/specflow-snap/SKILL.md +6 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { type ResolvePlanningRootOptions, type ResolvedPlanningRoot } from '../../core/root-selection.js';
|
|
3
|
+
export interface CommandPlanningRootOptions extends ResolvePlanningRootOptions {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function resolveCommandPlanningRoot(options?: CommandPlanningRootOptions): Promise<ResolvedPlanningRoot>;
|
|
7
|
+
export declare function addStoreOption(command: Command): Command;
|
|
8
|
+
export declare function resolveRootFromCommandOptions(options: {
|
|
9
|
+
store?: string;
|
|
10
|
+
cwd?: string;
|
|
11
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getGlobalConfig, getStoreRegistryPath } from '../../core/global-config.js';
|
|
2
|
+
import { readRegistry } from '../../core/store/registry.js';
|
|
3
|
+
import { hasErrorDiagnostic } from '../../core/diagnostics.js';
|
|
4
|
+
import { resolvePlanningRoot, } from '../../core/root-selection.js';
|
|
5
|
+
export async function resolveCommandPlanningRoot(options = {}) {
|
|
6
|
+
const registryPath = getStoreRegistryPath();
|
|
7
|
+
let registry = options.registry;
|
|
8
|
+
if (!registry) {
|
|
9
|
+
try {
|
|
10
|
+
registry = await readRegistry(registryPath);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
registry = { version: 1, stores: {} };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const globalConfig = options.globalConfig ?? getGlobalConfig();
|
|
17
|
+
const resolved = await resolvePlanningRoot({
|
|
18
|
+
startPath: options.cwd ?? options.startPath ?? process.cwd(),
|
|
19
|
+
store: options.store,
|
|
20
|
+
registry,
|
|
21
|
+
globalConfig,
|
|
22
|
+
readConfig: options.readConfig,
|
|
23
|
+
allowImplicitRoot: options.allowImplicitRoot,
|
|
24
|
+
});
|
|
25
|
+
if (hasErrorDiagnostic(resolved.diagnostics) && resolved.source === 'implicit') {
|
|
26
|
+
const message = resolved.diagnostics.map((d) => d.message).join('\n');
|
|
27
|
+
throw new Error(message);
|
|
28
|
+
}
|
|
29
|
+
return resolved;
|
|
30
|
+
}
|
|
31
|
+
export function addStoreOption(command) {
|
|
32
|
+
return command.option('--store <id>', 'Select planning store for this command');
|
|
33
|
+
}
|
|
34
|
+
export async function resolveRootFromCommandOptions(options) {
|
|
35
|
+
const resolved = await resolveCommandPlanningRoot({
|
|
36
|
+
cwd: options.cwd,
|
|
37
|
+
store: options.store,
|
|
38
|
+
});
|
|
39
|
+
return resolved.root;
|
|
40
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { SchemaYaml } from './types.js';
|
|
2
|
+
import type { Diagnostic } from '../diagnostics.js';
|
|
3
|
+
import type { RootSource } from '../root-selection.js';
|
|
4
|
+
import { type ArtifactLanguage } from '../artifact-language.js';
|
|
2
5
|
/**
|
|
3
6
|
* Dependency information included in artifact instructions.
|
|
4
7
|
*/
|
|
@@ -10,6 +13,15 @@ export interface DependencyInfo {
|
|
|
10
13
|
/** Description of the dependency artifact */
|
|
11
14
|
readonly description: string;
|
|
12
15
|
}
|
|
16
|
+
export interface ReferencedStoresSection {
|
|
17
|
+
readonly rendered: string;
|
|
18
|
+
readonly truncated: boolean;
|
|
19
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
20
|
+
}
|
|
21
|
+
export interface RootProvenanceSection {
|
|
22
|
+
readonly source: RootSource;
|
|
23
|
+
readonly storeId?: string;
|
|
24
|
+
}
|
|
13
25
|
/**
|
|
14
26
|
* Loaded instructions for creating an artifact.
|
|
15
27
|
*/
|
|
@@ -18,6 +30,14 @@ export interface ArtifactInstructions {
|
|
|
18
30
|
readonly instruction: string | undefined;
|
|
19
31
|
/** Project context from the config */
|
|
20
32
|
readonly context: string | undefined;
|
|
33
|
+
/** Canonical language for human-readable artifact content */
|
|
34
|
+
readonly artifactLanguage: ArtifactLanguage;
|
|
35
|
+
/** Rendered generation policy for the selected artifact language */
|
|
36
|
+
readonly languageGuidance: string;
|
|
37
|
+
/** Separately budgeted referenced-store index */
|
|
38
|
+
readonly referencedStores?: ReferencedStoresSection;
|
|
39
|
+
/** Selected planning root provenance */
|
|
40
|
+
readonly rootProvenance?: RootProvenanceSection;
|
|
21
41
|
/** Dependencies with their metadata */
|
|
22
42
|
readonly dependencies: readonly DependencyInfo[];
|
|
23
43
|
/** The artifact's generates path */
|
|
@@ -30,6 +50,9 @@ export interface ArtifactInstructions {
|
|
|
30
50
|
*/
|
|
31
51
|
export interface ProjectConfig {
|
|
32
52
|
readonly context?: string;
|
|
53
|
+
readonly artifactLanguage?: ArtifactLanguage;
|
|
54
|
+
readonly referencedStores?: ReferencedStoresSection;
|
|
55
|
+
readonly rootProvenance?: RootProvenanceSection;
|
|
33
56
|
readonly [key: string]: unknown;
|
|
34
57
|
}
|
|
35
58
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_ARTIFACT_LANGUAGE, renderArtifactLanguageGuidance, } from '../artifact-language.js';
|
|
1
2
|
/**
|
|
2
3
|
* Loads enriched instructions for creating an artifact.
|
|
3
4
|
*
|
|
@@ -14,9 +15,14 @@ export function loadInstructions(artifactId, changeDir, schema, config) {
|
|
|
14
15
|
throw new Error(`Artifact '${artifactId}' not found in schema '${schema.name}'`);
|
|
15
16
|
}
|
|
16
17
|
const dependencies = buildDependencyInfo(artifact, schema);
|
|
18
|
+
const artifactLanguage = config?.artifactLanguage ?? DEFAULT_ARTIFACT_LANGUAGE;
|
|
17
19
|
return {
|
|
18
20
|
instruction: artifact.instruction,
|
|
19
21
|
context: config?.context ?? undefined,
|
|
22
|
+
artifactLanguage,
|
|
23
|
+
languageGuidance: renderArtifactLanguageGuidance(artifactLanguage),
|
|
24
|
+
referencedStores: config?.referencedStores,
|
|
25
|
+
rootProvenance: config?.rootProvenance,
|
|
20
26
|
dependencies,
|
|
21
27
|
generates: artifact.generates,
|
|
22
28
|
description: artifact.description,
|
|
@@ -77,7 +77,6 @@ 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;
|
|
82
81
|
artifacts: {
|
|
83
82
|
id: string;
|
|
@@ -86,6 +85,7 @@ export declare const SchemaYamlSchema: z.ZodObject<{
|
|
|
86
85
|
requires: string[];
|
|
87
86
|
instruction?: string | undefined;
|
|
88
87
|
}[];
|
|
88
|
+
name: string;
|
|
89
89
|
apply?: {
|
|
90
90
|
requires: string[];
|
|
91
91
|
instruction?: string | undefined;
|
|
@@ -93,7 +93,6 @@ export declare const SchemaYamlSchema: z.ZodObject<{
|
|
|
93
93
|
} | undefined;
|
|
94
94
|
description?: string | undefined;
|
|
95
95
|
}, {
|
|
96
|
-
name: string;
|
|
97
96
|
version: number;
|
|
98
97
|
artifacts: {
|
|
99
98
|
id: string;
|
|
@@ -102,6 +101,7 @@ export declare const SchemaYamlSchema: z.ZodObject<{
|
|
|
102
101
|
instruction?: string | undefined;
|
|
103
102
|
requires?: string[] | undefined;
|
|
104
103
|
}[];
|
|
104
|
+
name: string;
|
|
105
105
|
apply?: {
|
|
106
106
|
requires: string[];
|
|
107
107
|
instruction?: string | undefined;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const ARTIFACT_LANGUAGES: readonly ["en", "zh-CN"];
|
|
2
|
+
export type ArtifactLanguage = (typeof ARTIFACT_LANGUAGES)[number];
|
|
3
|
+
export declare const DEFAULT_ARTIFACT_LANGUAGE: ArtifactLanguage;
|
|
4
|
+
export declare function isArtifactLanguage(value: unknown): value is ArtifactLanguage;
|
|
5
|
+
export declare function normalizeArtifactLanguage(value: unknown): ArtifactLanguage | undefined;
|
|
6
|
+
export declare function requireArtifactLanguage(value: unknown): ArtifactLanguage;
|
|
7
|
+
export declare function renderArtifactLanguageGuidance(language: ArtifactLanguage): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const ARTIFACT_LANGUAGES = ['en', 'zh-CN'];
|
|
2
|
+
export const DEFAULT_ARTIFACT_LANGUAGE = 'en';
|
|
3
|
+
export function isArtifactLanguage(value) {
|
|
4
|
+
return value === 'en' || value === 'zh-CN';
|
|
5
|
+
}
|
|
6
|
+
export function normalizeArtifactLanguage(value) {
|
|
7
|
+
if (value === 'zh') {
|
|
8
|
+
return 'zh-CN';
|
|
9
|
+
}
|
|
10
|
+
return isArtifactLanguage(value) ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
export function requireArtifactLanguage(value) {
|
|
13
|
+
const language = normalizeArtifactLanguage(value);
|
|
14
|
+
if (language) {
|
|
15
|
+
return language;
|
|
16
|
+
}
|
|
17
|
+
throw new Error(`Unsupported artifact language ${JSON.stringify(value)}. Supported values: en, zh-CN (alias: zh).`);
|
|
18
|
+
}
|
|
19
|
+
export function renderArtifactLanguageGuidance(language) {
|
|
20
|
+
const narrativeLanguage = language === 'zh-CN' ? 'Simplified Chinese' : 'English';
|
|
21
|
+
return [
|
|
22
|
+
'## Artifact language policy',
|
|
23
|
+
'',
|
|
24
|
+
`Write human-readable artifact content in ${narrativeLanguage}.`,
|
|
25
|
+
'',
|
|
26
|
+
'Keep SpecFlow protocol markers unchanged, including:',
|
|
27
|
+
'- `ADDED Requirements`, `MODIFIED Requirements`, `REMOVED Requirements`, and `RENAMED Requirements`',
|
|
28
|
+
'- `Requirement:`, `Scenario:`, `WHEN`, and `THEN`',
|
|
29
|
+
'',
|
|
30
|
+
'Keep capability IDs, change names, file paths, commands, code, and symbols unchanged.',
|
|
31
|
+
'When modifying an existing requirement, preserve its exact name when delta matching requires it.',
|
|
32
|
+
].join('\n');
|
|
33
|
+
}
|
|
@@ -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,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,16 @@
|
|
|
1
|
+
import type { Diagnostic } from './diagnostics.js';
|
|
2
|
+
import { type ArtifactLanguage } from './artifact-language.js';
|
|
3
|
+
export interface NormalizedReference {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly remote?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ParsedProjectConfig {
|
|
8
|
+
readonly schema: string;
|
|
9
|
+
readonly context?: string;
|
|
10
|
+
readonly artifactLanguage: ArtifactLanguage;
|
|
11
|
+
readonly store?: string;
|
|
12
|
+
readonly references: readonly NormalizedReference[];
|
|
13
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
14
|
+
}
|
|
15
|
+
export declare function parseProjectConfig(raw: unknown): ParsedProjectConfig;
|
|
16
|
+
export declare function loadProjectConfigFromObject(raw: unknown): ParsedProjectConfig;
|