@llman-sdd/cli 0.3.1 → 0.5.0

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.
@@ -0,0 +1,459 @@
1
+ import {
2
+ applyStrict,
3
+ buildDuplicatesFor,
4
+ checkGlobalChangeIdUniqueness,
5
+ collectChanges,
6
+ currentBranch,
7
+ defaultBranch,
8
+ evaluateStaleness,
9
+ formatTotals,
10
+ notApplicableStaleness,
11
+ renderMachine,
12
+ runHarnessForSpecs,
13
+ specRelFor,
14
+ specIdOf,
15
+ STAGE_ORDER,
16
+ validateCapability,
17
+ validateChange,
18
+ type ChangeIssue,
19
+ type HarnessGate,
20
+ type StalenessInfo,
21
+ } from '@llman-sdd/core';
22
+ import type { Command } from 'commander';
23
+
24
+ import {
25
+ addReportOutputOptions,
26
+ assertCompactJsonPairing,
27
+ CliError,
28
+ cliMaxScanDepth,
29
+ exitWith,
30
+ loadCliConfig,
31
+ loadSpecEntries,
32
+ newIo,
33
+ resolveChangeIdOrExit,
34
+ resolveOutMode,
35
+ } from '../cli-shared.ts';
36
+ import { makeCliHarnessRunner } from '../harness.ts';
37
+ import { makeCliGit } from '../io.ts';
38
+
39
+ interface VItem {
40
+ id: string;
41
+ type: string;
42
+ valid: boolean;
43
+ issues: ChangeIssue[];
44
+ durationMs: number;
45
+ staleness: StalenessInfo;
46
+ matchedViaPrefix: boolean;
47
+ }
48
+
49
+ /** predecessor validate ordering: id asc, tie-broken by type asc. */
50
+ function compareItems(a: VItem, b: VItem): number {
51
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : a.type.localeCompare(b.type);
52
+ }
53
+
54
+ function specV1Items(
55
+ opts: { strict?: boolean; harness?: HarnessGate; harnessOnlyIds?: string[] },
56
+ entries: ReturnType<typeof loadSpecEntries> = loadSpecEntries(),
57
+ ): VItem[] {
58
+ const io = newIo();
59
+ const git = makeCliGit(process.cwd());
60
+ const duplicatesFor = buildDuplicatesFor(entries);
61
+
62
+ const items: VItem[] = [];
63
+ for (const entry of entries) {
64
+ const cap = specIdOf(entry);
65
+ const verdict = validateCapability(entry as never, duplicatesFor, io, {
66
+ strict: opts.strict === true,
67
+ });
68
+ const specRel = specRelFor(entry.fileName);
69
+ const staleness = evaluateStaleness({
70
+ git,
71
+ root: process.cwd(),
72
+ specRel,
73
+ scope: entry.doc.header.scope?.split(',').map((s) => s.trim()) ?? [],
74
+ baseRefEnv: process.env.LLMANSPEC_BASE_REF,
75
+ });
76
+ let issues: ChangeIssue[] = verdict.items.map((i) => ({
77
+ level: i.level,
78
+ path: i.id,
79
+ message: i.message,
80
+ }));
81
+ if (opts.strict === true) issues = [...issues, ...applyStrict(staleness.issues)];
82
+ else issues = [...issues, ...staleness.issues];
83
+ items.push({
84
+ id: cap,
85
+ type: 'spec',
86
+ valid: issues.every((i) => i.level !== 'ERROR'),
87
+ issues,
88
+ durationMs: 0,
89
+ staleness: staleness.info,
90
+ matchedViaPrefix: false,
91
+ });
92
+ }
93
+ // r13/r48: harness issues attach to their spec item after the structural
94
+ // verdict (execution runs only for the entries actually being validated —
95
+ // a single-item validate scopes the harness to that one spec).
96
+ if (opts.harness !== undefined) {
97
+ const scoped =
98
+ opts.harnessOnlyIds === undefined
99
+ ? entries
100
+ : entries.filter((entry) => opts.harnessOnlyIds?.includes(specIdOf(entry)));
101
+ const outcome = runHarnessForSpecs(
102
+ scoped.map((entry) => ({ capability: specIdOf(entry), featurePath: entry.fileName })),
103
+ opts.harness,
104
+ );
105
+ for (const item of items) {
106
+ const extra = outcome.issuesByCapability.get(item.id);
107
+ if (extra === undefined || extra.length === 0) continue;
108
+ item.issues = [
109
+ ...item.issues,
110
+ ...extra.map((i) => ({ level: i.level, path: i.id, message: i.message })),
111
+ ];
112
+ item.valid = item.issues.every((i) => i.level !== 'ERROR');
113
+ }
114
+ }
115
+ items.sort(compareItems);
116
+ return items;
117
+ }
118
+
119
+ function changeV1Items(names: string[], opts: { stage?: string; strict?: boolean }): VItem[] {
120
+ const io = newIo();
121
+ const git = makeCliGit(process.cwd());
122
+ const config = loadCliConfig();
123
+ const items: VItem[] = [];
124
+ for (const name of names) {
125
+ const res = validateChange(
126
+ io,
127
+ process.cwd(),
128
+ name,
129
+ {
130
+ strict_defer: config?.archive?.strict_defer ?? null,
131
+ change_id_pattern: config?.change_id?.pattern ?? null,
132
+ },
133
+ { stage: opts.stage as never, strict: opts.strict === true, git },
134
+ );
135
+ items.push({
136
+ id: name,
137
+ type: 'change',
138
+ valid: res.valid,
139
+ issues: res.issues,
140
+ durationMs: 0,
141
+ staleness: notApplicableStaleness(),
142
+ matchedViaPrefix: false,
143
+ });
144
+ }
145
+ // r87: global change-id uniqueness gate. Duplicate ids attach to their
146
+ // active change item when the active change is being validated; archive↔
147
+ // archive / frozen-only collisions surface as synthetic items (no active
148
+ // counterpart to carry the ERROR).
149
+ const duplicates = checkGlobalChangeIdUniqueness(io, `${process.cwd()}/llmanspec/changes`);
150
+ const byId = new Map(items.map((i) => [i.id, i]));
151
+ for (const dup of duplicates) {
152
+ const issue: ChangeIssue = {
153
+ level: 'ERROR',
154
+ path: 'change-id',
155
+ message: `duplicate change id: ${dup}`,
156
+ };
157
+ const target = byId.get(dup);
158
+ if (target !== undefined) {
159
+ target.issues = [...target.issues, issue];
160
+ target.valid = false;
161
+ } else {
162
+ items.push({
163
+ id: dup,
164
+ type: 'change',
165
+ valid: false,
166
+ issues: [issue],
167
+ durationMs: 0,
168
+ staleness: notApplicableStaleness(),
169
+ matchedViaPrefix: false,
170
+ });
171
+ }
172
+ }
173
+ items.sort(compareItems);
174
+ return items;
175
+ }
176
+
177
+ function printStalenessLines(info: StalenessInfo): void {
178
+ if (info.status === 'NOTAPPLICABLE') return;
179
+ console.log(`Staleness: ${info.status}`);
180
+ if (info.touchedPaths.length > 0)
181
+ console.log(`Touched scope paths: ${info.touchedPaths.join(', ')}`);
182
+ if (info.specUpdated) console.log('Spec file updated since base.');
183
+ if (info.dirty) console.log('Working tree is dirty; results may be unreliable.');
184
+ for (const note of info.notes) console.log(`Note: ${note}`);
185
+ }
186
+
187
+ function renderValidateText(items: VItem[]): void {
188
+ const passed = items.filter((i) => i.valid).length;
189
+ const failed = items.length - passed;
190
+ for (const item of items) {
191
+ if (item.valid) {
192
+ console.log(`OK ${item.type}/${item.id}`);
193
+ } else {
194
+ console.error(`FAIL ${item.type}/${item.id}`);
195
+ for (const issue of item.issues) {
196
+ console.error(` [${issue.level}] ${issue.path}: ${issue.message}`);
197
+ }
198
+ }
199
+ if (item.type === 'spec') printStalenessLines(item.staleness);
200
+ }
201
+ console.log(formatTotals(passed, failed, items.length));
202
+ }
203
+
204
+ function renderValidateReport(items: VItem[], mode: 'json' | 'compact-json' | 'toon'): void {
205
+ const types = [...new Set(items.map((i) => i.type))] as string[];
206
+ const summary = {
207
+ totals: {
208
+ items: items.length,
209
+ passed: items.filter((i) => i.valid).length,
210
+ failed: items.filter((i) => !i.valid).length,
211
+ },
212
+ byType: types.reduce<Record<string, { items: number; passed: number; failed: number }>>(
213
+ (acc, t) => {
214
+ const of = items.filter((i) => i.type === t);
215
+ acc[t] = {
216
+ items: of.length,
217
+ passed: of.filter((i) => i.valid).length,
218
+ failed: of.filter((i) => !i.valid).length,
219
+ };
220
+ return acc;
221
+ },
222
+ {},
223
+ ),
224
+ };
225
+ console.log(renderMachine({ items, summary, version: '1.0' }, mode));
226
+ }
227
+
228
+ const SPEC_NEXT_STEPS = [
229
+ '- Ensure each .feature starts with "# capability:", "# purpose:" and "# scope:" header comments',
230
+ '- Each requirement MUST include at least one scenario object',
231
+ '- Re-run with --json to see structured report',
232
+ ];
233
+ const CHANGE_NEXT_STEPS = [
234
+ '- Edit live single-track specs (`llmanspec/specs/<capability>.feature`) on the feature branch (@human constraints and @executable acceptance share one track, linked via @req); run `llman-sdd change start <id>` or `change attach <id>`',
235
+ '- Ensure proposal.md, design.md (if needed), and tasks.md are complete before apply',
236
+ '- Debug change state: llman-sdd show <id> --json --type change',
237
+ ];
238
+
239
+ /** r63: default-branch dirty live specs — workspace-level guard, once per run. */
240
+ function warnDirtySpecsOnDefaultBranch(): void {
241
+ const git = makeCliGit(process.cwd());
242
+ const current = currentBranch(git);
243
+ if (current === null || current !== defaultBranch(git)) return;
244
+ const out = git.runOpt(['status', '--porcelain', '--', 'llmanspec/specs']) ?? '';
245
+ if (out.trim() === '') return;
246
+ console.error(
247
+ `[WARNING] llmanspec/specs: live specs dirty on default branch \`${current}\`: do not commit unimplemented contracts to the default branch. Switch to the change's bound branch (or \`llman-sdd change start <id>\`) before editing llmanspec/specs/.`,
248
+ );
249
+ }
250
+
251
+ /** r13: stderr banner before the first harness execution — once per process. */
252
+ let harnessBannerShown = false;
253
+
254
+ /**
255
+ * r13/r48 trigger state: the nested guard env and the check flags are read
256
+ * here (CLI boundary) and injected into core as parameters — core harness
257
+ * logic never touches process.env. Commander tri-state (both flags declared):
258
+ * --check → check=true, --no-check → check=false, absent → undefined.
259
+ */
260
+ function makeHarnessGate(options: { check?: boolean }): HarnessGate {
261
+ const runCommand = loadCliConfig()?.bdd?.run_command ?? null;
262
+ const configured = runCommand !== null && runCommand !== '';
263
+ return {
264
+ nested: process.env.LLMAN_SDD_HARNESS_ACTIVE === '1',
265
+ check: options.check === false ? 'off' : options.check === true ? 'on' : 'default',
266
+ runner: configured ? makeCliHarnessRunner() : undefined,
267
+ runCommand,
268
+ cwd: process.cwd(),
269
+ onBeforeFirstRun: (expanded: string): void => {
270
+ if (harnessBannerShown) return;
271
+ harnessBannerShown = true;
272
+ console.error(`running bdd harness: ${expanded} (use --no-check to skip)`);
273
+ },
274
+ };
275
+ }
276
+
277
+ export function registerValidate(program: Command): void {
278
+ const validate = program
279
+ .command('validate')
280
+ .description('Validate specs and changes (structural gates + stage/completion rules)')
281
+ .argument('[item]', 'spec id or change id (auto-disambiguated)')
282
+ .option('--all', 'validate all specs and all changes')
283
+ .option('--changes', 'restrict scope to changes')
284
+ .option('--specs', 'restrict scope to specs')
285
+ .option('--type <type>', 'force disambiguation: change | spec')
286
+ .option('--stage <stage>', 'change stage gate: draft | designed | planned | full')
287
+ .option('--strict', 'warnings also make the exit code non-zero')
288
+ .option('--include-info', 'keep INFO-level issues (default: WARNING and above)')
289
+ .option('--no-check', 'skip the bdd harness')
290
+ .option('--check', 'run the bdd harness (default when bdd.run_command is configured)');
291
+ addReportOutputOptions(validate);
292
+ validate.action(
293
+ (
294
+ item: string | undefined,
295
+ options: {
296
+ all?: boolean;
297
+ changes?: boolean;
298
+ specs?: boolean;
299
+ type?: string;
300
+ stage?: string;
301
+ strict?: boolean;
302
+ json?: boolean;
303
+ compactJson?: boolean;
304
+ includeInfo?: boolean;
305
+ output?: string;
306
+ check?: boolean;
307
+ },
308
+ ) => {
309
+ if (!assertCompactJsonPairing(options)) return;
310
+ const outMode = resolveOutMode(options.output, options.json, options.compactJson);
311
+ // r32: INFO issues are presentation noise — dropped unless opted in.
312
+ // Filtering never touches `valid`, summaries, or exit codes.
313
+ const keepInfo = options.includeInfo === true;
314
+ const stripInfo = <T extends { issues: { level: string }[] }>(it: T): T =>
315
+ keepInfo ? it : { ...it, issues: it.issues.filter((x) => x.level !== 'INFO') };
316
+ warnDirtySpecsOnDefaultBranch();
317
+ if (options.type !== undefined && options.type !== 'change' && options.type !== 'spec') {
318
+ throw new CliError(`invalid --type: ${options.type}`);
319
+ }
320
+ if (
321
+ options.stage !== undefined &&
322
+ !(STAGE_ORDER as readonly string[]).includes(options.stage)
323
+ ) {
324
+ throw new CliError(`invalid --stage: ${options.stage}`);
325
+ }
326
+ const harness = makeHarnessGate(options);
327
+
328
+ // ---- single item (auto-disambiguate: spec first, then change) ----
329
+ if (item !== undefined) {
330
+ const entries = loadSpecEntries();
331
+ const specEntry =
332
+ options.type === 'change' ? undefined : entries.find((e) => specIdOf(e) === item);
333
+ if (specEntry !== undefined) {
334
+ const items = specV1Items(
335
+ { strict: options.strict, harness, harnessOnlyIds: [item] },
336
+ entries,
337
+ );
338
+ const mine = items.find((i) => i.id === item);
339
+ if (mine === undefined) {
340
+ throw new CliError(`no spec or change matches: ${item}`);
341
+ }
342
+ const shown = stripInfo(mine);
343
+ if (outMode !== 'human') {
344
+ renderValidateReport([shown], outMode);
345
+ exitWith(shown.valid ? 0 : 1);
346
+ return;
347
+ }
348
+ if (shown.valid) {
349
+ console.log(`Specification '${item}' is valid`);
350
+ } else {
351
+ console.error(`Specification '${item}' has issues`);
352
+ for (const issue of shown.issues)
353
+ console.error(` [${issue.level}] ${issue.path}: ${issue.message}`);
354
+ console.error('Next steps:');
355
+ for (const s of SPEC_NEXT_STEPS) console.error(s);
356
+ }
357
+ if (shown.type === 'spec') printStalenessLines(shown.staleness);
358
+ if (!shown.valid) console.error('Error: validation failed');
359
+ exitWith(shown.valid ? 0 : 1);
360
+ return;
361
+ }
362
+ // change single (r61: predecessor r112 prefix resolution on the change id)
363
+ const io = newIo();
364
+ const root = process.cwd();
365
+ const resolved = resolveChangeIdOrExit(program, item, {
366
+ suppressHint: options.json === true,
367
+ });
368
+ const changeId = resolved.id;
369
+ const res = validateChange(
370
+ io,
371
+ root,
372
+ changeId,
373
+ {
374
+ strict_defer: loadCliConfig()?.archive?.strict_defer ?? null,
375
+ change_id_pattern: loadCliConfig()?.change_id?.pattern ?? null,
376
+ },
377
+ {
378
+ stage: options.stage as never,
379
+ strict: options.strict === true,
380
+ git: makeCliGit(process.cwd()),
381
+ },
382
+ );
383
+ const infos = res.issues.filter((i) => i.level === 'INFO');
384
+ if (outMode !== 'human') {
385
+ renderValidateReport(
386
+ [
387
+ stripInfo({
388
+ id: changeId,
389
+ type: 'change',
390
+ valid: res.valid,
391
+ issues: res.issues,
392
+ durationMs: 0,
393
+ staleness: notApplicableStaleness(),
394
+ matchedViaPrefix: resolved.viaPrefix,
395
+ }),
396
+ ],
397
+ outMode,
398
+ );
399
+ exitWith(res.valid ? 0 : 1);
400
+ return;
401
+ }
402
+ if (res.valid) {
403
+ console.log(`Change '${changeId}' is valid`);
404
+ } else {
405
+ console.error(`Change '${changeId}' has issues`);
406
+ for (const issue of res.issues.filter((i) => i.level !== 'INFO'))
407
+ console.error(` [${issue.level}] ${issue.path}: ${issue.message}`);
408
+ console.error('Next steps:');
409
+ for (const s of CHANGE_NEXT_STEPS) console.error(s);
410
+ }
411
+ if (keepInfo) {
412
+ for (const info of infos) console.error(`[${info.level}] ${info.path}: ${info.message}`);
413
+ }
414
+ if (!res.valid) console.error('Error: validation failed');
415
+ exitWith(res.valid ? 0 : 1);
416
+ return;
417
+ }
418
+
419
+ // ---- bulk ----
420
+ const specScope = options.all || !options.changes || options.specs === true;
421
+ const changeScope = options.all || options.changes === true;
422
+ const defaultSpecsOnly = !options.all && !options.changes;
423
+ const effectiveSpecs = defaultSpecsOnly ? true : specScope;
424
+ const effectiveChanges = defaultSpecsOnly ? false : changeScope;
425
+
426
+ let items: VItem[] = [];
427
+ if (effectiveSpecs) items = items.concat(specV1Items({ strict: options.strict, harness }));
428
+ if (effectiveChanges) {
429
+ const names = collectChanges(newIo(), process.cwd(), new Date(), {
430
+ maxScanDepth: cliMaxScanDepth(program),
431
+ }).map((c) => c.name);
432
+ items = items.concat(
433
+ changeV1Items(names, { stage: options.stage, strict: options.strict }),
434
+ );
435
+ }
436
+ items.sort(compareItems);
437
+ if (!keepInfo) items = items.map(stripInfo);
438
+
439
+ if (outMode !== 'human') {
440
+ renderValidateReport(items, outMode);
441
+ if (items.some((i) => !i.valid)) console.error('Error: validation failed');
442
+ exitWith(items.some((i) => !i.valid) ? 1 : 0);
443
+ return;
444
+ }
445
+ renderValidateText(items);
446
+ if (items.some((i) => !i.valid)) {
447
+ // r47: human mode carries the Next steps guidance for every failing
448
+ // item kind (bulk path; the single-item path emits per-kind steps).
449
+ const kinds = new Set(items.filter((i) => !i.valid).map((i) => i.type));
450
+ console.error('Next steps:');
451
+ for (const kind of kinds) {
452
+ for (const s of kind === 'spec' ? SPEC_NEXT_STEPS : CHANGE_NEXT_STEPS) console.error(s);
453
+ }
454
+ console.error('Error: validation failed');
455
+ }
456
+ exitWith(items.some((i) => !i.valid) ? 1 : 0);
457
+ },
458
+ );
459
+ }
package/src/harness.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * CLI HarnessRunner adapter (validation r13/r48): executes the expanded
3
+ * bdd.run_command through `sh -c` in the project root, exporting
4
+ * LLMAN_SDD_HARNESS_ACTIVE=1 so harness-spawned validate invocations skip
5
+ * execution (nested guard). Windows (no sh) is out of scope for this change —
6
+ * the spawnError branch surfaces it as an ERROR. stdout and stderr are merged;
7
+ * core keeps only the tail.
8
+ */
9
+ import { spawnSync } from 'node:child_process';
10
+
11
+ import type { HarnessRunner } from '@llman-sdd/core';
12
+
13
+ export function makeCliHarnessRunner(): HarnessRunner {
14
+ return {
15
+ run: (command: string, cwd: string) => {
16
+ const proc = spawnSync('sh', ['-c', command], {
17
+ cwd,
18
+ encoding: 'utf8',
19
+ env: { ...process.env, LLMAN_SDD_HARNESS_ACTIVE: '1' },
20
+ });
21
+ if (proc.error !== undefined || proc.status === null) {
22
+ return {
23
+ exitCode: null,
24
+ output: `${proc.stdout ?? ''}${proc.stderr ?? ''}`,
25
+ spawnError: proc.error !== undefined ? String(proc.error) : 'no exit status from harness',
26
+ };
27
+ }
28
+ return { exitCode: proc.status, output: `${proc.stdout ?? ''}${proc.stderr ?? ''}` };
29
+ },
30
+ };
31
+ }
package/src/io.ts CHANGED
@@ -28,6 +28,8 @@ export interface CliIo {
28
28
  remove(path: string): void;
29
29
  mkdirp(path: string): void;
30
30
  processAlive(pid: number): boolean;
31
+ currentPid(): number;
32
+ now(): Date;
31
33
  mtimeMs(path: string): number;
32
34
  removeDir(path: string): void;
33
35
  moveDir(from: string, to: string): void;
@@ -60,6 +62,8 @@ export function makeIo(root: string): CliIo {
60
62
  return (error as NodeJS.ErrnoException).code !== 'ESRCH';
61
63
  }
62
64
  },
65
+ currentPid: () => process.pid,
66
+ now: () => new Date(),
63
67
  mtimeMs: (p) => statSync(full(p)).mtimeMs,
64
68
  removeDir: (p) => rmSync(full(p), { recursive: true, force: true }),
65
69
  moveDir: (from, to) => renameSync(full(from), full(to)),