@llman-sdd/cli 0.3.1 → 0.5.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/package.json +2 -2
- package/src/cli-shared.ts +190 -0
- package/src/commands/archive.ts +114 -0
- package/src/commands/change.ts +389 -0
- package/src/commands/config.ts +39 -0
- package/src/commands/context.ts +59 -0
- package/src/commands/graph.ts +35 -0
- package/src/commands/index.ts +43 -0
- package/src/commands/init.ts +37 -0
- package/src/commands/list.ts +69 -0
- package/src/commands/project.ts +54 -0
- package/src/commands/review.ts +83 -0
- package/src/commands/show.ts +268 -0
- package/src/commands/spec.ts +181 -0
- package/src/commands/validate.ts +459 -0
- package/src/harness.ts +31 -0
- package/src/io.ts +4 -0
- package/src/main.ts +58 -1420
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { buildReqRegistry, migrateNoteFor, migrateOverviewFor, planDedupe } from '@llman-sdd/core';
|
|
2
|
+
import type { Command } from 'commander';
|
|
3
|
+
|
|
4
|
+
import { CliError, loadCliConfig, loadSpecEntries, newIo } from '../cli-shared.ts';
|
|
5
|
+
|
|
6
|
+
export function registerProject(program: Command): void {
|
|
7
|
+
const project = program.command('project').description('Project management commands');
|
|
8
|
+
|
|
9
|
+
project
|
|
10
|
+
.command('dedupe-req-ids')
|
|
11
|
+
.description('Remap globally duplicated req ids (report with --dry-run)')
|
|
12
|
+
.option('--dry-run', 'report the remap plan without writing')
|
|
13
|
+
.action((options: { dryRun?: boolean }) => {
|
|
14
|
+
// dedupe registry covers requirement handles (`@req` on 规则: headers) only;
|
|
15
|
+
// duplicates follow the occurrence model (同文件内共用或跨文件均算)
|
|
16
|
+
const entries = loadSpecEntries();
|
|
17
|
+
const { duplicates } = buildReqRegistry(entries);
|
|
18
|
+
if (duplicates.length === 0) {
|
|
19
|
+
console.log('No colliding req_id values in llmanspec/specs.');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const io = newIo();
|
|
23
|
+
// r43: --dry-run is zero-side-effect — plan only, never write.
|
|
24
|
+
const plan = planDedupe(entries, io, 'llmanspec/specs', duplicates, {
|
|
25
|
+
apply: !options.dryRun,
|
|
26
|
+
});
|
|
27
|
+
// predecessor output: `{cap}: {from} → {to}` per remap (prefix in dry-run) + count line.
|
|
28
|
+
for (const item of plan) {
|
|
29
|
+
const cap = item.remapFile.replace(/^.*specs\//u, '').replace(/\.feature$/u, '');
|
|
30
|
+
const prefix = options.dryRun ? '[dry-run] ' : '';
|
|
31
|
+
console.log(`${prefix}${cap}: ${item.reqId} → ${item.newReqId}`);
|
|
32
|
+
}
|
|
33
|
+
console.log(`${plan.length} remapping(s)${options.dryRun ? ' (dry-run)' : ''}`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
project
|
|
37
|
+
.command('migrate')
|
|
38
|
+
.description('Legacy migration collaboration notes (prints guidance; performs no migration)')
|
|
39
|
+
.option('--kind <kind>', 'collaboration notes: toon2features | specs-flatten')
|
|
40
|
+
.action((options: { kind?: string }) => {
|
|
41
|
+
const locale = loadCliConfig()?.locale ?? 'en';
|
|
42
|
+
if (options.kind === undefined) {
|
|
43
|
+
console.log(migrateOverviewFor(locale));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const note = migrateNoteFor(options.kind, locale);
|
|
47
|
+
if (note === null) {
|
|
48
|
+
throw new CliError(
|
|
49
|
+
`unknown migration kind: ${options.kind} (expected: toon2features | specs-flatten)`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
console.log(note);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildReview,
|
|
6
|
+
collectChanges,
|
|
7
|
+
renderMachine,
|
|
8
|
+
renderReviewHtml,
|
|
9
|
+
specIdOf,
|
|
10
|
+
TEMPLATES_ROOT,
|
|
11
|
+
} from '@llman-sdd/core';
|
|
12
|
+
import type { Command } from 'commander';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
addReportOutputOptions,
|
|
16
|
+
CliError,
|
|
17
|
+
cliMaxScanDepth,
|
|
18
|
+
exitWith,
|
|
19
|
+
loadSpecEntries,
|
|
20
|
+
newIo,
|
|
21
|
+
resolveOutMode,
|
|
22
|
+
templateIo,
|
|
23
|
+
} from '../cli-shared.ts';
|
|
24
|
+
import { makeCliGit } from '../io.ts';
|
|
25
|
+
|
|
26
|
+
export function registerReview(program: Command): void {
|
|
27
|
+
const review = program
|
|
28
|
+
.command('review')
|
|
29
|
+
.description('Aggregate review: pending/unbound/stale signals plus a validate sweep');
|
|
30
|
+
|
|
31
|
+
review.option('--capability <capability>', 'restrict the sweep to one capability/spec id');
|
|
32
|
+
addReportOutputOptions(review);
|
|
33
|
+
review
|
|
34
|
+
.option('--export-html <path>', 'write a self-contained HTML report')
|
|
35
|
+
.action(
|
|
36
|
+
(options: {
|
|
37
|
+
capability?: string;
|
|
38
|
+
json?: boolean;
|
|
39
|
+
compactJson?: boolean;
|
|
40
|
+
output?: string;
|
|
41
|
+
exportHtml?: string;
|
|
42
|
+
}) => {
|
|
43
|
+
const io = newIo();
|
|
44
|
+
const entries = loadSpecEntries();
|
|
45
|
+
if (options.capability !== undefined) {
|
|
46
|
+
const known = new Set(entries.map((e) => specIdOf(e)));
|
|
47
|
+
if (!known.has(options.capability)) {
|
|
48
|
+
throw new CliError(`capability \`${options.capability}\` not found`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const activeChanges = collectChanges(io, process.cwd(), new Date(), {
|
|
52
|
+
maxScanDepth: cliMaxScanDepth(program),
|
|
53
|
+
});
|
|
54
|
+
// B17: 墙钟与 base-ref env 由 CLI 注入,core 不直读 process.*/new Date()
|
|
55
|
+
const now = new Date();
|
|
56
|
+
const result = buildReview(
|
|
57
|
+
{
|
|
58
|
+
entries,
|
|
59
|
+
boundChangeCount: activeChanges.filter((c) => c.hasBinding).length,
|
|
60
|
+
activeChanges,
|
|
61
|
+
capability: options.capability,
|
|
62
|
+
git: makeCliGit(process.cwd()),
|
|
63
|
+
root: process.cwd(),
|
|
64
|
+
specsDir: 'llmanspec/specs',
|
|
65
|
+
baseRefEnv: process.env.LLMANSPEC_BASE_REF,
|
|
66
|
+
},
|
|
67
|
+
io,
|
|
68
|
+
);
|
|
69
|
+
if (options.exportHtml !== undefined) {
|
|
70
|
+
const template = templateIo.readText(join(TEMPLATES_ROOT, 'shared', 'review.html'));
|
|
71
|
+
writeFileSync(options.exportHtml, renderReviewHtml(template, result, now));
|
|
72
|
+
console.log(`wrote ${options.exportHtml}`);
|
|
73
|
+
}
|
|
74
|
+
const outMode = resolveOutMode(options.output, options.json, options.compactJson);
|
|
75
|
+
if (outMode !== 'human') {
|
|
76
|
+
console.log(renderMachine({ signals: result.signals, summary: result.summary }, outMode));
|
|
77
|
+
} else {
|
|
78
|
+
console.log(result.lines.join('\n'));
|
|
79
|
+
}
|
|
80
|
+
if (result.exitCode !== 0) exitWith(result.exitCode);
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
collectChanges,
|
|
6
|
+
collectSpecs,
|
|
7
|
+
morphologyOf,
|
|
8
|
+
renderMachine,
|
|
9
|
+
showChangeJson,
|
|
10
|
+
specIdOf,
|
|
11
|
+
} from '@llman-sdd/core';
|
|
12
|
+
import type { Command } from 'commander';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
addReportOutputOptions,
|
|
16
|
+
CliError,
|
|
17
|
+
cliMaxScanDepth,
|
|
18
|
+
loadSpecEntries,
|
|
19
|
+
newIo,
|
|
20
|
+
resolveChangeIdOrExit,
|
|
21
|
+
} from '../cli-shared.ts';
|
|
22
|
+
import { makeCliGit } from '../io.ts';
|
|
23
|
+
|
|
24
|
+
function hasSection(proposal: string, heading: string): boolean {
|
|
25
|
+
return (
|
|
26
|
+
new RegExp(`^## ${heading.replace(/[/\\]/u, '')}\\s*$`, 'mu').test(proposal) ||
|
|
27
|
+
proposal.includes(`## ${heading}`)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderSpecJson(
|
|
32
|
+
entries: ReturnType<typeof loadSpecEntries>,
|
|
33
|
+
item: string,
|
|
34
|
+
opts: { metaOnly: boolean; noScenarios: boolean },
|
|
35
|
+
): Record<string, unknown> {
|
|
36
|
+
const entry = entries.find((e) => specIdOf(e) === item);
|
|
37
|
+
const doc = entry?.doc as
|
|
38
|
+
| {
|
|
39
|
+
header: { capability: string | null; purpose: string | null };
|
|
40
|
+
rules: {
|
|
41
|
+
reqId: string;
|
|
42
|
+
title: string;
|
|
43
|
+
description: string;
|
|
44
|
+
scenarios: {
|
|
45
|
+
name: string;
|
|
46
|
+
steps: { kind: string; text: string }[];
|
|
47
|
+
}[];
|
|
48
|
+
}[];
|
|
49
|
+
orphans: { name: string; steps: { kind: string; text: string }[] }[];
|
|
50
|
+
}
|
|
51
|
+
| undefined;
|
|
52
|
+
const cap = entry ? (doc?.header.capability ?? item) : item;
|
|
53
|
+
const purpose = doc?.header.purpose ?? '';
|
|
54
|
+
const rules = doc?.rules ?? [];
|
|
55
|
+
const morphology = morphologyOf(entry?.doc as never);
|
|
56
|
+
if (opts.metaOnly) {
|
|
57
|
+
return {
|
|
58
|
+
id: item,
|
|
59
|
+
featureId: cap,
|
|
60
|
+
title: cap,
|
|
61
|
+
purpose,
|
|
62
|
+
overview: purpose,
|
|
63
|
+
requirementCount: rules.length,
|
|
64
|
+
morphology,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const requirements = rules.map((rule) => ({
|
|
68
|
+
reqId: rule.reqId,
|
|
69
|
+
title: rule.title,
|
|
70
|
+
text: rule.description,
|
|
71
|
+
scenarios: opts.noScenarios
|
|
72
|
+
? []
|
|
73
|
+
: rule.scenarios.map((a) => ({
|
|
74
|
+
id: a.name,
|
|
75
|
+
rawText: `GIVEN: ${a.steps
|
|
76
|
+
.filter((s) => s.kind === 'given')
|
|
77
|
+
.map((s) => s.text)
|
|
78
|
+
.join('\n')}\nWHEN: ${a.steps
|
|
79
|
+
.filter((s) => s.kind === 'when')
|
|
80
|
+
.map((s) => s.text)
|
|
81
|
+
.join('\n')}\nTHEN: ${a.steps
|
|
82
|
+
.filter((s) => s.kind === 'then')
|
|
83
|
+
.map((s) => s.text)
|
|
84
|
+
.join('\n')}`,
|
|
85
|
+
source: 'acceptance',
|
|
86
|
+
reqIds: [rule.reqId],
|
|
87
|
+
})),
|
|
88
|
+
}));
|
|
89
|
+
return {
|
|
90
|
+
id: item,
|
|
91
|
+
title: cap,
|
|
92
|
+
purpose,
|
|
93
|
+
overview: purpose,
|
|
94
|
+
requirementCount: rules.length,
|
|
95
|
+
requirements,
|
|
96
|
+
morphology,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function registerShow(program: Command): void {
|
|
101
|
+
const show = program
|
|
102
|
+
.command('show')
|
|
103
|
+
.description('Show a change or spec')
|
|
104
|
+
.argument('<item>')
|
|
105
|
+
.option('--type <type>', 'force disambiguation: change | spec')
|
|
106
|
+
.option('-r, --requirement <n>', 'spec only: show a specific requirement by 1-based index');
|
|
107
|
+
addReportOutputOptions(show, {
|
|
108
|
+
outputHint: 'json | compact-json | toon | human | {json,meta-only|no-scenarios|reqs-only}',
|
|
109
|
+
});
|
|
110
|
+
show.action(
|
|
111
|
+
(
|
|
112
|
+
item: string,
|
|
113
|
+
options: {
|
|
114
|
+
output?: string;
|
|
115
|
+
type?: string;
|
|
116
|
+
requirement?: string;
|
|
117
|
+
json?: boolean;
|
|
118
|
+
compactJson?: boolean;
|
|
119
|
+
},
|
|
120
|
+
) => {
|
|
121
|
+
// D4 shared surface: the registered --json/--compact-json aliases feed
|
|
122
|
+
// the same token set as --output so show honors them uniformly.
|
|
123
|
+
const outTokens = new Set(
|
|
124
|
+
[
|
|
125
|
+
...(options.output ?? '').split(','),
|
|
126
|
+
...(options.json === true ? ['json'] : []),
|
|
127
|
+
...(options.compactJson === true ? ['compact-json'] : []),
|
|
128
|
+
]
|
|
129
|
+
.map((t) => t.trim())
|
|
130
|
+
.filter((t) => t !== ''),
|
|
131
|
+
);
|
|
132
|
+
const VALID_TOKENS = new Set([
|
|
133
|
+
'json',
|
|
134
|
+
'toon',
|
|
135
|
+
'human',
|
|
136
|
+
'compact-json',
|
|
137
|
+
'compact',
|
|
138
|
+
'meta-only',
|
|
139
|
+
'no-scenarios',
|
|
140
|
+
'reqs-only',
|
|
141
|
+
]);
|
|
142
|
+
for (const token of outTokens) {
|
|
143
|
+
if (!VALID_TOKENS.has(token)) {
|
|
144
|
+
throw new CliError(
|
|
145
|
+
`invalid --output token: ${token} (json | toon | human | compact-json | compact | meta-only | no-scenarios | reqs-only)`,
|
|
146
|
+
2,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// B10/Q1: `compact-json` is a real machine mode (single-line JSON), not a
|
|
151
|
+
// text fallback; legacy `compact` token aliases it.
|
|
152
|
+
const asCompact = outTokens.has('compact') || outTokens.has('compact-json');
|
|
153
|
+
const metaOnly = outTokens.has('meta-only');
|
|
154
|
+
const noScenarios = outTokens.has('no-scenarios');
|
|
155
|
+
const reqsOnly = outTokens.has('reqs-only');
|
|
156
|
+
// toon-default-output: no --output → toon; machine modes share the json
|
|
157
|
+
// gates (Why/What validation) and suppress the prefix hint; `human` is the
|
|
158
|
+
// sole predecessor text form. Legacy modifiers (meta-only/no-scenarios/reqs-only/
|
|
159
|
+
// -r) belong to the text face — they route to human (predecessor no-op render
|
|
160
|
+
// semantics preserved). The predecessor delta modifier was removed: unknown
|
|
161
|
+
// tokens are rejected like other report commands.
|
|
162
|
+
const wantsMachine = outTokens.has('json') || outTokens.has('toon') || asCompact;
|
|
163
|
+
// no --output at all → toon; explicit legacy-only modifiers → human text
|
|
164
|
+
const isHuman = options.output !== undefined && (outTokens.has('human') || !wantsMachine);
|
|
165
|
+
const asJson = !isHuman;
|
|
166
|
+
const showMode: 'json' | 'compact-json' | 'toon' = asCompact
|
|
167
|
+
? 'compact-json'
|
|
168
|
+
: outTokens.has('json')
|
|
169
|
+
? 'json'
|
|
170
|
+
: 'toon';
|
|
171
|
+
// predecessor: unknown output tokens are rejected by clap; script consumers rely on
|
|
172
|
+
// the deprecation being a no-op render rather than an error.
|
|
173
|
+
// Spec 判定与 collectSpecs/discoverSpecs 同口径:扁平文件与目录式
|
|
174
|
+
// `specs/<cap>/<cap>.feature` 均按 entry 精确 id(specIdOf:capability ?? 去
|
|
175
|
+
// `.feature` 后缀的 fileName)命中;r25 spec id 精确匹配优先于 change 前缀,
|
|
176
|
+
// 不做模糊解析。
|
|
177
|
+
// Discover once per run — the spec lookup, summary and JSON render below
|
|
178
|
+
// all share these entries.
|
|
179
|
+
const entries = loadSpecEntries();
|
|
180
|
+
const specEntry = entries.find((e) => specIdOf(e) === item);
|
|
181
|
+
const isSpec =
|
|
182
|
+
options.type === 'spec' ||
|
|
183
|
+
existsSync(join('llmanspec', 'specs', `${item}.feature`)) ||
|
|
184
|
+
specEntry !== undefined;
|
|
185
|
+
|
|
186
|
+
if (isSpec) {
|
|
187
|
+
const flatSpecPath = join('llmanspec', 'specs', `${item}.feature`);
|
|
188
|
+
// discoverSpecs 的 fileName 已是含 specs 目录前缀的相对路径
|
|
189
|
+
// (如 `llmanspec/specs/<cap>/<cap>.feature`),直接使用。
|
|
190
|
+
const specPath =
|
|
191
|
+
specEntry !== undefined && !existsSync(flatSpecPath) ? specEntry.fileName : flatSpecPath;
|
|
192
|
+
if (!existsSync(specPath)) {
|
|
193
|
+
throw new CliError(`spec not found: ${item}`);
|
|
194
|
+
}
|
|
195
|
+
if (asJson) {
|
|
196
|
+
console.log(
|
|
197
|
+
renderMachine(
|
|
198
|
+
renderSpecJson(entries, item, {
|
|
199
|
+
metaOnly,
|
|
200
|
+
noScenarios: noScenarios || reqsOnly,
|
|
201
|
+
}),
|
|
202
|
+
showMode,
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// text mode: predecessor ignores all output modifiers (meta-only/no-scenarios/-r)
|
|
208
|
+
// and renders the full source + morphology.
|
|
209
|
+
const raw = readFileSync(specPath, 'utf8').trimEnd();
|
|
210
|
+
const summary = collectSpecs(entries).find((x) => x.id === item);
|
|
211
|
+
const morphology = summary
|
|
212
|
+
? `\n\n## Morphology\nruleCount=${summary.morphology.ruleCount} enforced=${summary.morphology.ruleEnforcedCount} pending=${summary.morphology.rulePendingCount} acceptanceCount=${summary.morphology.acceptanceCount}`
|
|
213
|
+
: '';
|
|
214
|
+
console.log(`## Spec\n${raw}${morphology}`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---- change ----
|
|
219
|
+
// r61: predecessor r112 prefix chain — exact > unique prefix > multiple > not found.
|
|
220
|
+
const resolved = resolveChangeIdOrExit(program, item, { suppressHint: asJson });
|
|
221
|
+
const changeId = resolved.id;
|
|
222
|
+
const viaPrefix = resolved.viaPrefix;
|
|
223
|
+
const proposal = readFileSync(join('llmanspec', 'changes', changeId, 'proposal.md'), 'utf8');
|
|
224
|
+
if (asJson) {
|
|
225
|
+
// predecessor parse_change gates: Why first, then What Changes (json only).
|
|
226
|
+
if (!hasSection(proposal, 'Why')) {
|
|
227
|
+
throw new CliError('Change must have a Why section');
|
|
228
|
+
}
|
|
229
|
+
if (!hasSection(proposal, 'What Changes')) {
|
|
230
|
+
throw new CliError('Change must have a What Changes section');
|
|
231
|
+
}
|
|
232
|
+
const result = showChangeJson(
|
|
233
|
+
{
|
|
234
|
+
io: newIo(),
|
|
235
|
+
git: makeCliGit(process.cwd()),
|
|
236
|
+
root: process.cwd(),
|
|
237
|
+
specsDir: 'llmanspec/specs',
|
|
238
|
+
},
|
|
239
|
+
changeId,
|
|
240
|
+
{ matchedViaPrefix: viaPrefix },
|
|
241
|
+
);
|
|
242
|
+
console.log(renderMachine(result, showMode));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// text: Stage / path / content / Gates trailer (no section gates).
|
|
246
|
+
const changes = collectChanges(newIo(), process.cwd(), new Date(), {
|
|
247
|
+
maxScanDepth: cliMaxScanDepth(program),
|
|
248
|
+
});
|
|
249
|
+
const change = changes.find((c) => c.name === changeId);
|
|
250
|
+
console.log(`Stage: ${change?.stage ?? 'draft'}`);
|
|
251
|
+
console.log(`path: ${changeId}`);
|
|
252
|
+
process.stdout.write(proposal);
|
|
253
|
+
if (!proposal.endsWith('\n')) console.log();
|
|
254
|
+
const gates = showChangeJson(
|
|
255
|
+
{
|
|
256
|
+
io: newIo(),
|
|
257
|
+
git: makeCliGit(process.cwd()),
|
|
258
|
+
root: process.cwd(),
|
|
259
|
+
specsDir: 'llmanspec/specs',
|
|
260
|
+
},
|
|
261
|
+
changeId,
|
|
262
|
+
).gateChecks as { name: string; pass: boolean; hint: string }[];
|
|
263
|
+
const passCount = gates.filter((g) => g.pass).length;
|
|
264
|
+
console.log(`Gates: ${passCount}/${gates.length} pass`);
|
|
265
|
+
for (const g of gates.filter((g) => !g.pass)) console.log(`✗ ${g.name}: ${g.hint}`);
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
addReq,
|
|
7
|
+
addScenario,
|
|
8
|
+
hasNativeRules,
|
|
9
|
+
migrateNativeSource,
|
|
10
|
+
nextReqId,
|
|
11
|
+
resolveReq,
|
|
12
|
+
scaffoldSpec,
|
|
13
|
+
} from '@llman-sdd/core';
|
|
14
|
+
import type { Command } from 'commander';
|
|
15
|
+
|
|
16
|
+
import { CliError, loadCliConfig, loadSpecEntries, newIo } from '../cli-shared.ts';
|
|
17
|
+
|
|
18
|
+
/** Walk paths collecting .feature files (directories recurse). */
|
|
19
|
+
function collectFeatureFiles(paths: string[]): string[] {
|
|
20
|
+
const files: string[] = [];
|
|
21
|
+
const walk = (dir: string): void => {
|
|
22
|
+
for (const name of readdirSync(dir).toSorted()) {
|
|
23
|
+
const full = join(dir, name);
|
|
24
|
+
if (statSync(full).isDirectory()) walk(full);
|
|
25
|
+
else if (name.endsWith('.feature')) files.push(full);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
for (const p of paths) {
|
|
29
|
+
if (!existsSync(p)) throw new CliError(`path not found: ${p}`);
|
|
30
|
+
if (statSync(p).isDirectory()) walk(p);
|
|
31
|
+
else files.push(p);
|
|
32
|
+
}
|
|
33
|
+
return files;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function confirmInteractive(question: string): Promise<boolean> {
|
|
37
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
rl.question(question, (answer) => {
|
|
40
|
+
rl.close();
|
|
41
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerSpec(program: Command): void {
|
|
47
|
+
const spec = program.command('spec').description('Spec authoring helpers');
|
|
48
|
+
|
|
49
|
+
spec
|
|
50
|
+
.command('skeleton')
|
|
51
|
+
.description('Generate a single-track spec skeleton for a capability')
|
|
52
|
+
.argument('<capability>')
|
|
53
|
+
.option('--force', 'overwrite an existing spec file')
|
|
54
|
+
.action((capability: string, options: { force?: boolean }) => {
|
|
55
|
+
const locale = loadCliConfig()?.locale ?? 'en';
|
|
56
|
+
const path = join('llmanspec', 'specs', `${capability}.feature`);
|
|
57
|
+
if (!options.force && existsSync(path)) {
|
|
58
|
+
throw new CliError(`spec already exists: ${path} (use --force to overwrite)`);
|
|
59
|
+
}
|
|
60
|
+
const written = scaffoldSpec(newIo(), 'llmanspec/specs', capability, locale, {
|
|
61
|
+
force: options.force,
|
|
62
|
+
});
|
|
63
|
+
console.log(`wrote ${written}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
spec
|
|
67
|
+
.command('next-req-id')
|
|
68
|
+
.description('Allocate the next free global req id (rN)')
|
|
69
|
+
.option('--json', 'emit {reqId}')
|
|
70
|
+
.action((options: { json?: boolean }) => {
|
|
71
|
+
const reqId = nextReqId(newIo(), 'llmanspec/specs');
|
|
72
|
+
if (options.json) console.log(JSON.stringify({ reqId }, null, 2));
|
|
73
|
+
else console.log(reqId);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
spec
|
|
77
|
+
.command('add-req')
|
|
78
|
+
.alias('add-requirement')
|
|
79
|
+
.description('Append a 规则: block (with @req handle) to a capability spec')
|
|
80
|
+
.argument('<capability>')
|
|
81
|
+
.argument('<req_id>')
|
|
82
|
+
.requiredOption('--title <title>', 'rule title')
|
|
83
|
+
.requiredOption('--statement <statement>', 'rule statement (free text; multiple lines via \\n)')
|
|
84
|
+
.action((capability: string, reqId: string, options: { title: string; statement: string }) => {
|
|
85
|
+
const io = newIo();
|
|
86
|
+
const path = addReq(io, 'llmanspec/specs', loadSpecEntries(), {
|
|
87
|
+
capability,
|
|
88
|
+
reqId,
|
|
89
|
+
title: options.title,
|
|
90
|
+
statement: options.statement,
|
|
91
|
+
});
|
|
92
|
+
console.log(path);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
spec
|
|
96
|
+
.command('add-scenario')
|
|
97
|
+
.description('Insert a nested 场景: under the named rule in a spec')
|
|
98
|
+
.argument('<capability>')
|
|
99
|
+
.argument('<req_id>')
|
|
100
|
+
.argument('<scenario_id>')
|
|
101
|
+
.option('--given <given>', 'Given step (optional)')
|
|
102
|
+
.requiredOption('--when <when>', 'When step')
|
|
103
|
+
.requiredOption('--then <then>', 'Then step')
|
|
104
|
+
.action(
|
|
105
|
+
(
|
|
106
|
+
capability: string,
|
|
107
|
+
reqId: string,
|
|
108
|
+
scenarioId: string,
|
|
109
|
+
options: { given?: string; when: string; then: string },
|
|
110
|
+
) => {
|
|
111
|
+
const io = newIo();
|
|
112
|
+
const path = addScenario(io, 'llmanspec/specs', loadSpecEntries(), {
|
|
113
|
+
capability,
|
|
114
|
+
reqId,
|
|
115
|
+
scenarioId,
|
|
116
|
+
given: options.given,
|
|
117
|
+
when: options.when,
|
|
118
|
+
thenText: options.then,
|
|
119
|
+
});
|
|
120
|
+
console.log(path);
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
spec
|
|
125
|
+
.command('migrate-native')
|
|
126
|
+
.description('Migrate legacy tag-based .feature files to the native 规则:/场景: layout')
|
|
127
|
+
.argument('[paths...]', 'feature files or directories (default: llmanspec/specs)')
|
|
128
|
+
.option('--dry-run', 'print the migration plan without writing')
|
|
129
|
+
.option('-y, --yes', 'proceed without per-file confirmation')
|
|
130
|
+
.action(async (paths: string[], options: { dryRun?: boolean; yes?: boolean }) => {
|
|
131
|
+
const targets = collectFeatureFiles(paths.length > 0 ? paths : ['llmanspec/specs']);
|
|
132
|
+
const io = newIo();
|
|
133
|
+
let migrated = 0;
|
|
134
|
+
let skipped = 0;
|
|
135
|
+
for (const file of targets) {
|
|
136
|
+
const source = io.readText(file);
|
|
137
|
+
// already native? layout detection via the official parser
|
|
138
|
+
if (hasNativeRules(source)) {
|
|
139
|
+
skipped++;
|
|
140
|
+
if (options.dryRun) console.log(`[skip] ${file} (already native)`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const result = migrateNativeSource(source);
|
|
144
|
+
if (!result.ok) {
|
|
145
|
+
console.error(`[error] ${file}: ${result.message}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const line = `[migrate] ${file}: ${result.rules} rule(s), ${result.scenarios} scenario(s)`;
|
|
149
|
+
if (options.dryRun) {
|
|
150
|
+
console.log(`${line} (dry-run)`);
|
|
151
|
+
migrated++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (options.yes || (await confirmInteractive(`${line}. Write? (y/N) `))) {
|
|
155
|
+
writeFileSync(file, result.content, 'utf8');
|
|
156
|
+
console.log(line);
|
|
157
|
+
migrated++;
|
|
158
|
+
} else {
|
|
159
|
+
console.log(`[skip] ${file} (declined)`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
console.log(`migrate-native: ${migrated} file(s) migrated, ${skipped} already native`);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
spec
|
|
166
|
+
.command('resolve-req')
|
|
167
|
+
.description('Resolve an rN to its capability and statement')
|
|
168
|
+
.argument('<req_id>')
|
|
169
|
+
.action((reqId: string) => {
|
|
170
|
+
const resolved = resolveReq(loadSpecEntries(), reqId);
|
|
171
|
+
if (resolved === null) {
|
|
172
|
+
throw new CliError(`req id not found: ${reqId}`);
|
|
173
|
+
}
|
|
174
|
+
console.log(`reqId: ${resolved.reqId}`);
|
|
175
|
+
console.log(`capability: ${resolved.capability}`);
|
|
176
|
+
console.log(`title: ${resolved.title}`);
|
|
177
|
+
console.log(`statement: ${resolved.statement.replaceAll('\n', ' ')}`);
|
|
178
|
+
console.log('harness:');
|
|
179
|
+
for (const h of resolved.harness) console.log(` - ${h}`);
|
|
180
|
+
});
|
|
181
|
+
}
|