@llman-sdd/cli 0.5.1 → 0.7.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.
- package/package.json +2 -2
- package/src/cli-shared.ts +45 -3
- package/src/commands/change.ts +5 -10
- package/src/commands/config.ts +10 -2
- package/src/commands/init.ts +36 -4
- package/src/commands/review.ts +1 -1
- package/src/commands/show.ts +1 -1
- package/src/commands/spec.ts +103 -28
- package/src/commands/validate.ts +173 -12
- package/src/harness.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llman-sdd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Spec-driven development workflow CLI (the `llman-sdd` command)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"build": "bun run ./scripts/build-binary.ts"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@llman-sdd/core": "0.
|
|
30
|
+
"@llman-sdd/core": "0.7.0",
|
|
31
31
|
"commander": "^15.0.0"
|
|
32
32
|
},
|
|
33
33
|
"engines": {
|
package/src/cli-shared.ts
CHANGED
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
* Command modules depend one-way on this file; it must not import any of them.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
discoverSpecs,
|
|
10
11
|
embeddedTemplates,
|
|
11
12
|
loadConfig,
|
|
13
|
+
loadConfigDetail,
|
|
12
14
|
makeEmbeddedTemplateIo,
|
|
13
15
|
resolveChangeId,
|
|
16
|
+
resolveInstanceRoot,
|
|
14
17
|
type TemplateIo,
|
|
15
18
|
} from '@llman-sdd/core';
|
|
16
19
|
import type { Command } from 'commander';
|
|
@@ -74,10 +77,49 @@ export function loadSpecEntries(): ReturnType<typeof discoverSpecs> {
|
|
|
74
77
|
return discoverSpecs('llmanspec/specs', newIo());
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Per-root specs dir (r94): the nearest llmanspec/ instance at or above the
|
|
82
|
+
* start dir (--directory override). Identity 'llmanspec/specs' when the cwd
|
|
83
|
+
* is itself the instance root — legacy relative output paths stay
|
|
84
|
+
* byte-identical there.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveRootSpecsDir(directory?: string): string {
|
|
87
|
+
const start = directory ?? process.cwd();
|
|
88
|
+
const root = resolveInstanceRoot(start, newIo());
|
|
89
|
+
if (root === null) {
|
|
90
|
+
throw new CliError(`no llman spec root at or above: ${start}`);
|
|
91
|
+
}
|
|
92
|
+
return root === process.cwd() ? 'llmanspec/specs' : join(root, 'llmanspec', 'specs');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Spec entries of the resolved instance root (per-root registry, r94). */
|
|
96
|
+
export function loadRootSpecEntries(directory?: string): ReturnType<typeof discoverSpecs> {
|
|
97
|
+
return discoverSpecs(resolveRootSpecsDir(directory), newIo());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One-time legacy `bdd:` migration warning — printed to stderr by the first
|
|
101
|
+
* config-reading command of a process (specs-check-config-and-unbound-feed). */
|
|
102
|
+
let legacyBddWarningShown = false;
|
|
103
|
+
|
|
104
|
+
function warnLegacyBdd(elevated: boolean): void {
|
|
105
|
+
if (!elevated || legacyBddWarningShown) return;
|
|
106
|
+
legacyBddWarningShown = true;
|
|
107
|
+
console.error(
|
|
108
|
+
'[WARNING] llmanspec/config.yaml uses the legacy `bdd:` section — migrate to `specs:` (`bdd.run_command` → `specs.check_command`). See migrations/v0.5-v0.6/README.md.',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Surface the legacy-section warning from a raw config source (config command). */
|
|
113
|
+
export function warnLegacyBddOnce(source: string): void {
|
|
114
|
+
if (legacyBddWarningShown) return;
|
|
115
|
+
warnLegacyBdd(loadConfigDetail(source).legacyBddElevated);
|
|
116
|
+
}
|
|
117
|
+
|
|
77
118
|
export function loadCliConfig(): ReturnType<typeof loadConfig> | null {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
119
|
+
if (!existsSync('llmanspec/config.yaml')) return null;
|
|
120
|
+
const detail = loadConfigDetail(readFileSync('llmanspec/config.yaml', 'utf8'));
|
|
121
|
+
warnLegacyBdd(detail.legacyBddElevated);
|
|
122
|
+
return detail.config;
|
|
81
123
|
}
|
|
82
124
|
|
|
83
125
|
export function cliMaxScanDepth(program: Command): number {
|
package/src/commands/change.ts
CHANGED
|
@@ -40,21 +40,15 @@ function runValidateSweep(): boolean {
|
|
|
40
40
|
return report.verdicts.some((v) => !v.ok);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/** r81: run or refuse the
|
|
43
|
+
/** r81: run or refuse the spec verification command before any merge or rename. */
|
|
44
44
|
function enforceCloseOutHarness(id: string, noCheck: boolean): void {
|
|
45
45
|
const proposalPath = `llmanspec/changes/${id}/proposal.md`;
|
|
46
46
|
const proposal = existsSync(proposalPath) ? readFileSync(proposalPath, 'utf8') : '';
|
|
47
47
|
const needsSpecsChange = readNeedsSpecsChange(extractFrontmatter(proposal));
|
|
48
|
-
const
|
|
49
|
-
(entry) =>
|
|
50
|
-
entry.doc.rules.some((r) => r.scenarios.some((s) => s.runnable && s.stepCount > 0)) ||
|
|
51
|
-
entry.doc.orphans.some((s) => s.runnable && s.stepCount > 0),
|
|
52
|
-
);
|
|
53
|
-
const runCommand = loadCliConfig()?.bdd?.run_command ?? null;
|
|
48
|
+
const runCommand = loadCliConfig()?.specs?.check_command ?? null;
|
|
54
49
|
const decision = decideCloseOutHarness({
|
|
55
50
|
noCheck,
|
|
56
51
|
needsSpecsChange,
|
|
57
|
-
hasExecutable,
|
|
58
52
|
runCommand,
|
|
59
53
|
nested: process.env.LLMAN_SDD_HARNESS_ACTIVE === '1',
|
|
60
54
|
});
|
|
@@ -62,13 +56,14 @@ function enforceCloseOutHarness(id: string, noCheck: boolean): void {
|
|
|
62
56
|
throw new CliError(`close-out aborted: ${decision.message}`);
|
|
63
57
|
}
|
|
64
58
|
if (decision.kind === 'skip') {
|
|
65
|
-
if (decision.announce) console.error('
|
|
59
|
+
if (decision.announce) console.error('spec check skipped: --no-check');
|
|
60
|
+
if (decision.warning !== undefined) console.error(`[WARNING] ${decision.warning}`);
|
|
66
61
|
return;
|
|
67
62
|
}
|
|
68
63
|
const result = makeCliHarnessRunner().run(decision.command, process.cwd());
|
|
69
64
|
if (result.spawnError !== undefined || result.exitCode !== 0) {
|
|
70
65
|
const why = result.spawnError ?? `exit ${result.exitCode}`;
|
|
71
|
-
throw new CliError(`close-out aborted:
|
|
66
|
+
throw new CliError(`close-out aborted: spec check failed (${why})`);
|
|
72
67
|
}
|
|
73
68
|
}
|
|
74
69
|
|
package/src/commands/config.ts
CHANGED
|
@@ -3,7 +3,12 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { renderConfigOverview, renderMachine, skillsJson } from '@llman-sdd/core';
|
|
4
4
|
import type { Command } from 'commander';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
addReportOutputOptions,
|
|
8
|
+
resolveOutMode,
|
|
9
|
+
skillDesc,
|
|
10
|
+
warnLegacyBddOnce,
|
|
11
|
+
} from '../cli-shared.ts';
|
|
7
12
|
|
|
8
13
|
export function registerConfig(program: Command): void {
|
|
9
14
|
const configCmd = program
|
|
@@ -12,6 +17,7 @@ export function registerConfig(program: Command): void {
|
|
|
12
17
|
|
|
13
18
|
configCmd.description('Print a read-only llmanspec/config.yaml overview').action(() => {
|
|
14
19
|
const source = readFileSync('llmanspec/config.yaml', 'utf8');
|
|
20
|
+
warnLegacyBddOnce(source);
|
|
15
21
|
console.log(renderConfigOverview(source).join('\n'));
|
|
16
22
|
});
|
|
17
23
|
|
|
@@ -19,7 +25,9 @@ export function registerConfig(program: Command): void {
|
|
|
19
25
|
addReportOutputOptions(skills);
|
|
20
26
|
skills.action((options: { json?: boolean; output?: string; compactJson?: boolean }) => {
|
|
21
27
|
const path = 'llmanspec/config.yaml';
|
|
22
|
-
const
|
|
28
|
+
const source = readFileSync(path, 'utf8');
|
|
29
|
+
warnLegacyBddOnce(source);
|
|
30
|
+
const info = skillsJson(source);
|
|
23
31
|
const mode = resolveOutMode(options.output, options.json, options.compactJson);
|
|
24
32
|
if (mode !== 'human') {
|
|
25
33
|
console.log(renderMachine(info, mode));
|
package/src/commands/init.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { runInit } from '@llman-sdd/core';
|
|
4
|
+
import { discoverRoots, refreshSubRootBlocks, runInit } from '@llman-sdd/core';
|
|
5
5
|
import type { Command } from 'commander';
|
|
6
6
|
|
|
7
7
|
import { CliError, templateIo, version } from '../cli-shared.ts';
|
|
8
|
-
import { makeIo } from '../io.ts';
|
|
8
|
+
import { makeCliGit, makeIo } from '../io.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* r93: the repo-root instance (git toplevel, or outside any repo) carries the
|
|
12
|
+
* agent skill surface; a sub-root init defaults to blocks-only. `--skills`
|
|
13
|
+
* opts a sub-root in.
|
|
14
|
+
*/
|
|
15
|
+
function isRepoRootInstance(root: string): boolean {
|
|
16
|
+
const toplevel = makeCliGit(root).runOpt(['rev-parse', '--show-toplevel']);
|
|
17
|
+
return toplevel === null || resolve(toplevel) === root;
|
|
18
|
+
}
|
|
9
19
|
|
|
10
20
|
export function registerInit(program: Command): void {
|
|
11
21
|
program
|
|
@@ -13,10 +23,17 @@ export function registerInit(program: Command): void {
|
|
|
13
23
|
.description('Initialize llmanspec in your project (--update to refresh existing)')
|
|
14
24
|
.argument('[path]', 'target directory (created when missing; defaults to cwd)')
|
|
15
25
|
.option('--update', 'refresh an existing installation')
|
|
26
|
+
.option(
|
|
27
|
+
'--skills',
|
|
28
|
+
'inject .agents/skills at a sub-root instance (repo-root instances always inject)',
|
|
29
|
+
)
|
|
16
30
|
.option('--locale <locale>', 'locale for generated templates (defaults to config or en)')
|
|
17
31
|
.option('--lang <locale>', 'alias of --locale')
|
|
18
32
|
.action(
|
|
19
|
-
(
|
|
33
|
+
(
|
|
34
|
+
path: string | undefined,
|
|
35
|
+
options: { update?: boolean; skills?: boolean; locale?: string; lang?: string },
|
|
36
|
+
) => {
|
|
20
37
|
if (options.locale !== undefined && options.lang !== undefined) {
|
|
21
38
|
throw new CliError('--locale and --lang are mutually exclusive (they are aliases)');
|
|
22
39
|
}
|
|
@@ -25,13 +42,28 @@ export function registerInit(program: Command): void {
|
|
|
25
42
|
root = resolve(path);
|
|
26
43
|
mkdirSync(root, { recursive: true });
|
|
27
44
|
}
|
|
45
|
+
const repoRootInstance = isRepoRootInstance(root);
|
|
28
46
|
const result = runInit(makeIo(root), templateIo, {
|
|
29
47
|
update: options.update ?? false,
|
|
30
48
|
locale: options.locale ?? options.lang,
|
|
31
49
|
version,
|
|
50
|
+
skills: options.skills === true || repoRootInstance,
|
|
32
51
|
});
|
|
52
|
+
let swept = '';
|
|
53
|
+
if ((options.update ?? false) && repoRootInstance) {
|
|
54
|
+
// r93 --update sweep: refresh managed blocks of every discovered
|
|
55
|
+
// sub-root (blocks only; skills stay a repo-root surface).
|
|
56
|
+
const subRoots = discoverRoots(root, makeIo(root)).filter((r) => r.rootDir !== root);
|
|
57
|
+
const refreshed: string[] = [];
|
|
58
|
+
for (const sub of subRoots) {
|
|
59
|
+
if (refreshSubRootBlocks(makeIo(sub.rootDir), templateIo, version)) {
|
|
60
|
+
refreshed.push(sub.rootDir);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (refreshed.length > 0) swept = `, blocks refreshed: ${refreshed.join(', ')}`;
|
|
64
|
+
}
|
|
33
65
|
const removed = result.removed.length > 0 ? `, removed: ${result.removed.join(', ')}` : '';
|
|
34
|
-
console.log(`initialized llmanspec (${result.skills.length} skills${removed})`);
|
|
66
|
+
console.log(`initialized llmanspec (${result.skills.length} skills${removed}${swept})`);
|
|
35
67
|
},
|
|
36
68
|
);
|
|
37
69
|
}
|
package/src/commands/review.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { makeCliGit } from '../io.ts';
|
|
|
26
26
|
export function registerReview(program: Command): void {
|
|
27
27
|
const review = program
|
|
28
28
|
.command('review')
|
|
29
|
-
.description('Aggregate review:
|
|
29
|
+
.description('Aggregate review: unbound/stale signals plus a validate sweep');
|
|
30
30
|
|
|
31
31
|
review.option('--capability <capability>', 'restrict the sweep to one capability/spec id');
|
|
32
32
|
addReportOutputOptions(review);
|
package/src/commands/show.ts
CHANGED
|
@@ -209,7 +209,7 @@ export function registerShow(program: Command): void {
|
|
|
209
209
|
const raw = readFileSync(specPath, 'utf8').trimEnd();
|
|
210
210
|
const summary = collectSpecs(entries).find((x) => x.id === item);
|
|
211
211
|
const morphology = summary
|
|
212
|
-
? `\n\n## Morphology\
|
|
212
|
+
? `\n\n## Morphology\nrequirements=${summary.requirementCount} bound=${summary.morphology.requirementBoundCount} unbound=${summary.morphology.requirementUnboundCount} acceptanceCount=${summary.morphology.acceptanceCount}`
|
|
213
213
|
: '';
|
|
214
214
|
console.log(`## Spec\n${raw}${morphology}`);
|
|
215
215
|
return;
|
package/src/commands/spec.ts
CHANGED
|
@@ -5,15 +5,26 @@ import { createInterface } from 'node:readline';
|
|
|
5
5
|
import {
|
|
6
6
|
addReq,
|
|
7
7
|
addScenario,
|
|
8
|
+
buildUnboundFeed,
|
|
8
9
|
hasNativeRules,
|
|
9
10
|
migrateNativeSource,
|
|
10
11
|
nextReqId,
|
|
12
|
+
renderMachine,
|
|
11
13
|
resolveReq,
|
|
12
14
|
scaffoldSpec,
|
|
15
|
+
type UnboundFeed,
|
|
13
16
|
} from '@llman-sdd/core';
|
|
14
17
|
import type { Command } from 'commander';
|
|
15
18
|
|
|
16
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
addReportOutputOptions,
|
|
21
|
+
CliError,
|
|
22
|
+
loadCliConfig,
|
|
23
|
+
loadRootSpecEntries,
|
|
24
|
+
newIo,
|
|
25
|
+
resolveOutMode,
|
|
26
|
+
resolveRootSpecsDir,
|
|
27
|
+
} from '../cli-shared.ts';
|
|
17
28
|
|
|
18
29
|
/** Walk paths collecting .feature files (directories recurse). */
|
|
19
30
|
function collectFeatureFiles(paths: string[]): string[] {
|
|
@@ -51,13 +62,18 @@ export function registerSpec(program: Command): void {
|
|
|
51
62
|
.description('Generate a single-track spec skeleton for a capability')
|
|
52
63
|
.argument('<capability>')
|
|
53
64
|
.option('--force', 'overwrite an existing spec file')
|
|
54
|
-
.
|
|
65
|
+
.option(
|
|
66
|
+
'--directory <path>',
|
|
67
|
+
'instance root resolution start (default: nearest llmanspec/ at or above cwd)',
|
|
68
|
+
)
|
|
69
|
+
.action((capability: string, options: { force?: boolean; directory?: string }) => {
|
|
55
70
|
const locale = loadCliConfig()?.locale ?? 'en';
|
|
56
|
-
const
|
|
71
|
+
const specsDir = resolveRootSpecsDir(options.directory);
|
|
72
|
+
const path = join(specsDir, `${capability}.feature`);
|
|
57
73
|
if (!options.force && existsSync(path)) {
|
|
58
74
|
throw new CliError(`spec already exists: ${path} (use --force to overwrite)`);
|
|
59
75
|
}
|
|
60
|
-
const written = scaffoldSpec(newIo(),
|
|
76
|
+
const written = scaffoldSpec(newIo(), specsDir, capability, locale, {
|
|
61
77
|
force: options.force,
|
|
62
78
|
});
|
|
63
79
|
console.log(`wrote ${written}`);
|
|
@@ -65,10 +81,14 @@ export function registerSpec(program: Command): void {
|
|
|
65
81
|
|
|
66
82
|
spec
|
|
67
83
|
.command('next-req-id')
|
|
68
|
-
.description('Allocate the next
|
|
84
|
+
.description('Allocate the next global req id (max in use + 1, rN)')
|
|
69
85
|
.option('--json', 'emit {reqId}')
|
|
70
|
-
.
|
|
71
|
-
|
|
86
|
+
.option(
|
|
87
|
+
'--directory <path>',
|
|
88
|
+
'instance root resolution start (default: nearest llmanspec/ at or above cwd)',
|
|
89
|
+
)
|
|
90
|
+
.action((options: { json?: boolean; directory?: string }) => {
|
|
91
|
+
const reqId = nextReqId(newIo(), resolveRootSpecsDir(options.directory));
|
|
72
92
|
if (options.json) console.log(JSON.stringify({ reqId }, null, 2));
|
|
73
93
|
else console.log(reqId);
|
|
74
94
|
});
|
|
@@ -81,16 +101,31 @@ export function registerSpec(program: Command): void {
|
|
|
81
101
|
.argument('<req_id>')
|
|
82
102
|
.requiredOption('--title <title>', 'rule title')
|
|
83
103
|
.requiredOption('--statement <statement>', 'rule statement (free text; multiple lines via \\n)')
|
|
84
|
-
.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
104
|
+
.option(
|
|
105
|
+
'--directory <path>',
|
|
106
|
+
'instance root resolution start (default: nearest llmanspec/ at or above cwd)',
|
|
107
|
+
)
|
|
108
|
+
.action(
|
|
109
|
+
(
|
|
110
|
+
capability: string,
|
|
111
|
+
reqId: string,
|
|
112
|
+
options: { title: string; statement: string; directory?: string },
|
|
113
|
+
) => {
|
|
114
|
+
const io = newIo();
|
|
115
|
+
const path = addReq(
|
|
116
|
+
io,
|
|
117
|
+
resolveRootSpecsDir(options.directory),
|
|
118
|
+
loadRootSpecEntries(options.directory),
|
|
119
|
+
{
|
|
120
|
+
capability,
|
|
121
|
+
reqId,
|
|
122
|
+
title: options.title,
|
|
123
|
+
statement: options.statement,
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
console.log(path);
|
|
127
|
+
},
|
|
128
|
+
);
|
|
94
129
|
|
|
95
130
|
spec
|
|
96
131
|
.command('add-scenario')
|
|
@@ -101,22 +136,31 @@ export function registerSpec(program: Command): void {
|
|
|
101
136
|
.option('--given <given>', 'Given step (optional)')
|
|
102
137
|
.requiredOption('--when <when>', 'When step')
|
|
103
138
|
.requiredOption('--then <then>', 'Then step')
|
|
139
|
+
.option(
|
|
140
|
+
'--directory <path>',
|
|
141
|
+
'instance root resolution start (default: nearest llmanspec/ at or above cwd)',
|
|
142
|
+
)
|
|
104
143
|
.action(
|
|
105
144
|
(
|
|
106
145
|
capability: string,
|
|
107
146
|
reqId: string,
|
|
108
147
|
scenarioId: string,
|
|
109
|
-
options: { given?: string; when: string; then: string },
|
|
148
|
+
options: { given?: string; when: string; then: string; directory?: string },
|
|
110
149
|
) => {
|
|
111
150
|
const io = newIo();
|
|
112
|
-
const path = addScenario(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
151
|
+
const path = addScenario(
|
|
152
|
+
io,
|
|
153
|
+
resolveRootSpecsDir(options.directory),
|
|
154
|
+
loadRootSpecEntries(options.directory),
|
|
155
|
+
{
|
|
156
|
+
capability,
|
|
157
|
+
reqId,
|
|
158
|
+
scenarioId,
|
|
159
|
+
given: options.given,
|
|
160
|
+
when: options.when,
|
|
161
|
+
thenText: options.then,
|
|
162
|
+
},
|
|
163
|
+
);
|
|
120
164
|
console.log(path);
|
|
121
165
|
},
|
|
122
166
|
);
|
|
@@ -166,8 +210,12 @@ export function registerSpec(program: Command): void {
|
|
|
166
210
|
.command('resolve-req')
|
|
167
211
|
.description('Resolve an rN to its capability and statement')
|
|
168
212
|
.argument('<req_id>')
|
|
169
|
-
.
|
|
170
|
-
|
|
213
|
+
.option(
|
|
214
|
+
'--directory <path>',
|
|
215
|
+
'instance root resolution start (default: nearest llmanspec/ at or above cwd)',
|
|
216
|
+
)
|
|
217
|
+
.action((reqId: string, options: { directory?: string }) => {
|
|
218
|
+
const resolved = resolveReq(loadRootSpecEntries(options.directory), reqId);
|
|
171
219
|
if (resolved === null) {
|
|
172
220
|
throw new CliError(`req id not found: ${reqId}`);
|
|
173
221
|
}
|
|
@@ -178,4 +226,31 @@ export function registerSpec(program: Command): void {
|
|
|
178
226
|
console.log('harness:');
|
|
179
227
|
for (const h of resolved.harness) console.log(` - ${h}`);
|
|
180
228
|
});
|
|
229
|
+
|
|
230
|
+
const unbound = spec
|
|
231
|
+
.command('unbound')
|
|
232
|
+
.description('List unbound requirements (no runnable nested scenario) for implementation')
|
|
233
|
+
.option('--limit <N>', 'max entries to return (default 1; 0 = all)');
|
|
234
|
+
addReportOutputOptions(unbound);
|
|
235
|
+
unbound.action(
|
|
236
|
+
(options: { limit?: string; output?: string; json?: boolean; compactJson?: boolean }) => {
|
|
237
|
+
const raw = options.limit ?? '1';
|
|
238
|
+
if (!/^\d+$/u.test(raw)) {
|
|
239
|
+
throw new CliError(`invalid --limit: ${raw} (non-negative integer)`, 2);
|
|
240
|
+
}
|
|
241
|
+
const limit = Number(raw);
|
|
242
|
+
const feed: UnboundFeed = buildUnboundFeed(loadRootSpecEntries(), limit);
|
|
243
|
+
const mode = resolveOutMode(options.output, options.json, options.compactJson);
|
|
244
|
+
if (mode !== 'human') {
|
|
245
|
+
console.log(renderMachine(feed, mode));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
console.log(`spec unbound: ${feed.returned} shown of ${feed.total} total`);
|
|
249
|
+
for (const r of feed.requirements) {
|
|
250
|
+
console.log(` - [${r.reqId}] ${r.featurePath} :: ${r.title}`);
|
|
251
|
+
for (const line of r.statement.split('\n')) console.log(` ${line}`);
|
|
252
|
+
}
|
|
253
|
+
if (feed.hint !== '') console.log(feed.hint);
|
|
254
|
+
},
|
|
255
|
+
);
|
|
181
256
|
}
|
package/src/commands/validate.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
applyStrict,
|
|
3
5
|
buildDuplicatesFor,
|
|
@@ -5,11 +7,14 @@ import {
|
|
|
5
7
|
collectChanges,
|
|
6
8
|
currentBranch,
|
|
7
9
|
defaultBranch,
|
|
10
|
+
discoverRoots,
|
|
8
11
|
evaluateStaleness,
|
|
9
12
|
formatTotals,
|
|
10
13
|
notApplicableStaleness,
|
|
11
14
|
renderMachine,
|
|
15
|
+
resolveInstanceRoot,
|
|
12
16
|
runHarnessForSpecs,
|
|
17
|
+
scopeCrossings,
|
|
13
18
|
specRelFor,
|
|
14
19
|
specIdOf,
|
|
15
20
|
STAGE_ORDER,
|
|
@@ -17,6 +22,7 @@ import {
|
|
|
17
22
|
validateChange,
|
|
18
23
|
type ChangeIssue,
|
|
19
24
|
type HarnessGate,
|
|
25
|
+
type RootEntry,
|
|
20
26
|
type StalenessInfo,
|
|
21
27
|
} from '@llman-sdd/core';
|
|
22
28
|
import type { Command } from 'commander';
|
|
@@ -51,13 +57,34 @@ function compareItems(a: VItem, b: VItem): number {
|
|
|
51
57
|
return a.id < b.id ? -1 : a.id > b.id ? 1 : a.type.localeCompare(b.type);
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Multi-root context of the specs being validated (r91-r92): `repoRelPrefix`
|
|
62
|
+
* lifts instance-root-relative scopes into repo-relative paths for the
|
|
63
|
+
* staleness diff match ('' when the instance root is the git toplevel —
|
|
64
|
+
* byte-identical legacy behavior); `otherRoots` feeds the single-ownership
|
|
65
|
+
* crossings that surface as ERROR issues.
|
|
66
|
+
*/
|
|
67
|
+
export interface InstanceContext {
|
|
68
|
+
repoRelPrefix: string;
|
|
69
|
+
otherRoots: RootEntry[];
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
function specV1Items(
|
|
55
|
-
opts: {
|
|
73
|
+
opts: {
|
|
74
|
+
strict?: boolean;
|
|
75
|
+
harness?: HarnessGate;
|
|
76
|
+
harnessOnlyIds?: string[];
|
|
77
|
+
instance?: InstanceContext;
|
|
78
|
+
},
|
|
56
79
|
entries: ReturnType<typeof loadSpecEntries> = loadSpecEntries(),
|
|
57
80
|
): VItem[] {
|
|
58
81
|
const io = newIo();
|
|
59
82
|
const git = makeCliGit(process.cwd());
|
|
60
83
|
const duplicatesFor = buildDuplicatesFor(entries);
|
|
84
|
+
const liftScope = (scope: string): string =>
|
|
85
|
+
opts.instance !== undefined && opts.instance.repoRelPrefix !== ''
|
|
86
|
+
? `${opts.instance.repoRelPrefix}/${scope}`
|
|
87
|
+
: scope;
|
|
61
88
|
|
|
62
89
|
const items: VItem[] = [];
|
|
63
90
|
for (const entry of entries) {
|
|
@@ -65,12 +92,13 @@ function specV1Items(
|
|
|
65
92
|
const verdict = validateCapability(entry as never, duplicatesFor, io, {
|
|
66
93
|
strict: opts.strict === true,
|
|
67
94
|
});
|
|
95
|
+
const scopes = entry.doc.header.scope?.split(',').map((s) => s.trim()) ?? [];
|
|
68
96
|
const specRel = specRelFor(entry.fileName);
|
|
69
97
|
const staleness = evaluateStaleness({
|
|
70
98
|
git,
|
|
71
99
|
root: process.cwd(),
|
|
72
100
|
specRel,
|
|
73
|
-
scope:
|
|
101
|
+
scope: scopes.map(liftScope),
|
|
74
102
|
baseRefEnv: process.env.LLMANSPEC_BASE_REF,
|
|
75
103
|
});
|
|
76
104
|
let issues: ChangeIssue[] = verdict.items.map((i) => ({
|
|
@@ -78,6 +106,18 @@ function specV1Items(
|
|
|
78
106
|
path: i.id,
|
|
79
107
|
message: i.message,
|
|
80
108
|
}));
|
|
109
|
+
if (opts.instance !== undefined) {
|
|
110
|
+
for (const crossing of scopeCrossings(scopes, process.cwd(), opts.instance.otherRoots)) {
|
|
111
|
+
issues = [
|
|
112
|
+
...issues,
|
|
113
|
+
{
|
|
114
|
+
level: 'ERROR',
|
|
115
|
+
path: 'single-ownership',
|
|
116
|
+
message: `scope '${crossing.scope}' crosses sub-root '${crossing.rootDir}' — migrate these specs into that root or narrow the scope (one path, one root)`,
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
81
121
|
if (opts.strict === true) issues = [...issues, ...applyStrict(staleness.issues)];
|
|
82
122
|
else issues = [...issues, ...staleness.issues];
|
|
83
123
|
items.push({
|
|
@@ -201,9 +241,33 @@ function renderValidateText(items: VItem[]): void {
|
|
|
201
241
|
console.log(formatTotals(passed, failed, items.length));
|
|
202
242
|
}
|
|
203
243
|
|
|
204
|
-
function
|
|
244
|
+
function gitToplevelOrCwd(): string {
|
|
245
|
+
const toplevel = makeCliGit(process.cwd()).runOpt(['rev-parse', '--show-toplevel']);
|
|
246
|
+
return toplevel ?? process.cwd();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Multi-root context of the cwd instance (r91-r92): empty otherRoots + ''
|
|
251
|
+
* prefix when run at a lone root — legacy byte-identical behavior.
|
|
252
|
+
*/
|
|
253
|
+
function instanceContextFor(maxDepth: number): InstanceContext {
|
|
254
|
+
const toplevel = gitToplevelOrCwd();
|
|
255
|
+
const otherRoots = discoverRoots(toplevel, newIo(), { maxDepth }).filter(
|
|
256
|
+
(r) => r.rootDir !== process.cwd(),
|
|
257
|
+
);
|
|
258
|
+
const repoRelPrefix = toplevel === process.cwd() ? '' : relative(toplevel, process.cwd());
|
|
259
|
+
return { repoRelPrefix, otherRoots };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
interface RootReport {
|
|
263
|
+
root: string;
|
|
264
|
+
items: VItem[];
|
|
265
|
+
summary: ReturnType<typeof summarizeItems>;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function summarizeItems(items: VItem[]) {
|
|
205
269
|
const types = [...new Set(items.map((i) => i.type))] as string[];
|
|
206
|
-
|
|
270
|
+
return {
|
|
207
271
|
totals: {
|
|
208
272
|
items: items.length,
|
|
209
273
|
passed: items.filter((i) => i.valid).length,
|
|
@@ -222,7 +286,10 @@ function renderValidateReport(items: VItem[], mode: 'json' | 'compact-json' | 't
|
|
|
222
286
|
{},
|
|
223
287
|
),
|
|
224
288
|
};
|
|
225
|
-
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderValidateReport(items: VItem[], mode: 'json' | 'compact-json' | 'toon'): void {
|
|
292
|
+
console.log(renderMachine({ items, summary: summarizeItems(items), version: '1.0' }, mode));
|
|
226
293
|
}
|
|
227
294
|
|
|
228
295
|
const SPEC_NEXT_STEPS = [
|
|
@@ -258,7 +325,7 @@ let harnessBannerShown = false;
|
|
|
258
325
|
* --check → check=true, --no-check → check=false, absent → undefined.
|
|
259
326
|
*/
|
|
260
327
|
function makeHarnessGate(options: { check?: boolean }): HarnessGate {
|
|
261
|
-
const runCommand = loadCliConfig()?.
|
|
328
|
+
const runCommand = loadCliConfig()?.specs?.check_command ?? null;
|
|
262
329
|
const configured = runCommand !== null && runCommand !== '';
|
|
263
330
|
return {
|
|
264
331
|
nested: process.env.LLMAN_SDD_HARNESS_ACTIVE === '1',
|
|
@@ -269,7 +336,7 @@ function makeHarnessGate(options: { check?: boolean }): HarnessGate {
|
|
|
269
336
|
onBeforeFirstRun: (expanded: string): void => {
|
|
270
337
|
if (harnessBannerShown) return;
|
|
271
338
|
harnessBannerShown = true;
|
|
272
|
-
console.error(`running
|
|
339
|
+
console.error(`running spec check: ${expanded} (use --no-check to skip)`);
|
|
273
340
|
},
|
|
274
341
|
};
|
|
275
342
|
}
|
|
@@ -285,9 +352,17 @@ export function registerValidate(program: Command): void {
|
|
|
285
352
|
.option('--type <type>', 'force disambiguation: change | spec')
|
|
286
353
|
.option('--stage <stage>', 'change stage gate: draft | designed | planned | full')
|
|
287
354
|
.option('--strict', 'warnings also make the exit code non-zero')
|
|
355
|
+
.option(
|
|
356
|
+
'--directory <path>',
|
|
357
|
+
'instance root resolution start (default: nearest llmanspec/ at or above cwd)',
|
|
358
|
+
)
|
|
359
|
+
.option(
|
|
360
|
+
'--all-roots',
|
|
361
|
+
'aggregate: validate every discovered llmanspec root (specs scope, any root red → non-zero exit)',
|
|
362
|
+
)
|
|
288
363
|
.option('--include-info', 'keep INFO-level issues (default: WARNING and above)')
|
|
289
|
-
.option('--no-check', 'skip the
|
|
290
|
-
.option('--check', 'run the
|
|
364
|
+
.option('--no-check', 'skip the spec check')
|
|
365
|
+
.option('--check', 'run the spec check (default when specs.check_command is configured)');
|
|
291
366
|
addReportOutputOptions(validate);
|
|
292
367
|
validate.action(
|
|
293
368
|
(
|
|
@@ -304,6 +379,8 @@ export function registerValidate(program: Command): void {
|
|
|
304
379
|
includeInfo?: boolean;
|
|
305
380
|
output?: string;
|
|
306
381
|
check?: boolean;
|
|
382
|
+
directory?: string;
|
|
383
|
+
allRoots?: boolean;
|
|
307
384
|
},
|
|
308
385
|
) => {
|
|
309
386
|
if (!assertCompactJsonPairing(options)) return;
|
|
@@ -323,6 +400,77 @@ export function registerValidate(program: Command): void {
|
|
|
323
400
|
) {
|
|
324
401
|
throw new CliError(`invalid --stage: ${options.stage}`);
|
|
325
402
|
}
|
|
403
|
+
const maxDepth = cliMaxScanDepth(program);
|
|
404
|
+
|
|
405
|
+
// ---- aggregate over every discovered root (r91) ----
|
|
406
|
+
if (options.allRoots === true) {
|
|
407
|
+
if (item !== undefined) {
|
|
408
|
+
throw new CliError('--all-roots validates every root; drop the <item> argument');
|
|
409
|
+
}
|
|
410
|
+
const start = options.directory ?? gitToplevelOrCwd();
|
|
411
|
+
const roots = discoverRoots(start, newIo(), { maxDepth });
|
|
412
|
+
if (roots.length === 0) {
|
|
413
|
+
throw new CliError(`no llmanspec roots discovered under: ${start}`);
|
|
414
|
+
}
|
|
415
|
+
const originalCwd = process.cwd();
|
|
416
|
+
const reports: RootReport[] = [];
|
|
417
|
+
let anyFail = false;
|
|
418
|
+
try {
|
|
419
|
+
for (const root of roots) {
|
|
420
|
+
process.chdir(root.rootDir);
|
|
421
|
+
const items = specV1Items({
|
|
422
|
+
strict: options.strict,
|
|
423
|
+
harness: makeHarnessGate(options),
|
|
424
|
+
instance: instanceContextFor(maxDepth),
|
|
425
|
+
});
|
|
426
|
+
const visible = keepInfo ? items : items.map(stripInfo);
|
|
427
|
+
if (visible.some((i) => !i.valid)) anyFail = true;
|
|
428
|
+
reports.push({
|
|
429
|
+
root: relative(start, root.rootDir) || '.',
|
|
430
|
+
items: visible,
|
|
431
|
+
summary: summarizeItems(visible),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
} finally {
|
|
435
|
+
process.chdir(originalCwd);
|
|
436
|
+
}
|
|
437
|
+
if (outMode === 'human') {
|
|
438
|
+
for (const report of reports) {
|
|
439
|
+
console.log(`root ${report.root}`);
|
|
440
|
+
renderValidateText(report.items);
|
|
441
|
+
}
|
|
442
|
+
} else {
|
|
443
|
+
const totals = reports.reduce(
|
|
444
|
+
(acc, r) => ({
|
|
445
|
+
items: acc.items + r.summary.totals.items,
|
|
446
|
+
passed: acc.passed + r.summary.totals.passed,
|
|
447
|
+
failed: acc.failed + r.summary.totals.failed,
|
|
448
|
+
}),
|
|
449
|
+
{ items: 0, passed: 0, failed: 0 },
|
|
450
|
+
);
|
|
451
|
+
console.log(
|
|
452
|
+
renderMachine({ roots: reports, summary: { totals }, version: '1.0' }, outMode),
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
if (anyFail) console.error('Error: validation failed');
|
|
456
|
+
exitWith(anyFail ? 1 : 0);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---- instance root resolution: --directory start, else nearest
|
|
461
|
+
// ancestor llmanspec/ ("cd into the subpackage and run", r94). A no-op
|
|
462
|
+
// when cwd already sits at a root (byte-identical legacy path). ----
|
|
463
|
+
if (options.directory !== undefined) {
|
|
464
|
+
const root = resolveInstanceRoot(options.directory, newIo());
|
|
465
|
+
if (root === null) {
|
|
466
|
+
throw new CliError(`no llmanspec root at or above: ${options.directory}`);
|
|
467
|
+
}
|
|
468
|
+
process.chdir(root);
|
|
469
|
+
} else {
|
|
470
|
+
const root = resolveInstanceRoot(process.cwd(), newIo());
|
|
471
|
+
if (root !== null && root !== process.cwd()) process.chdir(root);
|
|
472
|
+
}
|
|
473
|
+
|
|
326
474
|
const harness = makeHarnessGate(options);
|
|
327
475
|
|
|
328
476
|
// ---- single item (auto-disambiguate: spec first, then change) ----
|
|
@@ -332,7 +480,12 @@ export function registerValidate(program: Command): void {
|
|
|
332
480
|
options.type === 'change' ? undefined : entries.find((e) => specIdOf(e) === item);
|
|
333
481
|
if (specEntry !== undefined) {
|
|
334
482
|
const items = specV1Items(
|
|
335
|
-
{
|
|
483
|
+
{
|
|
484
|
+
strict: options.strict,
|
|
485
|
+
harness,
|
|
486
|
+
harnessOnlyIds: [item],
|
|
487
|
+
instance: instanceContextFor(maxDepth),
|
|
488
|
+
},
|
|
336
489
|
entries,
|
|
337
490
|
);
|
|
338
491
|
const mine = items.find((i) => i.id === item);
|
|
@@ -424,10 +577,18 @@ export function registerValidate(program: Command): void {
|
|
|
424
577
|
const effectiveChanges = defaultSpecsOnly ? false : changeScope;
|
|
425
578
|
|
|
426
579
|
let items: VItem[] = [];
|
|
427
|
-
if (effectiveSpecs)
|
|
580
|
+
if (effectiveSpecs) {
|
|
581
|
+
items = items.concat(
|
|
582
|
+
specV1Items({
|
|
583
|
+
strict: options.strict,
|
|
584
|
+
harness,
|
|
585
|
+
instance: instanceContextFor(maxDepth),
|
|
586
|
+
}),
|
|
587
|
+
);
|
|
588
|
+
}
|
|
428
589
|
if (effectiveChanges) {
|
|
429
590
|
const names = collectChanges(newIo(), process.cwd(), new Date(), {
|
|
430
|
-
maxScanDepth:
|
|
591
|
+
maxScanDepth: maxDepth,
|
|
431
592
|
}).map((c) => c.name);
|
|
432
593
|
items = items.concat(
|
|
433
594
|
changeV1Items(names, { stage: options.stage, strict: options.strict }),
|
package/src/harness.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI HarnessRunner adapter (validation r13/r48): executes the expanded
|
|
3
|
-
*
|
|
3
|
+
* specs.check_command through `sh -c` in the project root, exporting
|
|
4
4
|
* LLMAN_SDD_HARNESS_ACTIVE=1 so harness-spawned validate invocations skip
|
|
5
5
|
* execution (nested guard). Windows (no sh) is out of scope for this change —
|
|
6
6
|
* the spawnError branch surfaces it as an ERROR. stdout and stderr are merged;
|