@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,104 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { DEFAULT_ARTIFACT_LANGUAGE, isArtifactLanguage, } from './artifact-language.js';
|
|
3
|
+
const KEBAB_CASE_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
4
|
+
const ReferenceObjectSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
id: z.string().regex(KEBAB_CASE_ID),
|
|
7
|
+
remote: z.string().url().optional(),
|
|
8
|
+
})
|
|
9
|
+
.strict();
|
|
10
|
+
function referenceDiagnostic(message, target) {
|
|
11
|
+
return {
|
|
12
|
+
severity: 'warning',
|
|
13
|
+
code: 'invalid_reference',
|
|
14
|
+
message,
|
|
15
|
+
target,
|
|
16
|
+
fix: 'Fix or remove the invalid reference entry in specflow/config.yaml.',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function parseProjectConfig(raw) {
|
|
20
|
+
const diagnostics = [];
|
|
21
|
+
const record = typeof raw === 'object' && raw !== null ? raw : {};
|
|
22
|
+
const schema = typeof record.schema === 'string' ? record.schema : 'specflow';
|
|
23
|
+
const context = typeof record.context === 'string' ? record.context : undefined;
|
|
24
|
+
let artifactLanguage = DEFAULT_ARTIFACT_LANGUAGE;
|
|
25
|
+
if (record.artifacts !== undefined) {
|
|
26
|
+
const artifacts = typeof record.artifacts === 'object' &&
|
|
27
|
+
record.artifacts !== null &&
|
|
28
|
+
!Array.isArray(record.artifacts)
|
|
29
|
+
? record.artifacts
|
|
30
|
+
: undefined;
|
|
31
|
+
const configuredLanguage = artifacts?.language;
|
|
32
|
+
if (!artifacts ||
|
|
33
|
+
(configuredLanguage !== undefined &&
|
|
34
|
+
!isArtifactLanguage(configuredLanguage))) {
|
|
35
|
+
diagnostics.push({
|
|
36
|
+
severity: 'error',
|
|
37
|
+
code: 'invalid_artifact_language',
|
|
38
|
+
message: "Project artifact language must be one of: 'en', 'zh-CN'.",
|
|
39
|
+
target: 'config.artifacts.language',
|
|
40
|
+
fix: "Set artifacts.language to 'en' or 'zh-CN'.",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
else if (isArtifactLanguage(configuredLanguage)) {
|
|
44
|
+
artifactLanguage = configuredLanguage;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
let store;
|
|
48
|
+
if (record.store !== undefined) {
|
|
49
|
+
if (typeof record.store === 'string' && KEBAB_CASE_ID.test(record.store)) {
|
|
50
|
+
store = record.store;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
diagnostics.push({
|
|
54
|
+
severity: 'error',
|
|
55
|
+
code: 'invalid_store_pointer',
|
|
56
|
+
message: 'Project store pointer must be a kebab-case store ID.',
|
|
57
|
+
target: 'config.store',
|
|
58
|
+
fix: 'Set store to a valid kebab-case ID such as platform-contracts.',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const references = [];
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
if (Array.isArray(record.references)) {
|
|
65
|
+
for (const [index, entry] of record.references.entries()) {
|
|
66
|
+
if (typeof entry === 'string') {
|
|
67
|
+
if (!KEBAB_CASE_ID.test(entry)) {
|
|
68
|
+
diagnostics.push(referenceDiagnostic(`Invalid reference ID '${entry}'.`, `config.references[${index}]`));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (seen.has(entry)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
seen.add(entry);
|
|
75
|
+
references.push({ id: entry });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const parsed = ReferenceObjectSchema.safeParse(entry);
|
|
79
|
+
if (!parsed.success) {
|
|
80
|
+
diagnostics.push(referenceDiagnostic(parsed.error.message, `config.references[${index}]`));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (seen.has(parsed.data.id)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
seen.add(parsed.data.id);
|
|
87
|
+
references.push({
|
|
88
|
+
id: parsed.data.id,
|
|
89
|
+
...(parsed.data.remote ? { remote: parsed.data.remote } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
schema,
|
|
95
|
+
context,
|
|
96
|
+
artifactLanguage,
|
|
97
|
+
store,
|
|
98
|
+
references,
|
|
99
|
+
diagnostics,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function loadProjectConfigFromObject(raw) {
|
|
103
|
+
return parseProjectConfig(raw);
|
|
104
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const REFERENCE_INDEX_BUDGET_BYTES = 50 * 1024;
|
|
2
|
+
export function normalizeReferences(references, rootStoreId) {
|
|
3
|
+
const seen = new Set();
|
|
4
|
+
const output = [];
|
|
5
|
+
for (const ref of references) {
|
|
6
|
+
if (rootStoreId && ref.id === rootStoreId) {
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
9
|
+
if (seen.has(ref.id)) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
seen.add(ref.id);
|
|
13
|
+
output.push(ref);
|
|
14
|
+
}
|
|
15
|
+
return output;
|
|
16
|
+
}
|
|
17
|
+
export function sanitizeReferenceField(value, maxLength = 200) {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/[\u0000-\u001F\u007F]/g, ' ')
|
|
20
|
+
.replace(/\s+/g, ' ')
|
|
21
|
+
.trim()
|
|
22
|
+
.slice(0, maxLength);
|
|
23
|
+
}
|
|
24
|
+
export function buildReferenceIndex(entries) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
const diagnostics = [];
|
|
27
|
+
let truncated = false;
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const line = `- ${sanitizeReferenceField(entry.storeId)}/${sanitizeReferenceField(entry.specId)}: ${sanitizeReferenceField(entry.purpose)} | ${entry.fetchCommand}`;
|
|
30
|
+
const candidate = [...lines, line].join('\n');
|
|
31
|
+
if (Buffer.byteLength(candidate, 'utf8') > REFERENCE_INDEX_BUDGET_BYTES) {
|
|
32
|
+
truncated = true;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
lines.push(line);
|
|
36
|
+
}
|
|
37
|
+
if (truncated) {
|
|
38
|
+
diagnostics.push({
|
|
39
|
+
severity: 'warning',
|
|
40
|
+
code: 'reference_index_truncated',
|
|
41
|
+
message: 'Referenced specification index exceeded the 50 KiB budget.',
|
|
42
|
+
target: 'references.index',
|
|
43
|
+
fix: 'Use specflow show or specflow context to fetch remaining referenced specs.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
rendered: lines.join('\n'),
|
|
48
|
+
truncated,
|
|
49
|
+
diagnostics,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Diagnostic } from './diagnostics.js';
|
|
2
|
+
import type { NormalizedReference } from './project-config.js';
|
|
3
|
+
import type { ParsedProjectConfig } from './project-config.js';
|
|
4
|
+
import type { StoreRegistry } from './store/foundation.js';
|
|
5
|
+
import type { ResolvedPlanningRoot } from './root-selection.js';
|
|
6
|
+
export interface RelationshipHealthResult {
|
|
7
|
+
readonly rootHealth: readonly Diagnostic[];
|
|
8
|
+
readonly selectedStoreHealth: readonly Diagnostic[];
|
|
9
|
+
readonly referenceHealth: Array<{
|
|
10
|
+
storeId: string;
|
|
11
|
+
diagnostics: Diagnostic[];
|
|
12
|
+
}>;
|
|
13
|
+
readonly registryHealth: readonly Diagnostic[];
|
|
14
|
+
readonly pointerHealth: readonly Diagnostic[];
|
|
15
|
+
readonly mutationsPerformed: false;
|
|
16
|
+
}
|
|
17
|
+
export declare function inspectRelationships(input: {
|
|
18
|
+
resolved: ResolvedPlanningRoot;
|
|
19
|
+
references: readonly NormalizedReference[];
|
|
20
|
+
registry: StoreRegistry;
|
|
21
|
+
projectConfig?: ParsedProjectConfig;
|
|
22
|
+
}): Promise<RelationshipHealthResult>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { sortDiagnostics } from './diagnostics.js';
|
|
2
|
+
import { inspectStoreEntry } from './store/health.js';
|
|
3
|
+
import { hasPlanningContent } from './root-selection.js';
|
|
4
|
+
export async function inspectRelationships(input) {
|
|
5
|
+
const pointerHealth = [];
|
|
6
|
+
if (input.projectConfig?.store && input.resolved.source === 'nearest') {
|
|
7
|
+
pointerHealth.push({
|
|
8
|
+
severity: 'info',
|
|
9
|
+
code: 'store_pointer_ignored',
|
|
10
|
+
message: `Project store pointer '${input.projectConfig.store}' is ignored because local planning content exists.`,
|
|
11
|
+
target: 'config.store',
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
const rootHealth = [...input.resolved.diagnostics];
|
|
15
|
+
if (!(await hasPlanningContent(input.resolved.root)) && !input.resolved.storeId) {
|
|
16
|
+
rootHealth.push({
|
|
17
|
+
severity: 'warning',
|
|
18
|
+
code: 'root_empty_planning',
|
|
19
|
+
message: 'Resolved planning root has no baseline specs or active changes.',
|
|
20
|
+
target: 'planning.root',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const selectedStoreHealth = [];
|
|
24
|
+
if (input.resolved.storeId) {
|
|
25
|
+
const entry = input.registry.stores[input.resolved.storeId];
|
|
26
|
+
if (!entry) {
|
|
27
|
+
selectedStoreHealth.push({
|
|
28
|
+
severity: 'error',
|
|
29
|
+
code: 'store_not_registered',
|
|
30
|
+
message: `Selected store '${input.resolved.storeId}' is not registered.`,
|
|
31
|
+
target: `stores.${input.resolved.storeId}`,
|
|
32
|
+
fix: `Run specflow store register --id ${input.resolved.storeId} <path> --yes`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
selectedStoreHealth.push(...(await inspectStoreEntry(input.resolved.storeId, entry)));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const referenceHealth = await Promise.all(input.references.map(async (ref) => {
|
|
40
|
+
const entry = input.registry.stores[ref.id];
|
|
41
|
+
if (!entry) {
|
|
42
|
+
return {
|
|
43
|
+
storeId: ref.id,
|
|
44
|
+
diagnostics: [
|
|
45
|
+
{
|
|
46
|
+
severity: 'warning',
|
|
47
|
+
code: 'reference_unregistered',
|
|
48
|
+
message: `Referenced store '${ref.id}' is not registered.`,
|
|
49
|
+
target: `references.${ref.id}`,
|
|
50
|
+
fix: `Run specflow store register --id ${ref.id} <path> --yes`,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
storeId: ref.id,
|
|
57
|
+
diagnostics: [...(await inspectStoreEntry(ref.id, entry))],
|
|
58
|
+
};
|
|
59
|
+
}));
|
|
60
|
+
return {
|
|
61
|
+
rootHealth: sortDiagnostics(rootHealth),
|
|
62
|
+
selectedStoreHealth: sortDiagnostics(selectedStoreHealth),
|
|
63
|
+
referenceHealth,
|
|
64
|
+
registryHealth: [],
|
|
65
|
+
pointerHealth: sortDiagnostics(pointerHealth),
|
|
66
|
+
mutationsPerformed: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Diagnostic } from './diagnostics.js';
|
|
2
|
+
import type { GlobalConfig } from './global-config.js';
|
|
3
|
+
import { type ParsedProjectConfig } from './project-config.js';
|
|
4
|
+
import type { StoreRegistry } from './store/foundation.js';
|
|
5
|
+
export type RootSource = 'store' | 'declared' | 'global_default' | 'nearest' | 'implicit';
|
|
6
|
+
export interface ResolvedPlanningRoot {
|
|
7
|
+
readonly root: string;
|
|
8
|
+
readonly specsDir: string;
|
|
9
|
+
readonly changesDir: string;
|
|
10
|
+
readonly archiveDir: string;
|
|
11
|
+
readonly source: RootSource;
|
|
12
|
+
readonly storeId?: string;
|
|
13
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
14
|
+
}
|
|
15
|
+
export interface ResolvePlanningRootOptions {
|
|
16
|
+
readonly startPath?: string;
|
|
17
|
+
readonly store?: string;
|
|
18
|
+
readonly allowImplicitRoot?: boolean;
|
|
19
|
+
readonly registry?: StoreRegistry;
|
|
20
|
+
readonly globalConfig?: GlobalConfig;
|
|
21
|
+
readonly readConfig?: (projectRoot: string) => ParsedProjectConfig;
|
|
22
|
+
}
|
|
23
|
+
export declare function hasBaselineSpec(root: string): Promise<boolean>;
|
|
24
|
+
export declare function hasNonArchiveChange(root: string): Promise<boolean>;
|
|
25
|
+
export declare function hasPlanningContent(root: string): Promise<boolean>;
|
|
26
|
+
export declare function resolvePlanningRoot(options?: ResolvePlanningRootOptions): Promise<ResolvedPlanningRoot>;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { parseProjectConfig } from './project-config.js';
|
|
6
|
+
import { findProjectRoot } from '../utils/project-root.js';
|
|
7
|
+
const ARCHIVE_DIR_NAME = 'archive';
|
|
8
|
+
async function directoryHasEntries(dirPath) {
|
|
9
|
+
try {
|
|
10
|
+
const entries = await fs.readdir(dirPath);
|
|
11
|
+
return entries.length > 0;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function hasBaselineSpec(root) {
|
|
18
|
+
const specsDir = path.join(root, 'specflow', 'specs');
|
|
19
|
+
if (!(await directoryHasEntries(specsDir))) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const entries = await fs.readdir(specsDir, { withFileTypes: true });
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
if (entry.isDirectory()) {
|
|
25
|
+
const specPath = path.join(specsDir, entry.name, 'spec.md');
|
|
26
|
+
try {
|
|
27
|
+
await fs.stat(specPath);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// continue
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
export async function hasNonArchiveChange(root) {
|
|
38
|
+
const changesDir = path.join(root, 'specflow', 'changes');
|
|
39
|
+
if (!(await directoryHasEntries(changesDir))) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const entries = await fs.readdir(changesDir, { withFileTypes: true });
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (!entry.isDirectory() || entry.name === ARCHIVE_DIR_NAME) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
export async function hasPlanningContent(root) {
|
|
52
|
+
return (await hasBaselineSpec(root)) || (await hasNonArchiveChange(root));
|
|
53
|
+
}
|
|
54
|
+
function resolveFromStoreId(storeId, registry, source) {
|
|
55
|
+
const entry = registry.stores[storeId];
|
|
56
|
+
if (!entry) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const root = entry.backend.local_path;
|
|
60
|
+
return {
|
|
61
|
+
root,
|
|
62
|
+
specsDir: path.join(root, 'specflow', 'specs'),
|
|
63
|
+
changesDir: path.join(root, 'specflow', 'changes'),
|
|
64
|
+
archiveDir: path.join(root, 'specflow', 'changes', ARCHIVE_DIR_NAME),
|
|
65
|
+
source,
|
|
66
|
+
storeId,
|
|
67
|
+
diagnostics: [],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function buildImplicitFailure(startPath) {
|
|
71
|
+
return {
|
|
72
|
+
root: startPath,
|
|
73
|
+
specsDir: path.join(startPath, 'specflow', 'specs'),
|
|
74
|
+
changesDir: path.join(startPath, 'specflow', 'changes'),
|
|
75
|
+
archiveDir: path.join(startPath, 'specflow', 'changes', ARCHIVE_DIR_NAME),
|
|
76
|
+
source: 'implicit',
|
|
77
|
+
diagnostics: [
|
|
78
|
+
{
|
|
79
|
+
severity: 'error',
|
|
80
|
+
code: 'planning_root_not_found',
|
|
81
|
+
message: 'No planning root could be resolved.',
|
|
82
|
+
target: 'planning.root',
|
|
83
|
+
fix: 'Run specflow init or register a planning store.',
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export async function resolvePlanningRoot(options = {}) {
|
|
89
|
+
const startPath = path.resolve(options.startPath ?? process.cwd());
|
|
90
|
+
const registry = options.registry ?? { version: 1, stores: {} };
|
|
91
|
+
const globalConfig = options.globalConfig ?? {};
|
|
92
|
+
const readConfig = options.readConfig ??
|
|
93
|
+
((projectRoot) => {
|
|
94
|
+
const configPath = path.join(projectRoot, 'specflow', 'config.yaml');
|
|
95
|
+
try {
|
|
96
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
97
|
+
return parseProjectConfig(yaml.load(content));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return parseProjectConfig({});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
if (options.store) {
|
|
104
|
+
const explicit = resolveFromStoreId(options.store, registry, 'store');
|
|
105
|
+
if (explicit) {
|
|
106
|
+
return explicit;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
...buildImplicitFailure(startPath),
|
|
110
|
+
source: 'store',
|
|
111
|
+
storeId: options.store,
|
|
112
|
+
diagnostics: [
|
|
113
|
+
{
|
|
114
|
+
severity: 'error',
|
|
115
|
+
code: 'store_not_registered',
|
|
116
|
+
message: `Store '${options.store}' is not registered.`,
|
|
117
|
+
target: `stores.${options.store}`,
|
|
118
|
+
fix: 'Run specflow store register to add the checkout.',
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const nearest = findProjectRoot(startPath);
|
|
124
|
+
if (nearest) {
|
|
125
|
+
const config = readConfig(nearest);
|
|
126
|
+
if (await hasPlanningContent(nearest)) {
|
|
127
|
+
return {
|
|
128
|
+
root: nearest,
|
|
129
|
+
specsDir: path.join(nearest, 'specflow', 'specs'),
|
|
130
|
+
changesDir: path.join(nearest, 'specflow', 'changes'),
|
|
131
|
+
archiveDir: path.join(nearest, 'specflow', 'changes', ARCHIVE_DIR_NAME),
|
|
132
|
+
source: 'nearest',
|
|
133
|
+
diagnostics: config.store
|
|
134
|
+
? [
|
|
135
|
+
{
|
|
136
|
+
severity: 'info',
|
|
137
|
+
code: 'store_pointer_ignored',
|
|
138
|
+
message: `Ignoring store pointer '${config.store}' because local planning content exists.`,
|
|
139
|
+
target: 'config.store',
|
|
140
|
+
},
|
|
141
|
+
]
|
|
142
|
+
: [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (config.store) {
|
|
146
|
+
const declared = resolveFromStoreId(config.store, registry, 'declared');
|
|
147
|
+
if (declared) {
|
|
148
|
+
return declared;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...buildImplicitFailure(nearest),
|
|
152
|
+
source: 'declared',
|
|
153
|
+
storeId: config.store,
|
|
154
|
+
diagnostics: [
|
|
155
|
+
{
|
|
156
|
+
severity: 'error',
|
|
157
|
+
code: 'store_not_registered',
|
|
158
|
+
message: `Declared store '${config.store}' is not registered.`,
|
|
159
|
+
target: 'config.store',
|
|
160
|
+
fix: `Run specflow store register for '${config.store}'.`,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (globalConfig.defaultStore) {
|
|
167
|
+
const fallback = resolveFromStoreId(globalConfig.defaultStore, registry, 'global_default');
|
|
168
|
+
if (fallback) {
|
|
169
|
+
return fallback;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
...buildImplicitFailure(startPath),
|
|
173
|
+
source: 'global_default',
|
|
174
|
+
storeId: globalConfig.defaultStore,
|
|
175
|
+
diagnostics: [
|
|
176
|
+
{
|
|
177
|
+
severity: 'error',
|
|
178
|
+
code: 'default_store_missing',
|
|
179
|
+
message: `Global default store '${globalConfig.defaultStore}' is not registered.`,
|
|
180
|
+
target: 'global.defaultStore',
|
|
181
|
+
fix: 'Register the default store or clear defaultStore from global config.',
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (nearest && options.allowImplicitRoot !== false) {
|
|
187
|
+
return {
|
|
188
|
+
root: nearest,
|
|
189
|
+
specsDir: path.join(nearest, 'specflow', 'specs'),
|
|
190
|
+
changesDir: path.join(nearest, 'specflow', 'changes'),
|
|
191
|
+
archiveDir: path.join(nearest, 'specflow', 'changes', ARCHIVE_DIR_NAME),
|
|
192
|
+
source: 'nearest',
|
|
193
|
+
diagnostics: [],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return buildImplicitFailure(startPath);
|
|
197
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { StoreError } from './foundation.js';
|