@llman-sdd/core 0.5.0 → 0.6.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/change/closeOutHarness.ts +10 -6
- package/src/config/load.ts +53 -3
- package/src/config/schema.ts +6 -6
- package/src/config/surface.ts +1 -1
- package/src/index.ts +37 -6
- package/src/init/defaultConfig.ts +8 -8
- package/src/init/init.ts +1 -1
- package/src/report/specHelpers.ts +13 -11
- package/src/report/specs.ts +16 -16
- package/src/report/unbound.ts +70 -0
- package/src/review/review.ts +9 -9
- package/src/spec/authoring.ts +67 -19
- package/src/spec/ir.ts +10 -0
- package/src/spec/keywords.ts +147 -0
- package/src/spec/migrateNative.ts +41 -7
- package/src/spec/parser.ts +22 -6
- package/src/spec/reqRegistry.ts +30 -13
- package/src/templates/skills.ts +10 -10
- package/src/validation/harness.ts +12 -12
- package/src/validation/validate.ts +9 -8
- package/templates/en/skills/llman-sdd-apply.md +1 -1
- package/templates/en/skills/llman-sdd-explore.md +1 -1
- package/templates/en/skills/llman-sdd-propose.md +5 -5
- package/templates/en/skills/llman-sdd-validate.md +3 -3
- package/templates/en/skills/llman-sdd-verify.md +6 -6
- package/templates/zh-Hans/skills/llman-sdd-apply.md +1 -1
- package/templates/zh-Hans/skills/llman-sdd-explore.md +1 -1
- package/templates/zh-Hans/skills/llman-sdd-propose.md +5 -5
- package/templates/zh-Hans/skills/llman-sdd-validate.md +4 -4
- package/templates/zh-Hans/skills/llman-sdd-verify.md +6 -6
package/src/spec/authoring.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
import type { CapabilityDoc, RuleIR } from './ir.ts';
|
|
9
9
|
import { specIdOf } from './ir.ts';
|
|
10
|
+
import { BLOCK_KEYWORD_LINE_RE, officialKeywordsOrEn } from './keywords.ts';
|
|
11
|
+
import { sourceDialect } from './parser.ts';
|
|
12
|
+
import type { RegistryDuplicate } from './reqRegistry.ts';
|
|
10
13
|
|
|
11
14
|
export class AuthoringError extends Error {}
|
|
12
15
|
|
|
@@ -30,9 +33,14 @@ interface KeywordSet {
|
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
function keywordsOf(content: string): KeywordSet {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
:
|
|
36
|
+
const kw = officialKeywordsOrEn(sourceDialect(content));
|
|
37
|
+
return {
|
|
38
|
+
rule: kw.rule,
|
|
39
|
+
scenario: kw.scenario,
|
|
40
|
+
given: kw.given,
|
|
41
|
+
when: kw.when,
|
|
42
|
+
thenText: kw.thenText,
|
|
43
|
+
};
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
function findRule(
|
|
@@ -137,15 +145,16 @@ export function addScenario(
|
|
|
137
145
|
const lines = content.split('\n');
|
|
138
146
|
const tagIdx = lines.findIndex((l) => l.trim() === `@req:${opts.reqId}`);
|
|
139
147
|
if (tagIdx === -1) throw new AuthoringError(`req id not found in file: ${opts.reqId}`);
|
|
140
|
-
// block end = first line after the tag's own
|
|
141
|
-
// indent that is a tag line or
|
|
142
|
-
// immediately). The rule header directly
|
|
143
|
-
// the
|
|
144
|
-
//
|
|
148
|
+
// block end = first line after the tag's own rule header at 2-space top-level
|
|
149
|
+
// indent that is a tag line or an official block keyword line (2-space +
|
|
150
|
+
// keyword immediately, any official dialect). The rule header directly
|
|
151
|
+
// following the tag opens the block, so the scan starts after it — otherwise
|
|
152
|
+
// the header itself is mistaken for the boundary and the scenario is
|
|
153
|
+
// inserted before the rule line (broken output).
|
|
145
154
|
let end = lines.length;
|
|
146
155
|
for (let i = tagIdx + 2; i < lines.length; i++) {
|
|
147
156
|
const l = lines[i] ?? '';
|
|
148
|
-
if (l.startsWith(' @') ||
|
|
157
|
+
if (l.startsWith(' @') || BLOCK_KEYWORD_LINE_RE.test(l)) {
|
|
149
158
|
end = i;
|
|
150
159
|
break;
|
|
151
160
|
}
|
|
@@ -187,21 +196,44 @@ export function resolveReq(entries: readonly SpecEntryLike[], reqId: string): Re
|
|
|
187
196
|
|
|
188
197
|
export interface DedupePlanItem {
|
|
189
198
|
reqId: string;
|
|
199
|
+
/** File of the kept (first) occurrence. */
|
|
190
200
|
keepFile: string;
|
|
191
201
|
remapFile: string;
|
|
192
202
|
newReqId: string;
|
|
203
|
+
/** 1-based ordinal of the remapped `@req:<id>` occurrence within remapFile's text. */
|
|
204
|
+
occurrenceOrdinal: number;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Replace the ordinal-th `@req:<id>` tag in raw text. The tag match is
|
|
209
|
+
* boundary-exact (`@req:r1` never matches inside `@req:r10`), and only the
|
|
210
|
+
* target ordinal is rewritten so a kept first occurrence in the same file
|
|
211
|
+
* stays untouched (r43: 按出现顺序首现保留、其余出现逐个重取号).
|
|
212
|
+
*/
|
|
213
|
+
function replaceNthReqTag(
|
|
214
|
+
content: string,
|
|
215
|
+
reqId: string,
|
|
216
|
+
newReqId: string,
|
|
217
|
+
ordinal: number,
|
|
218
|
+
): string {
|
|
219
|
+
const pattern = new RegExp(`@req:${reqId}(?!\\d)`, 'gu');
|
|
220
|
+
let seen = 0;
|
|
221
|
+
return content.replace(pattern, (tag) => {
|
|
222
|
+
seen += 1;
|
|
223
|
+
return seen === ordinal ? `@req:${newReqId}` : tag;
|
|
224
|
+
});
|
|
193
225
|
}
|
|
194
226
|
|
|
195
227
|
/**
|
|
196
|
-
* Plan (and optionally apply) a re-map of globally duplicated rN ids
|
|
197
|
-
*
|
|
198
|
-
* false` (`--dry-run`) returns the plan without writing anything.
|
|
228
|
+
* Plan (and optionally apply) a re-map of globally duplicated rN ids
|
|
229
|
+
* (r43: 同文件内共用或跨文件,按出现顺序首现保留、其余出现逐个重取号).
|
|
230
|
+
* `apply: false` (`--dry-run`) returns the plan without writing anything.
|
|
199
231
|
*/
|
|
200
232
|
export function planDedupe(
|
|
201
233
|
entries: readonly SpecEntryLike[],
|
|
202
234
|
io: WriteIo,
|
|
203
235
|
specsRoot: string,
|
|
204
|
-
duplicates: readonly
|
|
236
|
+
duplicates: readonly RegistryDuplicate[],
|
|
205
237
|
opts: { apply?: boolean } = {},
|
|
206
238
|
): DedupePlanItem[] {
|
|
207
239
|
const used = ruleReqIds(entries);
|
|
@@ -213,12 +245,25 @@ export function planDedupe(
|
|
|
213
245
|
};
|
|
214
246
|
const plan: DedupePlanItem[] = [];
|
|
215
247
|
for (const dup of duplicates) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
248
|
+
// occurrences are in scan order (sorted files, in-file rule order); the
|
|
249
|
+
// per-file ordinal counts every occurrence so kept ones hold their slot
|
|
250
|
+
// in the text and remapped ones target the exact `@req:` tag to rewrite.
|
|
251
|
+
const perFileOrdinal = new Map<string, number>();
|
|
252
|
+
let keepFile: string | null = null;
|
|
253
|
+
for (const occ of dup.occurrences) {
|
|
254
|
+
const ordinal = (perFileOrdinal.get(occ.fileName) ?? 0) + 1;
|
|
255
|
+
perFileOrdinal.set(occ.fileName, ordinal);
|
|
256
|
+
if (keepFile === null) {
|
|
257
|
+
keepFile = occ.fileName;
|
|
258
|
+
continue;
|
|
221
259
|
}
|
|
260
|
+
plan.push({
|
|
261
|
+
reqId: dup.reqId,
|
|
262
|
+
keepFile,
|
|
263
|
+
remapFile: occ.fileName,
|
|
264
|
+
newReqId: fresh(),
|
|
265
|
+
occurrenceOrdinal: ordinal,
|
|
266
|
+
});
|
|
222
267
|
}
|
|
223
268
|
}
|
|
224
269
|
if (opts.apply === false) return plan;
|
|
@@ -228,7 +273,10 @@ export function planDedupe(
|
|
|
228
273
|
? item.remapFile
|
|
229
274
|
: `${specsRoot}/${item.remapFile}`;
|
|
230
275
|
const content = io.readText(path);
|
|
231
|
-
io.writeText(
|
|
276
|
+
io.writeText(
|
|
277
|
+
path,
|
|
278
|
+
replaceNthReqTag(content, item.reqId, item.newReqId, item.occurrenceOrdinal),
|
|
279
|
+
);
|
|
232
280
|
}
|
|
233
281
|
return plan;
|
|
234
282
|
}
|
package/src/spec/ir.ts
CHANGED
|
@@ -66,6 +66,16 @@ export interface CapabilityDoc {
|
|
|
66
66
|
errors: SpecStructuralError[];
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Whether a requirement is bound to real logic — it has at least one runnable
|
|
71
|
+
* nested scenario (not `@skip`/`@experimental` and carrying steps). Single
|
|
72
|
+
* authority for the `bound`/`unbound` definition shared by review's `unbound`
|
|
73
|
+
* signal, validate's aggregate INFO, list/show morphology and `spec unbound`.
|
|
74
|
+
*/
|
|
75
|
+
export function ruleHasRunnableScenario(rule: RuleIR): boolean {
|
|
76
|
+
return rule.scenarios.some((s) => s.runnable && s.stepCount > 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
/**
|
|
70
80
|
* Single spec-id caliber (r25) for every consumer that labels a discovered
|
|
71
81
|
* spec entry: the `# capability:` header wins, else the fileName minus the
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official Gherkin dialect keyword lookup — the vocabulary SSOT is the
|
|
3
|
+
* official table shipped with @cucumber/gherkin (`dialects`, i.e.
|
|
4
|
+
* gherkin-languages.json, 80 dialects). Emitting keywords from anything
|
|
5
|
+
* else risks mixed-dialect output the official parser rejects.
|
|
6
|
+
*
|
|
7
|
+
* Selection policy (deterministic): the official tables list synonyms in an
|
|
8
|
+
* order that reproduces no contract (en `scenario: ["Example", "Scenario"]`,
|
|
9
|
+
* zh-CN `rule: ["Rule", "规则"]`), so position heuristics cannot work. The
|
|
10
|
+
* en and zh-CN contract keywords (r88 — 不得漂移) are pinned explicitly and
|
|
11
|
+
* MUST be members of their official table (runtime-checked, tests pin the
|
|
12
|
+
* bytes); every other dialect takes the first non-star synonym.
|
|
13
|
+
*/
|
|
14
|
+
import { dialects, type Dialect } from '@cucumber/gherkin';
|
|
15
|
+
|
|
16
|
+
export interface GherkinKeywords {
|
|
17
|
+
feature: string;
|
|
18
|
+
rule: string;
|
|
19
|
+
scenario: string;
|
|
20
|
+
given: string;
|
|
21
|
+
when: string;
|
|
22
|
+
thenText: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* GherkinKeywords keys ↔ official Dialect field keys (thenText ↔ then).
|
|
27
|
+
*/
|
|
28
|
+
const TABLE_KEY_PAIRS = [
|
|
29
|
+
['feature', 'feature'],
|
|
30
|
+
['rule', 'rule'],
|
|
31
|
+
['scenario', 'scenario'],
|
|
32
|
+
['given', 'given'],
|
|
33
|
+
['when', 'when'],
|
|
34
|
+
['then', 'thenText'],
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
/** Contract-locked keyword bytes (r88) — membership-checked against the table. */
|
|
38
|
+
const LOCKED_KEYWORDS: Record<string, GherkinKeywords> = {
|
|
39
|
+
en: {
|
|
40
|
+
feature: 'Feature',
|
|
41
|
+
rule: 'Rule',
|
|
42
|
+
scenario: 'Scenario',
|
|
43
|
+
given: 'Given',
|
|
44
|
+
when: 'When',
|
|
45
|
+
thenText: 'Then',
|
|
46
|
+
},
|
|
47
|
+
'zh-CN': {
|
|
48
|
+
feature: '功能',
|
|
49
|
+
rule: '规则',
|
|
50
|
+
scenario: '场景',
|
|
51
|
+
given: '假如',
|
|
52
|
+
when: '当',
|
|
53
|
+
thenText: '那么',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function pickKeyword(entries: readonly string[]): string {
|
|
58
|
+
return entries.map((k) => k.trim()).find((k) => k !== '' && k !== '*') ?? '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function keywordsFromTable(table: Dialect): GherkinKeywords | null {
|
|
62
|
+
const kw: GherkinKeywords = {
|
|
63
|
+
feature: pickKeyword(table.feature),
|
|
64
|
+
rule: pickKeyword(table.rule),
|
|
65
|
+
scenario: pickKeyword(table.scenario),
|
|
66
|
+
given: pickKeyword(table.given),
|
|
67
|
+
when: pickKeyword(table.when),
|
|
68
|
+
thenText: pickKeyword(table.then),
|
|
69
|
+
};
|
|
70
|
+
if (Object.values(kw).some((v) => v === '')) return null;
|
|
71
|
+
return kw;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Official keywords for a gherkin dialect, or null when the language has no table. */
|
|
75
|
+
export function officialKeywords(language: string): GherkinKeywords | null {
|
|
76
|
+
const table = dialects[language];
|
|
77
|
+
if (!table) return null;
|
|
78
|
+
const locked = LOCKED_KEYWORDS[language];
|
|
79
|
+
if (locked === undefined) return keywordsFromTable(table);
|
|
80
|
+
// A locked byte that left the official table would silently drift the
|
|
81
|
+
// dialect — fall through to the table instead of emitting it.
|
|
82
|
+
const fromTable = keywordsFromTable(table);
|
|
83
|
+
if (fromTable === null) return null;
|
|
84
|
+
return TABLE_KEY_PAIRS.every(([tableKey, kwKey]) =>
|
|
85
|
+
table[tableKey].map((k) => k.trim()).includes(locked[kwKey]),
|
|
86
|
+
)
|
|
87
|
+
? locked
|
|
88
|
+
: fromTable;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Official keywords, falling back to the en table for table-less languages. */
|
|
92
|
+
export function officialKeywordsOrEn(language: string): GherkinKeywords {
|
|
93
|
+
return officialKeywords(language) ?? officialKeywords('en') ?? LOCKED_KEYWORDS['en']!;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Official step keyword → kind across every dialect (star-filtered,
|
|
98
|
+
* trimmed). And/But/`*` are absent by construction — callers decide their
|
|
99
|
+
* inheritance. en/zh-CN resolve to the same kinds as the former hand-rolled
|
|
100
|
+
* regexes; other official dialects (fr Soit/Quand/Alors, …) now classify
|
|
101
|
+
* correctly instead of falling into a default.
|
|
102
|
+
*/
|
|
103
|
+
export const STEP_KIND_BY_KEYWORD: ReadonlyMap<string, 'given' | 'when' | 'then'> = (() => {
|
|
104
|
+
const map = new Map<string, 'given' | 'when' | 'then'>();
|
|
105
|
+
for (const table of Object.values(dialects)) {
|
|
106
|
+
for (const kind of ['given', 'when', 'then'] as const) {
|
|
107
|
+
for (const raw of table[kind]) {
|
|
108
|
+
const k = raw.trim();
|
|
109
|
+
if (k !== '' && k !== '*' && !map.has(k)) map.set(k, kind);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return map;
|
|
114
|
+
})();
|
|
115
|
+
|
|
116
|
+
export function stepKeywordToOfficialKind(keyword: string): 'given' | 'when' | 'then' | null {
|
|
117
|
+
return STEP_KIND_BY_KEYWORD.get(keyword.trim()) ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function escapeRegExp(k: string): string {
|
|
121
|
+
return k.replaceAll(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Top-level block keywords across every official dialect (2-space indent +
|
|
126
|
+
* keyword + `:`) — next-block boundary scanning must recognize a block end
|
|
127
|
+
* in any language, not just the four hardcoded ones.
|
|
128
|
+
*/
|
|
129
|
+
export const BLOCK_KEYWORD_LINE_RE: RegExp = new RegExp(
|
|
130
|
+
`^ (?:${[
|
|
131
|
+
...new Set(
|
|
132
|
+
Object.values(dialects)
|
|
133
|
+
.flatMap((table) => [
|
|
134
|
+
...table.feature,
|
|
135
|
+
...table.rule,
|
|
136
|
+
...table.scenario,
|
|
137
|
+
...table.scenarioOutline,
|
|
138
|
+
...table.background,
|
|
139
|
+
])
|
|
140
|
+
.map((k) => k.trim())
|
|
141
|
+
.filter((k) => k !== '' && k !== '*'),
|
|
142
|
+
),
|
|
143
|
+
]
|
|
144
|
+
.map(escapeRegExp)
|
|
145
|
+
.join('|')}):`,
|
|
146
|
+
'u',
|
|
147
|
+
);
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* derived from the tag set the official parser exposes per scenario.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { officialKeywordsOrEn } from './keywords.ts';
|
|
13
|
+
import { parseFeatureSource, sourceDialect } from './parser.ts';
|
|
13
14
|
|
|
14
15
|
export interface MigrateBlock {
|
|
15
16
|
reqIds: string[];
|
|
@@ -22,6 +23,8 @@ export interface MigrateBlock {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface MigrateAnalysis {
|
|
26
|
+
/** Gherkin dialect the source resolved to (en start, zh-CN fallback). */
|
|
27
|
+
language: string;
|
|
25
28
|
blocks: MigrateBlock[];
|
|
26
29
|
}
|
|
27
30
|
|
|
@@ -86,7 +89,7 @@ export function analyzeLegacy(source: string): MigrateAnalysis | { ok: false; me
|
|
|
86
89
|
steps: sc.steps.map((s) => ({ keyword: s.keyword.trim(), text: s.text.trim() })),
|
|
87
90
|
});
|
|
88
91
|
}
|
|
89
|
-
return { blocks };
|
|
92
|
+
return { language: sourceDialect(source), blocks };
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
/** Strip `- ` list markers from a rule-statement line (legacy prose residue). */
|
|
@@ -106,11 +109,20 @@ export function migrateNativeSource(source: string): MigrateResult {
|
|
|
106
109
|
const analysis = analyzeLegacy(source);
|
|
107
110
|
if ('ok' in analysis) return { ok: false, message: analysis.message };
|
|
108
111
|
|
|
109
|
-
const { blocks } = analysis as MigrateAnalysis;
|
|
112
|
+
const { language, blocks } = analysis as MigrateAnalysis;
|
|
110
113
|
if (blocks.length === 0) {
|
|
111
114
|
return { ok: false, message: 'no legacy scenarios found (already native?)' };
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
// Keywords come from the official gherkin dialect table so the output
|
|
118
|
+
// stays parseable in one language — the preamble (`# language:` header,
|
|
119
|
+
// `Feature:`/`功能:` line) is kept verbatim and already matches it. The
|
|
120
|
+
// trailing parse self-check guards the rest.
|
|
121
|
+
const kw = officialKeywordsOrEn(language);
|
|
122
|
+
// The auto-nested acceptance title is a synthesized name, not a keyword —
|
|
123
|
+
// no official source exists, so it keeps its localized map.
|
|
124
|
+
const autoAcceptance = language === 'zh-CN' ? '验收示例' : 'Acceptance example';
|
|
125
|
+
|
|
114
126
|
// preamble: everything before the first top-level tag line (headers,
|
|
115
127
|
// feature line, comments) — cut textually, preserved verbatim.
|
|
116
128
|
const lines = source.split('\n');
|
|
@@ -130,11 +142,21 @@ export function migrateNativeSource(source: string): MigrateResult {
|
|
|
130
142
|
if (!b.isRule) continue;
|
|
131
143
|
rules++;
|
|
132
144
|
out.push(` @req:${b.reqIds[0] ?? ''}`);
|
|
133
|
-
out.push(`
|
|
145
|
+
out.push(` ${kw.rule}: ${b.title}`);
|
|
134
146
|
for (const line of b.descriptionLines) {
|
|
135
147
|
const text = stripBullet(line);
|
|
136
148
|
if (text !== '') out.push(` ${text}`);
|
|
137
149
|
}
|
|
150
|
+
// A legacy rule scenario may carry its own acceptance steps inline
|
|
151
|
+
// (description + steps in one block). They become the rule's first
|
|
152
|
+
// nested scenario — file-order semantics, never dropped.
|
|
153
|
+
if (b.steps.length > 0) {
|
|
154
|
+
out.push('');
|
|
155
|
+
if (b.skip) out.push(' @skip');
|
|
156
|
+
out.push(` ${kw.scenario}: ${autoAcceptance}`);
|
|
157
|
+
for (const s of b.steps) out.push(` ${s.keyword} ${s.text}`);
|
|
158
|
+
scenarios++;
|
|
159
|
+
}
|
|
138
160
|
for (const [ai, a] of blocks.entries()) {
|
|
139
161
|
if (a.isRule || consumed.has(ai)) continue;
|
|
140
162
|
if (!a.reqIds.some((rid) => b.reqIds.includes(rid))) continue;
|
|
@@ -142,7 +164,7 @@ export function migrateNativeSource(source: string): MigrateResult {
|
|
|
142
164
|
scenarios++;
|
|
143
165
|
out.push('');
|
|
144
166
|
if (a.skip) out.push(' @skip');
|
|
145
|
-
out.push(`
|
|
167
|
+
out.push(` ${kw.scenario}: ${a.title}`);
|
|
146
168
|
for (const s of a.steps) out.push(` ${s.keyword} ${s.text}`);
|
|
147
169
|
}
|
|
148
170
|
}
|
|
@@ -156,12 +178,24 @@ export function migrateNativeSource(source: string): MigrateResult {
|
|
|
156
178
|
scenarios++;
|
|
157
179
|
out.push('');
|
|
158
180
|
if (a.skip) out.push(' @skip');
|
|
159
|
-
out.push(`
|
|
181
|
+
out.push(` ${kw.scenario}: ${a.title}`);
|
|
160
182
|
for (const s of a.steps) out.push(` ${s.keyword} ${s.text}`);
|
|
161
183
|
}
|
|
162
184
|
|
|
163
185
|
if (rules === 0) {
|
|
164
186
|
return { ok: false, message: 'no legacy rule scenarios found (@req + @rule/@human)' };
|
|
165
187
|
}
|
|
166
|
-
|
|
188
|
+
|
|
189
|
+
const content = `${out.join('\n')}\n`;
|
|
190
|
+
// Fail closed: never hand back content the official parser rejects — a
|
|
191
|
+
// dialect mismatch would otherwise land on disk as silent corruption.
|
|
192
|
+
try {
|
|
193
|
+
parseFeatureSource(content);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
message: `migrated output failed parse self-check: ${error instanceof Error ? error.message : String(error)}`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, content, rules, scenarios };
|
|
167
201
|
}
|
package/src/spec/parser.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type ScenarioStepKind,
|
|
20
20
|
type SpecStructuralError,
|
|
21
21
|
} from './ir.ts';
|
|
22
|
+
import { stepKeywordToOfficialKind } from './keywords.ts';
|
|
22
23
|
|
|
23
24
|
export class SpecParseError extends Error {}
|
|
24
25
|
|
|
@@ -52,15 +53,30 @@ export function parseFeatureSource(source: string): { doc: GherkinDocument; lang
|
|
|
52
53
|
);
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Unified source-dialect policy (r41/r88): an explicit per-file
|
|
58
|
+
* `# language:` header wins; headerless content is auto-discovered through
|
|
59
|
+
* the official matcher chain (en start, zh-CN fallback); anything still
|
|
60
|
+
* undiscoverable falls back to en. Unknown header names are returned
|
|
61
|
+
* as-is — callers pick vocabulary via officialKeywordsOrEn, and the
|
|
62
|
+
* migration parse self-check fail-closes genuinely broken headers.
|
|
63
|
+
*/
|
|
64
|
+
export function sourceDialect(source: string): string {
|
|
65
|
+
const firstLine = source.split('\n').find((l) => l.trim() !== '');
|
|
66
|
+
const header = firstLine?.match(/^#\s*language:\s*(\S+)\s*$/u)?.[1];
|
|
67
|
+
if (header !== undefined) return header;
|
|
68
|
+
try {
|
|
69
|
+
return parseFeatureSource(source).language;
|
|
70
|
+
} catch {
|
|
71
|
+
return 'en';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
const REQ_TAG_RE = /^@?req:(r\d+)$/u;
|
|
56
76
|
|
|
57
|
-
/** Gherkin keyword
|
|
77
|
+
/** Gherkin keyword → step kind, from the official dialect tables; And/But/* inherit via fallback. */
|
|
58
78
|
function stepKeywordToKind(keyword: string): ScenarioStepKind {
|
|
59
|
-
|
|
60
|
-
if (/^(假如|Given)/iu.test(kw)) return 'given';
|
|
61
|
-
if (/^(当|When)/iu.test(kw)) return 'when';
|
|
62
|
-
if (/^(那么|Then)/iu.test(kw)) return 'then';
|
|
63
|
-
return 'given';
|
|
79
|
+
return stepKeywordToOfficialKind(keyword) ?? 'given';
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
const HEADER_RE = /^#\s*(capability|purpose|scope):\s*(.*)$/u;
|
package/src/spec/reqRegistry.ts
CHANGED
|
@@ -1,36 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Global rN registry (spec-parsing capability): @req:rN ids on `规则:` blocks
|
|
3
|
-
* form a single global namespace across all capability specs
|
|
4
|
-
*
|
|
3
|
+
* form a single global namespace across all capability specs. A duplicate is
|
|
4
|
+
* any id carried by more than one rule — within one file or across files
|
|
5
|
+
* (r10: 按携带该 id 的规则条数 > 1 判定).
|
|
5
6
|
*/
|
|
6
7
|
import type { CapabilityDoc } from './ir.ts';
|
|
7
8
|
|
|
9
|
+
export interface RegistryOccurrence {
|
|
10
|
+
fileName: string;
|
|
11
|
+
/** 0-based index of the rule within its doc (in-file locating). */
|
|
12
|
+
ruleIndex: number;
|
|
13
|
+
title: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
export interface RegistryDuplicate {
|
|
9
17
|
reqId: string;
|
|
18
|
+
/** Unique files referencing the id (sorted, display surface). */
|
|
10
19
|
files: string[];
|
|
20
|
+
/** Every rule occurrence referencing the id, in scan order. */
|
|
21
|
+
occurrences: RegistryOccurrence[];
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
export interface ReqRegistry {
|
|
14
|
-
/** reqId →
|
|
15
|
-
byId: Map<string,
|
|
25
|
+
/** reqId → every rule occurrence referencing it (scan order). */
|
|
26
|
+
byId: Map<string, RegistryOccurrence[]>;
|
|
16
27
|
duplicates: RegistryDuplicate[];
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
export function buildReqRegistry(
|
|
20
31
|
docs: readonly { fileName: string; doc: CapabilityDoc }[],
|
|
21
32
|
): ReqRegistry {
|
|
22
|
-
const byId = new Map<string,
|
|
33
|
+
const byId = new Map<string, RegistryOccurrence[]>();
|
|
23
34
|
for (const { fileName, doc } of docs) {
|
|
24
|
-
|
|
25
|
-
if (rule.reqId === '')
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
byId.set(rule.reqId,
|
|
29
|
-
}
|
|
35
|
+
doc.rules.forEach((rule, ruleIndex) => {
|
|
36
|
+
if (rule.reqId === '') return;
|
|
37
|
+
const occurrences = byId.get(rule.reqId) ?? [];
|
|
38
|
+
occurrences.push({ fileName, ruleIndex, title: rule.title });
|
|
39
|
+
byId.set(rule.reqId, occurrences);
|
|
40
|
+
});
|
|
30
41
|
}
|
|
31
42
|
const duplicates: RegistryDuplicate[] = [];
|
|
32
|
-
for (const [reqId,
|
|
33
|
-
if (
|
|
43
|
+
for (const [reqId, occurrences] of byId) {
|
|
44
|
+
if (occurrences.length > 1) {
|
|
45
|
+
duplicates.push({
|
|
46
|
+
reqId,
|
|
47
|
+
files: [...new Set(occurrences.map((o) => o.fileName))].toSorted(),
|
|
48
|
+
occurrences,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
34
51
|
}
|
|
35
52
|
duplicates.sort((a, b) => a.reqId.localeCompare(b.reqId));
|
|
36
53
|
return { byId, duplicates };
|
package/src/templates/skills.ts
CHANGED
|
@@ -49,10 +49,10 @@ export const ETHICS_KEYS: readonly string[] = [
|
|
|
49
49
|
'ethics.escalation_policy',
|
|
50
50
|
];
|
|
51
51
|
|
|
52
|
-
/** Framework-derived
|
|
53
|
-
export function
|
|
54
|
-
if (
|
|
55
|
-
switch (
|
|
52
|
+
/** Framework-derived check_command (predecessor config.rs effective_run_command). */
|
|
53
|
+
export function effectiveCheckCommand(specs: NonNullable<SddConfig['specs']>): string {
|
|
54
|
+
if (specs.check_command) return specs.check_command;
|
|
55
|
+
switch (specs.framework ?? '') {
|
|
56
56
|
case 'pytest-bdd':
|
|
57
57
|
return 'pytest {feature_dir} -k {feature_name} -v';
|
|
58
58
|
case 'rstest-bdd':
|
|
@@ -62,18 +62,18 @@ export function effectiveRunCommand(bdd: NonNullable<SddConfig['bdd']>): string
|
|
|
62
62
|
case 'behave':
|
|
63
63
|
return 'behave {feature_path}';
|
|
64
64
|
default:
|
|
65
|
-
return "echo 'No
|
|
65
|
+
return "echo 'No check_command configured. Set specs.check_command in config.yaml'";
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/** All-string globals (predecessor BTreeMap<String, String> semantics). */
|
|
70
70
|
export function buildTemplateVars(config: SddConfig, version: string): Record<string, string> {
|
|
71
71
|
const vars: Record<string, string> = { llman_version: version };
|
|
72
|
-
if (config.
|
|
73
|
-
vars['
|
|
74
|
-
vars['
|
|
75
|
-
vars['
|
|
76
|
-
if (config.
|
|
72
|
+
if (config.specs) {
|
|
73
|
+
vars['specs_enabled'] = 'true';
|
|
74
|
+
vars['specs_framework'] = config.specs.framework ?? '';
|
|
75
|
+
vars['specs_check_command'] = effectiveCheckCommand(config.specs);
|
|
76
|
+
if (config.specs.verify_prompt) vars['specs_verify_prompt'] = config.specs.verify_prompt;
|
|
77
77
|
}
|
|
78
78
|
const extras = new Set<string>(config.extra_skills ?? []);
|
|
79
79
|
for (const name of OPTIONAL_SKILL_FILES) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* expands
|
|
2
|
+
* Spec verification check execution for validate (validation capability, r13/r48):
|
|
3
|
+
* expands specs.check_command per capability, executes each expanded command
|
|
4
4
|
* string at most once per validate invocation (batch-once), and maps results
|
|
5
5
|
* to per-capability issues. Pure — the subprocess runs through the injected
|
|
6
6
|
* HarnessRunner; this module never reads the environment or the wall clock
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { ValidationItem } from './validate.ts';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Harness runner port (r13/r48): executes one expanded
|
|
13
|
+
* Harness runner port (r13/r48): executes one expanded specs.check_command
|
|
14
14
|
* string; the CLI adapter owns the subprocess (and the nested-invocation
|
|
15
15
|
* guard env it exports). exitCode is null only when the command could not
|
|
16
16
|
* start (spawnError then carries the reason). Lives here rather than in
|
|
@@ -39,7 +39,7 @@ export interface HarnessGate {
|
|
|
39
39
|
/** LLMAN_SDD_HARNESS_ACTIVE=1 was set by an enclosing validate invocation. */
|
|
40
40
|
nested: boolean;
|
|
41
41
|
check: 'default' | 'on' | 'off';
|
|
42
|
-
/** Undefined when
|
|
42
|
+
/** Undefined when specs.check_command is not configured (non-empty string). */
|
|
43
43
|
runner: HarnessRunner | undefined;
|
|
44
44
|
runCommand: string | null;
|
|
45
45
|
/** Project root — the cwd every harness subprocess runs in. */
|
|
@@ -75,7 +75,7 @@ const OUTPUT_TAIL = 200;
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* Trigger matrix (r13): --no-check skips silently; a nested invocation skips
|
|
78
|
-
* with a per-spec INFO; an explicit --check without a configured
|
|
78
|
+
* with a per-spec INFO; an explicit --check without a configured check_command
|
|
79
79
|
* yields a single INFO on the first spec; otherwise every expanded command
|
|
80
80
|
* executes at most once (cache keyed by the expanded string).
|
|
81
81
|
*/
|
|
@@ -91,7 +91,7 @@ export function runHarnessForSpecs(
|
|
|
91
91
|
{
|
|
92
92
|
level: 'INFO',
|
|
93
93
|
id: target.featurePath,
|
|
94
|
-
message: '
|
|
94
|
+
message: 'spec check skipped: nested invocation',
|
|
95
95
|
},
|
|
96
96
|
]);
|
|
97
97
|
}
|
|
@@ -104,7 +104,7 @@ export function runHarnessForSpecs(
|
|
|
104
104
|
{
|
|
105
105
|
level: 'INFO',
|
|
106
106
|
id: first.featurePath,
|
|
107
|
-
message: '--check has no effect:
|
|
107
|
+
message: '--check has no effect: specs.check_command is not configured',
|
|
108
108
|
},
|
|
109
109
|
]);
|
|
110
110
|
}
|
|
@@ -123,14 +123,14 @@ export function runHarnessForSpecs(
|
|
|
123
123
|
{
|
|
124
124
|
level: 'INFO',
|
|
125
125
|
id: target.featurePath,
|
|
126
|
-
message: `
|
|
126
|
+
message: `spec check passed (cached): ${expanded}`,
|
|
127
127
|
},
|
|
128
128
|
]
|
|
129
129
|
: [
|
|
130
130
|
{
|
|
131
131
|
level: 'ERROR',
|
|
132
132
|
id: target.featurePath,
|
|
133
|
-
message: `
|
|
133
|
+
message: `spec check failed (cached result of ${expanded}): ${cached.failureSummary}`,
|
|
134
134
|
},
|
|
135
135
|
];
|
|
136
136
|
} else {
|
|
@@ -142,13 +142,13 @@ export function runHarnessForSpecs(
|
|
|
142
142
|
const result = gate.runner.run(expanded, gate.cwd);
|
|
143
143
|
const firstError =
|
|
144
144
|
result.spawnError !== undefined
|
|
145
|
-
? `
|
|
145
|
+
? `spec check could not start: ${expanded}: ${result.spawnError}`
|
|
146
146
|
: result.exitCode === 0
|
|
147
147
|
? null
|
|
148
|
-
: `
|
|
148
|
+
: `spec check failed (exit ${result.exitCode}): ${expanded}: ${result.output.slice(-OUTPUT_TAIL)}`;
|
|
149
149
|
issues =
|
|
150
150
|
firstError === null
|
|
151
|
-
? [{ level: 'INFO', id: target.featurePath, message: `
|
|
151
|
+
? [{ level: 'INFO', id: target.featurePath, message: `spec check passed: ${expanded}` }]
|
|
152
152
|
: [{ level: 'ERROR', id: target.featurePath, message: firstError }];
|
|
153
153
|
cache.set(expanded, {
|
|
154
154
|
success: firstError === null,
|