@llman-sdd/core 0.1.4 → 0.3.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 +1 -1
- package/src/archive/freeze.ts +8 -5
- package/src/change/id.ts +51 -31
- package/src/change/lifecycle.ts +186 -23
- package/src/change/nextId.ts +55 -0
- package/src/change/resolve.ts +34 -0
- package/src/config/changeId.ts +87 -0
- package/src/config/surface.ts +59 -0
- package/src/context/indexStore.ts +41 -0
- package/src/git/spawnGit.ts +12 -0
- package/src/index.ts +50 -1
- package/src/report/collect.ts +30 -14
- package/src/report/graph.ts +339 -43
- package/src/report/show.ts +38 -15
- package/src/report/specHelpers.ts +13 -5
- package/src/report/specs.ts +14 -7
- package/src/review/review.ts +36 -19
- package/src/spec/authoring.ts +206 -0
- package/src/spec/ir.ts +0 -1
- package/src/spec/parser.ts +4 -7
- package/src/validation/changeCheck.ts +341 -0
- package/src/validation/staleness.ts +158 -0
- package/src/validation/validate.ts +125 -42
- package/templates/en/units/skills/validation-hints.md +1 -1
- package/templates/en/units/spec/feature-contract.md +1 -1
- package/templates/zh-Hans/units/skills/validation-hints.md +1 -1
- package/templates/zh-Hans/units/spec/feature-contract.md +1 -1
package/src/report/show.ts
CHANGED
|
@@ -22,7 +22,11 @@ export interface ShowDeps {
|
|
|
22
22
|
specsDir: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function showChangeJson(
|
|
25
|
+
export function showChangeJson(
|
|
26
|
+
deps: ShowDeps,
|
|
27
|
+
id: string,
|
|
28
|
+
opts: { matchedViaPrefix?: boolean } = {},
|
|
29
|
+
): Record<string, unknown> {
|
|
26
30
|
const { io, git, root } = deps;
|
|
27
31
|
const dir = `${root}/${CHANGES_DIR}/${id}`;
|
|
28
32
|
const proposalPath = `${dir}/proposal.md`;
|
|
@@ -41,8 +45,16 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
|
|
|
41
45
|
? countTasks(io.readText(`${dir}/tasks.md`))
|
|
42
46
|
: { completed: 0, total: 0 };
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
// v1 parity: show works (gracefully degraded gates) outside a git repo.
|
|
49
|
+
let cleanTree = false;
|
|
50
|
+
let current = null;
|
|
51
|
+
try {
|
|
52
|
+
cleanTree = isCleanTree(git);
|
|
53
|
+
current = currentBranch(git);
|
|
54
|
+
} catch {
|
|
55
|
+
current = null;
|
|
56
|
+
}
|
|
57
|
+
const onBoundBranch = binding !== null && current === binding.branch;
|
|
46
58
|
|
|
47
59
|
// specs landing: bound changes must have touched llmanspec/specs/ since base
|
|
48
60
|
let specsLanded = false;
|
|
@@ -60,7 +72,17 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
|
|
|
60
72
|
const specReport = validateAllSpecs(discoverSpecs(deps.specsDir, io), io);
|
|
61
73
|
const validateOk = !specReport.failed && (total === 0 || tasksDone);
|
|
62
74
|
|
|
63
|
-
const
|
|
75
|
+
const stageHint = !hasDesign
|
|
76
|
+
? 'add design.md (current: draft → designed)'
|
|
77
|
+
: !hasTasks
|
|
78
|
+
? 'add tasks.md (current: designed → planned)'
|
|
79
|
+
: stage === 'planned'
|
|
80
|
+
? 'bind via `llman sdd change start <id>` (planned → full)'
|
|
81
|
+
: '';
|
|
82
|
+
const tasksHint =
|
|
83
|
+
total > 0 && completed < total
|
|
84
|
+
? `${total - completed} unchecked tasks`
|
|
85
|
+
: 'complete or check off remaining tasks';
|
|
64
86
|
|
|
65
87
|
const gateChecks = [
|
|
66
88
|
{
|
|
@@ -71,29 +93,30 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
|
|
|
71
93
|
{
|
|
72
94
|
name: 'on-bound-branch',
|
|
73
95
|
pass: onBoundBranch,
|
|
74
|
-
hint: onBoundBranch ? '' : 'change is not attached; run `llman
|
|
96
|
+
hint: onBoundBranch ? '' : 'change is not attached; run `llman sdd change start <id>`',
|
|
75
97
|
},
|
|
76
98
|
{
|
|
77
99
|
name: 'stage-complete',
|
|
78
|
-
pass:
|
|
79
|
-
hint:
|
|
100
|
+
pass: hasDesign && hasTasks,
|
|
101
|
+
hint: hasDesign && hasTasks ? '' : (stageHint as string),
|
|
80
102
|
},
|
|
81
103
|
{
|
|
82
104
|
name: 'specs-landed',
|
|
83
|
-
pass: specsLanded,
|
|
84
|
-
hint:
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
pass: specsLanded || !needsSpecsChange,
|
|
106
|
+
hint:
|
|
107
|
+
specsLanded || !needsSpecsChange
|
|
108
|
+
? ''
|
|
109
|
+
: 'edit live specs on the bound branch and commit (or needs_specs_change: false)',
|
|
87
110
|
},
|
|
88
111
|
{
|
|
89
112
|
name: 'tasks-done',
|
|
90
|
-
pass: tasksDone,
|
|
91
|
-
hint: tasksDone ? '' :
|
|
113
|
+
pass: tasksDone && total > 0,
|
|
114
|
+
hint: tasksDone && total > 0 ? '' : tasksHint,
|
|
92
115
|
},
|
|
93
116
|
{
|
|
94
117
|
name: 'validate',
|
|
95
118
|
pass: validateOk,
|
|
96
|
-
hint: validateOk ? '' : 'fix issues reported by `llman
|
|
119
|
+
hint: validateOk ? '' : 'fix issues reported by `llman sdd validate <id> --strict`',
|
|
97
120
|
},
|
|
98
121
|
];
|
|
99
122
|
|
|
@@ -110,6 +133,6 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
|
|
|
110
133
|
deltaCount: 0,
|
|
111
134
|
deltas: [] as string[],
|
|
112
135
|
gateChecks,
|
|
113
|
-
matchedViaPrefix:
|
|
136
|
+
matchedViaPrefix: opts.matchedViaPrefix === true,
|
|
114
137
|
};
|
|
115
138
|
}
|
|
@@ -14,9 +14,12 @@ export interface SpecHelperIo {
|
|
|
14
14
|
listDir(path: string): string[];
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* v1 parity (`req_registry.rs::next_req_id_from_index`): smallest free rN over
|
|
19
|
+
* the RULE (@human) req ids only; acceptance-only req tags do not occupy ids.
|
|
20
|
+
*/
|
|
18
21
|
export function nextReqId(io: SpecHelperIo, specsDir: string): string {
|
|
19
|
-
|
|
22
|
+
const used = new Set<number>();
|
|
20
23
|
const walk = (dir: string): void => {
|
|
21
24
|
if (!io.exists(dir)) return;
|
|
22
25
|
for (const name of io.listDir(dir)) {
|
|
@@ -24,8 +27,10 @@ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
|
|
|
24
27
|
if (name.endsWith('.feature')) {
|
|
25
28
|
const doc = parseCapability(io.readText(full), full);
|
|
26
29
|
for (const scenario of doc.scenarios) {
|
|
30
|
+
if (scenario.classification !== 'human') continue;
|
|
27
31
|
for (const reqId of scenario.reqIds) {
|
|
28
|
-
|
|
32
|
+
const n = Math.trunc(Number(reqId.replace(/^r/u, '')));
|
|
33
|
+
if (Number.isFinite(n)) used.add(n);
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
} else if (io.isDirectory(full)) {
|
|
@@ -34,7 +39,9 @@ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
|
|
|
34
39
|
}
|
|
35
40
|
};
|
|
36
41
|
walk(specsDir);
|
|
37
|
-
|
|
42
|
+
let n = 1;
|
|
43
|
+
while (used.has(n)) n += 1;
|
|
44
|
+
return `r${n}`;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
export function skeletonContent(capability: string, reqId: string, locale: string): string {
|
|
@@ -53,10 +60,11 @@ export function scaffoldSpec(
|
|
|
53
60
|
specsDir: string,
|
|
54
61
|
capability: string,
|
|
55
62
|
locale: string,
|
|
63
|
+
opts: { force?: boolean } = {},
|
|
56
64
|
): string {
|
|
57
65
|
const reqId = nextReqId(io, specsDir);
|
|
58
66
|
const path = `${specsDir}/${capability}.feature`;
|
|
59
|
-
if (io.exists(path)) throw new Error(`spec already exists: ${path}`);
|
|
67
|
+
if (!opts.force && io.exists(path)) throw new Error(`spec already exists: ${path}`);
|
|
60
68
|
io.mkdirp('src/');
|
|
61
69
|
io.writeText(path, skeletonContent(capability, reqId, locale));
|
|
62
70
|
return path;
|
package/src/report/specs.ts
CHANGED
|
@@ -10,7 +10,6 @@ import { pad } from './collect.ts';
|
|
|
10
10
|
export interface SpecMorphology {
|
|
11
11
|
ruleCount: number;
|
|
12
12
|
ruleEnforcedCount: number;
|
|
13
|
-
ruleManualCount: number;
|
|
14
13
|
rulePendingCount: number;
|
|
15
14
|
acceptanceCount: number;
|
|
16
15
|
orphanAcceptanceCount: number;
|
|
@@ -27,22 +26,30 @@ export interface SpecSummary {
|
|
|
27
26
|
morphology: SpecMorphology;
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
/** Morphology counts shared by `list --specs`, `show <spec> --json`, and the
|
|
30
|
+
* CLI text render — the single source of the enforced/pending two-state rule
|
|
31
|
+
* (rules = @human; enforced = @req-linked acceptance coverage; pending = rest). */
|
|
32
|
+
export function morphologyOfScenarios(
|
|
33
|
+
scenarios: readonly { classification: string; reqIds: readonly string[] }[],
|
|
34
|
+
): SpecMorphology {
|
|
35
|
+
const rules = scenarios.filter((s) => s.classification === 'human');
|
|
36
|
+
const acceptance = scenarios.filter((s) => s.classification === 'executable');
|
|
37
|
+
const acceptanceReqIds = new Set(acceptance.flatMap((s) => [...s.reqIds]));
|
|
34
38
|
const enforced = rules.filter((r) => r.reqIds.some((id) => acceptanceReqIds.has(id)));
|
|
35
39
|
const orphan = acceptance.filter((s) => s.reqIds.length === 0);
|
|
36
40
|
return {
|
|
37
41
|
ruleCount: rules.length,
|
|
38
42
|
ruleEnforcedCount: enforced.length,
|
|
39
|
-
ruleManualCount: rules.filter((r) => r.manual).length,
|
|
40
43
|
rulePendingCount: rules.length - enforced.length,
|
|
41
44
|
acceptanceCount: acceptance.length,
|
|
42
45
|
orphanAcceptanceCount: orphan.length,
|
|
43
46
|
};
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
function morphologyOf(doc: CapabilityDoc): SpecMorphology {
|
|
50
|
+
return morphologyOfScenarios(doc.scenarios);
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
export function collectSpecs(entries: readonly SpecEntry[]): SpecSummary[] {
|
|
47
54
|
return entries.map((e) => {
|
|
48
55
|
const morphology = morphologyOf(e.doc);
|
|
@@ -68,7 +75,7 @@ export function renderSpecsList(summaries: readonly SpecSummary[]): string[] {
|
|
|
68
75
|
for (const s of summaries) {
|
|
69
76
|
const m = s.morphology;
|
|
70
77
|
lines.push(
|
|
71
|
-
` ${pad(s.id, idWidth)}rules ${m.ruleCount} enforced ${m.ruleEnforcedCount}
|
|
78
|
+
` ${pad(s.id, idWidth)}rules ${m.ruleCount} enforced ${m.ruleEnforcedCount} pending ${m.rulePendingCount} acceptance ${m.acceptanceCount}`,
|
|
72
79
|
);
|
|
73
80
|
}
|
|
74
81
|
return lines;
|
package/src/review/review.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import type { GitLike } from '../git/spawnGit.ts';
|
|
2
|
+
import { evaluateStaleness, notApplicableStaleness } from '../validation/staleness.ts';
|
|
1
3
|
/**
|
|
2
4
|
* Review aggregation (review-freeze capability, r23): five-signal review over
|
|
3
5
|
* spec IR + validate sweep. Port of v1 sdd/review.rs observable contract.
|
|
4
6
|
*/
|
|
5
7
|
import { validateAllSpecs, type SpecEntry, type SpecIo } from '../validation/validate.ts';
|
|
6
8
|
|
|
7
|
-
export type ReviewKind = 'pending' | '
|
|
9
|
+
export type ReviewKind = 'pending' | 'unbound' | 'stale' | 'locked' | 'validate';
|
|
8
10
|
|
|
9
11
|
export interface ReviewSignal {
|
|
10
12
|
kind: ReviewKind;
|
|
@@ -26,6 +28,12 @@ export interface ReviewInput {
|
|
|
26
28
|
boundChangeCount: number;
|
|
27
29
|
/** Active change summaries for the strict sweep (pending tasks → FAIL). */
|
|
28
30
|
activeChanges?: readonly { name: string; completedTasks: number; totalTasks: number }[];
|
|
31
|
+
/** Restrict per-capability signals (pending/unbound/stale) to this capability. */
|
|
32
|
+
capability?: string;
|
|
33
|
+
/** git + root for real staleness evaluation (v1 parity). */
|
|
34
|
+
git?: GitLike;
|
|
35
|
+
root?: string;
|
|
36
|
+
specsDir?: string;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export interface ReviewResult {
|
|
@@ -35,17 +43,8 @@ export interface ReviewResult {
|
|
|
35
43
|
exitCode: number;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
function executableBound(
|
|
39
|
-
scenarioTags: readonly string[],
|
|
40
|
-
bindings: readonly TagBinding[] | null,
|
|
41
|
-
): boolean {
|
|
42
|
-
if (!bindings || bindings.length === 0) return false;
|
|
43
|
-
const has = (t: string): boolean => scenarioTags.includes(t);
|
|
44
|
-
return bindings.some((b) => (b.kind === 'tags' ? b.tags.every((t) => has(t)) : false));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
46
|
export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
48
|
-
const { entries,
|
|
47
|
+
const { entries, boundChangeCount } = input;
|
|
49
48
|
const strictChangeFails = (input.activeChanges ?? []).filter(
|
|
50
49
|
(c) => c.totalTasks > 0 && c.completedTasks < c.totalTasks,
|
|
51
50
|
);
|
|
@@ -59,18 +58,36 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
59
58
|
|
|
60
59
|
for (const entry of sorted) {
|
|
61
60
|
const cap = entry.doc.header.capability ?? entry.fileName;
|
|
61
|
+
// r33: per-capability signals honor the --capability filter; locked and
|
|
62
|
+
// validate stay global regardless.
|
|
63
|
+
if (input.capability !== undefined && cap !== input.capability) continue;
|
|
62
64
|
const rules = entry.doc.scenarios.filter((s) => s.classification === 'human');
|
|
63
65
|
const acceptance = entry.doc.scenarios.filter((s) => s.classification === 'executable');
|
|
64
66
|
const acceptanceReqIds = new Set(acceptance.flatMap((s) => s.reqIds));
|
|
65
67
|
const pending = rules.filter((r) => !r.reqIds.some((id) => acceptanceReqIds.has(id)));
|
|
66
|
-
|
|
67
|
-
const unbound = acceptance.filter((s) =>
|
|
68
|
+
// v1 r5: unbound = orphan acceptance scenarios (no @req link).
|
|
69
|
+
const unbound = acceptance.filter((s) => s.reqIds.length === 0);
|
|
68
70
|
|
|
69
71
|
push('pending', cap, pending.length);
|
|
70
|
-
push('manual', cap, manual.length);
|
|
71
72
|
push('unbound', cap, unbound.length);
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
|
|
74
|
+
// staleness (v1 evaluate): real base-ref/scope evaluation.
|
|
75
|
+
let staleInfo = notApplicableStaleness();
|
|
76
|
+
let staleCount = 0;
|
|
77
|
+
if (input.git !== undefined && input.root !== undefined) {
|
|
78
|
+
const specRel =
|
|
79
|
+
(entry.fileName.startsWith('llmanspec/') ? '' : 'llmanspec/specs/') + entry.fileName;
|
|
80
|
+
const evalResult = evaluateStaleness({
|
|
81
|
+
git: input.git,
|
|
82
|
+
root: input.root,
|
|
83
|
+
specRel,
|
|
84
|
+
scope: entry.doc.header.scope?.split(',').map((x) => x.trim()) ?? [],
|
|
85
|
+
baseRefEnv: process.env.LLMANSPEC_BASE_REF,
|
|
86
|
+
});
|
|
87
|
+
staleInfo = evalResult.info;
|
|
88
|
+
staleCount = staleInfo.status === 'OK' || staleInfo.status === 'NOTAPPLICABLE' ? 0 : 1;
|
|
89
|
+
}
|
|
90
|
+
push('stale', cap, staleCount, staleInfo.status === 'NOTAPPLICABLE' ? '' : staleInfo.status);
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
const failed = sweep.verdicts.filter((v) => !v.ok);
|
|
@@ -96,13 +113,13 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
96
113
|
'locked',
|
|
97
114
|
'-',
|
|
98
115
|
0,
|
|
99
|
-
`${boundChangeCount} bound change(s); inspect with \`llman
|
|
116
|
+
`${boundChangeCount} bound change(s); inspect with \`llman sdd change diff <id>\``,
|
|
100
117
|
);
|
|
101
118
|
push(
|
|
102
119
|
'validate',
|
|
103
120
|
'-',
|
|
104
121
|
failed.length,
|
|
105
|
-
failed.length > 0 ? 'validate --all failed; run `llman
|
|
122
|
+
failed.length > 0 ? 'validate --all failed; run `llman sdd validate --all` for details' : 'ok',
|
|
106
123
|
);
|
|
107
124
|
|
|
108
125
|
const warningCount = signals
|
|
@@ -112,7 +129,7 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
112
129
|
|
|
113
130
|
const lines: string[] = [`Review: critical=${criticalCount} warning=${warningCount}`];
|
|
114
131
|
for (const cap of sorted.map((e) => e.doc.header.capability ?? e.fileName)) {
|
|
115
|
-
for (const kind of ['pending', '
|
|
132
|
+
for (const kind of ['pending', 'unbound', 'stale'] as const) {
|
|
116
133
|
const s = signals.find((x) => x.kind === kind && x.capability === cap);
|
|
117
134
|
if (!s) continue;
|
|
118
135
|
lines.push(`${kind}: ${cap} (${s.count})`);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programming-style spec authoring helpers (r41-r43, v1 parity):
|
|
3
|
+
* append rules / acceptance scenarios, resolve req ids, dedupe conflicts.
|
|
4
|
+
* Appends are text-level so existing file content (formatting, comments)
|
|
5
|
+
* stays untouched.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CapabilityDoc } from './ir.ts';
|
|
9
|
+
|
|
10
|
+
const RULE_KEYWORDS = ['MUST', 'SHALL', '必须', '不得', '禁止'] as const;
|
|
11
|
+
|
|
12
|
+
export class AuthoringError extends Error {}
|
|
13
|
+
|
|
14
|
+
export interface SpecEntryLike {
|
|
15
|
+
fileName: string;
|
|
16
|
+
doc: CapabilityDoc;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WriteIo {
|
|
20
|
+
exists(path: string): boolean;
|
|
21
|
+
readText(path: string): string;
|
|
22
|
+
writeText(path: string, content: string): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function keywordsOf(content: string): {
|
|
26
|
+
scenario: string;
|
|
27
|
+
given: string;
|
|
28
|
+
when: string;
|
|
29
|
+
thenText: string;
|
|
30
|
+
} {
|
|
31
|
+
return content.includes('功能:')
|
|
32
|
+
? { scenario: '场景', given: '假如', when: '当', thenText: '那么' }
|
|
33
|
+
: { scenario: 'Scenario', given: 'Given', when: 'When', thenText: 'Then' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertRuleWording(statement: string): void {
|
|
37
|
+
if (!RULE_KEYWORDS.some((k) => statement.includes(k))) {
|
|
38
|
+
throw new AuthoringError(
|
|
39
|
+
`statement must contain a rule keyword (${RULE_KEYWORDS.join('/')}): ${statement}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findReq(
|
|
45
|
+
entries: readonly SpecEntryLike[],
|
|
46
|
+
reqId: string,
|
|
47
|
+
): { entry: SpecEntryLike; scenarioName: string; statement: string } | null {
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
for (const scenario of entry.doc.scenarios) {
|
|
50
|
+
if (scenario.reqIds.includes(reqId) && scenario.classification === 'human') {
|
|
51
|
+
return { entry, scenarioName: scenario.name, statement: scenario.statement };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** v1 parity: only @human (rule) req ids participate in the dedupe registry. */
|
|
59
|
+
export function ruleReqIds(entries: readonly SpecEntryLike[]): Set<string> {
|
|
60
|
+
const ids = new Set<string>();
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
for (const scenario of entry.doc.scenarios) {
|
|
63
|
+
if (scenario.classification === 'human') {
|
|
64
|
+
for (const id of scenario.reqIds) ids.add(id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return ids;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function allReqIds(entries: readonly SpecEntryLike[]): Set<string> {
|
|
72
|
+
const ids = new Set<string>();
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
for (const scenario of entry.doc.scenarios) {
|
|
75
|
+
for (const id of scenario.reqIds) ids.add(id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return ids;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface AddReqOpts {
|
|
82
|
+
capability: string;
|
|
83
|
+
reqId: string;
|
|
84
|
+
title: string;
|
|
85
|
+
statement: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Append `@req:<id> @human` rule scenario to `<capability>.feature` (r41). */
|
|
89
|
+
export function addReq(
|
|
90
|
+
io: WriteIo,
|
|
91
|
+
specsRoot: string,
|
|
92
|
+
entries: readonly SpecEntryLike[],
|
|
93
|
+
opts: AddReqOpts,
|
|
94
|
+
): string {
|
|
95
|
+
const path = `${specsRoot}/${opts.capability}.feature`;
|
|
96
|
+
if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
|
|
97
|
+
if (allReqIds(entries).has(opts.reqId)) {
|
|
98
|
+
throw new AuthoringError(`req id already in use: ${opts.reqId}`);
|
|
99
|
+
}
|
|
100
|
+
assertRuleWording(opts.statement);
|
|
101
|
+
const kw = keywordsOf(io.readText(path));
|
|
102
|
+
const block = `\n @req:${opts.reqId} @human\n ${kw.scenario}: ${opts.title}\n ${opts.statement}\n`;
|
|
103
|
+
io.writeText(path, `${io.readText(path).replace(/\n+$/u, '')}${block}`);
|
|
104
|
+
return path;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface AddScenarioOpts {
|
|
108
|
+
capability: string;
|
|
109
|
+
reqId: string;
|
|
110
|
+
scenarioId: string;
|
|
111
|
+
given?: string;
|
|
112
|
+
when: string;
|
|
113
|
+
thenText: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Append `@req:<id> @executable` acceptance scenario (r42). */
|
|
117
|
+
export function addScenario(
|
|
118
|
+
io: WriteIo,
|
|
119
|
+
specsRoot: string,
|
|
120
|
+
entries: readonly SpecEntryLike[],
|
|
121
|
+
opts: AddScenarioOpts,
|
|
122
|
+
): string {
|
|
123
|
+
const path = `${specsRoot}/${opts.capability}.feature`;
|
|
124
|
+
if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
|
|
125
|
+
if (findReq(entries, opts.reqId) === null) {
|
|
126
|
+
throw new AuthoringError(`req id not found: ${opts.reqId}`);
|
|
127
|
+
}
|
|
128
|
+
const kw = keywordsOf(io.readText(path));
|
|
129
|
+
const givenLine =
|
|
130
|
+
opts.given !== undefined && opts.given !== '' ? ` ${kw.given} ${opts.given}\n` : '';
|
|
131
|
+
const block =
|
|
132
|
+
`\n @req:${opts.reqId} @executable\n ${kw.scenario}: ${opts.scenarioId}\n` +
|
|
133
|
+
`${givenLine} ${kw.when} ${opts.when}\n ${kw.thenText} ${opts.thenText}\n`;
|
|
134
|
+
io.writeText(path, `${io.readText(path).replace(/\n+$/u, '')}${block}`);
|
|
135
|
+
return path;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ResolvedReq {
|
|
139
|
+
reqId: string;
|
|
140
|
+
capability: string;
|
|
141
|
+
title: string;
|
|
142
|
+
statement: string;
|
|
143
|
+
harness: string[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Resolve an rN to capability/statement plus bound harness scenarios (r43). */
|
|
147
|
+
export function resolveReq(entries: readonly SpecEntryLike[], reqId: string): ResolvedReq | null {
|
|
148
|
+
const rule = findReq(entries, reqId);
|
|
149
|
+
if (rule === null) return null;
|
|
150
|
+
const capability =
|
|
151
|
+
rule.entry.doc.header.capability ?? rule.entry.fileName.replace(/\.feature$/u, '');
|
|
152
|
+
const harness: string[] = [];
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
for (const scenario of entry.doc.scenarios) {
|
|
155
|
+
if (scenario.classification === 'executable' && scenario.reqIds.includes(reqId)) {
|
|
156
|
+
harness.push(`${entry.fileName}:${scenario.name}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { reqId, capability, title: rule.scenarioName, statement: rule.statement, harness };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface DedupePlanItem {
|
|
164
|
+
reqId: string;
|
|
165
|
+
keepFile: string;
|
|
166
|
+
remapFile: string;
|
|
167
|
+
newReqId: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Plan (and optionally apply) a re-map of globally duplicated rN ids: the
|
|
172
|
+
* first file keeps the id, later files get the next free id (r43).
|
|
173
|
+
*/
|
|
174
|
+
export function planDedupe(
|
|
175
|
+
entries: readonly SpecEntryLike[],
|
|
176
|
+
io: WriteIo,
|
|
177
|
+
specsRoot: string,
|
|
178
|
+
duplicates: readonly { reqId: string; files: string[] }[],
|
|
179
|
+
): DedupePlanItem[] {
|
|
180
|
+
const used = ruleReqIds(entries);
|
|
181
|
+
let next = 1;
|
|
182
|
+
const fresh = (): string => {
|
|
183
|
+
while (used.has(`r${String(next)}`)) next += 1;
|
|
184
|
+
used.add(`r${String(next)}`);
|
|
185
|
+
return `r${String(next)}`;
|
|
186
|
+
};
|
|
187
|
+
const plan: DedupePlanItem[] = [];
|
|
188
|
+
for (const dup of duplicates) {
|
|
189
|
+
const [keep, ...rest] = dup.files;
|
|
190
|
+
for (const remapFile of rest) {
|
|
191
|
+
const newReqId = fresh();
|
|
192
|
+
if (keep !== undefined) {
|
|
193
|
+
plan.push({ reqId: dup.reqId, keepFile: keep, remapFile, newReqId });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const item of plan) {
|
|
198
|
+
// registry duplicates carry bare file names; accept already-rooted paths too
|
|
199
|
+
const path = item.remapFile.startsWith(`${specsRoot}/`)
|
|
200
|
+
? item.remapFile
|
|
201
|
+
: `${specsRoot}/${item.remapFile}`;
|
|
202
|
+
const content = io.readText(path);
|
|
203
|
+
io.writeText(path, content.replaceAll(`@req:${item.reqId}`, `@req:${item.newReqId}`));
|
|
204
|
+
}
|
|
205
|
+
return plan;
|
|
206
|
+
}
|
package/src/spec/ir.ts
CHANGED
|
@@ -19,7 +19,6 @@ export interface ScenarioIR {
|
|
|
19
19
|
/** `@req:rN` links, normalized to `rN`. */
|
|
20
20
|
reqIds: string[];
|
|
21
21
|
classification: ScenarioClassification;
|
|
22
|
-
manual: boolean;
|
|
23
22
|
/** Rule statement (description lines, trimmed) + step texts for executables. */
|
|
24
23
|
statement: string;
|
|
25
24
|
stepCount: number;
|
package/src/spec/parser.ts
CHANGED
|
@@ -77,20 +77,18 @@ function extractHeader(source: string): CapabilityHeader {
|
|
|
77
77
|
|
|
78
78
|
function classify(tags: string[]): {
|
|
79
79
|
classification: ScenarioIR['classification'];
|
|
80
|
-
manual: boolean;
|
|
81
80
|
errors: SpecStructuralError[];
|
|
82
81
|
} {
|
|
83
82
|
const errors: SpecStructuralError[] = [];
|
|
84
83
|
const has = (t: string): boolean => tags.includes(t);
|
|
85
84
|
const human = has('human');
|
|
86
85
|
const executable = has('executable');
|
|
87
|
-
const manual = has('manual');
|
|
88
86
|
const label = tags.join(',');
|
|
89
87
|
|
|
90
|
-
if (manual
|
|
88
|
+
if (has('manual')) {
|
|
91
89
|
errors.push({
|
|
92
|
-
code: 'tag:manual-
|
|
93
|
-
message: `@manual
|
|
90
|
+
code: 'tag:manual-removed',
|
|
91
|
+
message: `@manual was removed in 0.3.0 — drop the tag (@human already carries the human-judgement semantics) (tags: ${label})`,
|
|
94
92
|
});
|
|
95
93
|
}
|
|
96
94
|
if (human && executable) {
|
|
@@ -104,7 +102,7 @@ function classify(tags: string[]): {
|
|
|
104
102
|
: executable
|
|
105
103
|
? 'executable'
|
|
106
104
|
: 'unclassified';
|
|
107
|
-
return { classification,
|
|
105
|
+
return { classification, errors };
|
|
108
106
|
}
|
|
109
107
|
|
|
110
108
|
/** Parse one capability .feature source into the single-track IR. */
|
|
@@ -165,7 +163,6 @@ export function parseCapability(source: string, fileName = '<inline>'): Capabili
|
|
|
165
163
|
tags,
|
|
166
164
|
reqIds,
|
|
167
165
|
classification: kind.classification,
|
|
168
|
-
manual: kind.manual,
|
|
169
166
|
statement,
|
|
170
167
|
stepCount: stepTexts.length,
|
|
171
168
|
steps,
|