@llman-sdd/core 0.3.0 → 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.
- package/package.json +2 -1
- package/src/archive/freeze.ts +86 -18
- package/src/archive/frozenCard.ts +105 -0
- package/src/archive/sevenzip.ts +15 -13
- package/src/change/closeOutHarness.ts +29 -0
- package/src/change/collect.ts +140 -0
- package/src/change/frontmatter.ts +48 -6
- package/src/change/id.ts +2 -6
- package/src/change/lifecycle.ts +285 -86
- package/src/change/nextId.ts +63 -2
- package/src/change/resolve.ts +2 -2
- package/src/change/tasks.ts +59 -0
- package/src/config/changeId.ts +14 -12
- package/src/config/load.ts +14 -0
- package/src/config/schema.ts +4 -41
- package/src/config/surface.ts +6 -36
- package/src/context/indexStore.ts +7 -3
- package/src/context/retrieve.ts +8 -10
- package/src/context/tree.ts +28 -24
- package/src/git/spawnGit.ts +90 -2
- package/src/index.ts +67 -59
- package/src/init/defaultConfig.ts +1 -5
- package/src/init/init.ts +19 -4
- package/src/ports.ts +1 -7
- package/src/project/migrateNotes.ts +104 -0
- package/src/render/machine.ts +30 -0
- package/src/report/collect.ts +11 -127
- package/src/report/graph/analysis.ts +152 -0
- package/src/report/graph/deps.ts +30 -0
- package/src/report/graph/graphData.ts +53 -0
- package/src/report/graph/nodes.ts +130 -0
- package/src/report/graph/render.ts +83 -0
- package/src/report/graph/types.ts +47 -0
- package/src/report/graph.ts +9 -381
- package/src/report/show.ts +20 -22
- package/src/report/specHelpers.ts +42 -21
- package/src/report/specs.ts +23 -25
- package/src/review/review.ts +45 -30
- package/src/spec/authoring.ts +91 -63
- package/src/spec/ir.ts +43 -15
- package/src/spec/migrateNative.ts +167 -0
- package/src/spec/parser.ts +73 -77
- package/src/spec/reqRegistry.ts +8 -9
- package/src/templates/embedded.ts +10 -16
- package/src/templates/engine.ts +10 -5
- package/src/templates/locale.ts +1 -1
- package/src/templates/skills.ts +4 -5
- package/src/validation/changeCheck.ts +128 -105
- package/src/validation/harness.ts +161 -0
- package/src/validation/staleness.ts +9 -5
- package/src/validation/validate.ts +60 -88
- package/templates/en/skills/llman-sdd-apply-cycle.md +20 -28
- package/templates/en/skills/llman-sdd-apply.md +58 -76
- package/templates/en/skills/llman-sdd-arch-review.md +12 -19
- package/templates/en/skills/llman-sdd-archive.md +27 -42
- package/templates/en/skills/llman-sdd-continue.md +17 -24
- package/templates/en/skills/llman-sdd-draft.md +17 -28
- package/templates/en/skills/llman-sdd-explore.md +29 -43
- package/templates/en/skills/llman-sdd-ff.md +12 -17
- package/templates/en/skills/llman-sdd-graph.md +14 -32
- package/templates/en/skills/llman-sdd-propose.md +48 -63
- package/templates/en/skills/llman-sdd-quick.md +12 -27
- package/templates/en/skills/llman-sdd-research.md +13 -24
- package/templates/en/skills/llman-sdd-specs-compact.md +14 -39
- package/templates/en/skills/llman-sdd-validate.md +11 -15
- package/templates/en/skills/llman-sdd-verify.md +23 -44
- package/templates/en/skills/llman-sdd-wayfinder.md +18 -22
- package/templates/en/units/skills/cli-footer.md +2 -0
- package/templates/en/units/skills/git-native-flow-brief.md +7 -6
- package/templates/en/units/skills/git-native-flow.md +21 -11
- package/templates/en/units/skills/human-readable-summary.md +2 -3
- package/templates/en/units/skills/stage-guard.md +7 -7
- package/templates/en/units/skills/structured-protocol.md +5 -8
- package/templates/en/units/skills/validation-hints.md +10 -14
- package/templates/en/units/spec/feature-contract.md +27 -16
- package/templates/en/units/workflow/archive-freeze-guidance.md +6 -3
- package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +23 -31
- package/templates/zh-Hans/skills/llman-sdd-apply.md +63 -81
- package/templates/zh-Hans/skills/llman-sdd-arch-review.md +21 -28
- package/templates/zh-Hans/skills/llman-sdd-archive.md +29 -44
- package/templates/zh-Hans/skills/llman-sdd-continue.md +17 -24
- package/templates/zh-Hans/skills/llman-sdd-draft.md +18 -29
- package/templates/zh-Hans/skills/llman-sdd-explore.md +34 -48
- package/templates/zh-Hans/skills/llman-sdd-ff.md +13 -18
- package/templates/zh-Hans/skills/llman-sdd-graph.md +16 -34
- package/templates/zh-Hans/skills/llman-sdd-propose.md +51 -65
- package/templates/zh-Hans/skills/llman-sdd-quick.md +15 -30
- package/templates/zh-Hans/skills/llman-sdd-research.md +17 -28
- package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +15 -40
- package/templates/zh-Hans/skills/llman-sdd-validate.md +11 -15
- package/templates/zh-Hans/skills/llman-sdd-verify.md +26 -47
- package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +25 -29
- package/templates/zh-Hans/units/skills/cli-footer.md +2 -0
- package/templates/zh-Hans/units/skills/git-native-flow-brief.md +7 -6
- package/templates/zh-Hans/units/skills/git-native-flow.md +22 -12
- package/templates/zh-Hans/units/skills/human-readable-summary.md +4 -5
- package/templates/zh-Hans/units/skills/stage-guard.md +9 -9
- package/templates/zh-Hans/units/skills/structured-protocol.md +5 -8
- package/templates/zh-Hans/units/skills/validation-hints.md +10 -14
- package/templates/zh-Hans/units/spec/feature-contract.md +25 -16
- package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +6 -2
- package/templates/en/skills/llman-sdd-onboard.md +0 -34
- package/templates/en/skills/llman-sdd-show.md +0 -24
- package/templates/en/units/migrate-prompt.md +0 -28
- package/templates/zh-Hans/skills/llman-sdd-onboard.md +0 -34
- package/templates/zh-Hans/skills/llman-sdd-show.md +0 -24
- package/templates/zh-Hans/units/migrate-prompt.md +0 -28
package/src/report/specs.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Specs listing (peripheral-commands capability, r20/r21): morphology counts
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Specs listing (peripheral-commands capability, r20/r21): morphology counts.
|
|
3
|
+
* Native model — rules = `规则:` blocks; enforced = rules with at least one
|
|
4
|
+
* nested executable scenario; pending = bare rules; acceptance = nested
|
|
5
|
+
* scenarios; featureScenarioCount = top-level feature-level examples.
|
|
5
6
|
*/
|
|
7
|
+
import { renderMachine } from '../render/machine.ts';
|
|
8
|
+
import { specIdOf } from '../spec/ir.ts';
|
|
6
9
|
import type { CapabilityDoc } from '../spec/ir.ts';
|
|
7
10
|
import type { SpecEntry } from '../validation/validate.ts';
|
|
8
11
|
import { pad } from './collect.ts';
|
|
@@ -12,7 +15,7 @@ export interface SpecMorphology {
|
|
|
12
15
|
ruleEnforcedCount: number;
|
|
13
16
|
rulePendingCount: number;
|
|
14
17
|
acceptanceCount: number;
|
|
15
|
-
|
|
18
|
+
featureScenarioCount: number;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export interface SpecSummary {
|
|
@@ -27,35 +30,27 @@ export interface SpecSummary {
|
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/** Morphology counts shared by `list --specs`, `show <spec> --json`, and the
|
|
30
|
-
* CLI text render —
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const acceptance = scenarios.filter((s) => s.classification === 'executable');
|
|
37
|
-
const acceptanceReqIds = new Set(acceptance.flatMap((s) => [...s.reqIds]));
|
|
38
|
-
const enforced = rules.filter((r) => r.reqIds.some((id) => acceptanceReqIds.has(id)));
|
|
39
|
-
const orphan = acceptance.filter((s) => s.reqIds.length === 0);
|
|
33
|
+
* CLI text render — rules with executable scenarios are enforced; bare rules
|
|
34
|
+
* without any nested scenario are pending; top-level examples are counted
|
|
35
|
+
* separately with no rule accounting. */
|
|
36
|
+
export function morphologyOf(doc: CapabilityDoc): SpecMorphology {
|
|
37
|
+
const rules = doc.rules;
|
|
38
|
+
const acceptance = rules.flatMap((r) => r.scenarios);
|
|
40
39
|
return {
|
|
41
40
|
ruleCount: rules.length,
|
|
42
|
-
ruleEnforcedCount:
|
|
43
|
-
rulePendingCount: rules.length
|
|
41
|
+
ruleEnforcedCount: rules.filter((r) => r.scenarios.length > 0).length,
|
|
42
|
+
rulePendingCount: rules.filter((r) => r.scenarios.length === 0).length,
|
|
44
43
|
acceptanceCount: acceptance.length,
|
|
45
|
-
|
|
44
|
+
featureScenarioCount: doc.orphans.length,
|
|
46
45
|
};
|
|
47
46
|
}
|
|
48
47
|
|
|
49
|
-
function morphologyOf(doc: CapabilityDoc): SpecMorphology {
|
|
50
|
-
return morphologyOfScenarios(doc.scenarios);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
48
|
export function collectSpecs(entries: readonly SpecEntry[]): SpecSummary[] {
|
|
54
49
|
return entries.map((e) => {
|
|
55
50
|
const morphology = morphologyOf(e.doc);
|
|
56
51
|
return {
|
|
57
|
-
id: e
|
|
58
|
-
title: e
|
|
52
|
+
id: specIdOf(e),
|
|
53
|
+
title: specIdOf(e),
|
|
59
54
|
purpose: e.doc.header.purpose ?? '',
|
|
60
55
|
validScope: (e.doc.header.scope ?? '')
|
|
61
56
|
.split(',')
|
|
@@ -81,6 +76,9 @@ export function renderSpecsList(summaries: readonly SpecSummary[]): string[] {
|
|
|
81
76
|
return lines;
|
|
82
77
|
}
|
|
83
78
|
|
|
84
|
-
export function renderSpecsJson(
|
|
85
|
-
|
|
79
|
+
export function renderSpecsJson(
|
|
80
|
+
summaries: readonly SpecSummary[],
|
|
81
|
+
mode: 'json' | 'compact-json' | 'toon' = 'json',
|
|
82
|
+
): string {
|
|
83
|
+
return renderMachine(summaries, mode);
|
|
86
84
|
}
|
package/src/review/review.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { GitLike } from '../git/spawnGit.ts';
|
|
2
|
+
import { specIdOf } from '../spec/ir.ts';
|
|
2
3
|
import { evaluateStaleness, notApplicableStaleness } from '../validation/staleness.ts';
|
|
3
4
|
/**
|
|
4
5
|
* Review aggregation (review-freeze capability, r23): five-signal review over
|
|
5
|
-
* spec IR + validate sweep. Port of
|
|
6
|
+
* spec IR + validate sweep. Port of predecessor sdd/review.rs observable contract.
|
|
6
7
|
*/
|
|
7
8
|
import { validateAllSpecs, type SpecEntry, type SpecIo } from '../validation/validate.ts';
|
|
8
9
|
|
|
9
|
-
export type ReviewKind = 'pending' | '
|
|
10
|
+
export type ReviewKind = 'pending' | 'stale' | 'locked' | 'validate';
|
|
10
11
|
|
|
11
12
|
export interface ReviewSignal {
|
|
12
13
|
kind: ReviewKind;
|
|
@@ -15,25 +16,20 @@ export interface ReviewSignal {
|
|
|
15
16
|
detail: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
export interface TagBinding {
|
|
19
|
-
kind: 'tags';
|
|
20
|
-
tags: string[];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
19
|
export interface ReviewInput {
|
|
24
20
|
entries: readonly SpecEntry[];
|
|
25
|
-
/** config bdd.bindings (tags sources); null/empty = every acceptance unbound. */
|
|
26
|
-
bindings: readonly TagBinding[] | null;
|
|
27
21
|
/** Active changes currently carrying a branch binding. */
|
|
28
22
|
boundChangeCount: number;
|
|
29
23
|
/** Active change summaries for the strict sweep (pending tasks → FAIL). */
|
|
30
24
|
activeChanges?: readonly { name: string; completedTasks: number; totalTasks: number }[];
|
|
31
25
|
/** Restrict per-capability signals (pending/unbound/stale) to this capability. */
|
|
32
26
|
capability?: string;
|
|
33
|
-
/** git + root for real staleness evaluation (
|
|
27
|
+
/** git + root for real staleness evaluation (predecessor parity). */
|
|
34
28
|
git?: GitLike;
|
|
35
29
|
root?: string;
|
|
36
30
|
specsDir?: string;
|
|
31
|
+
/** predecessor base-ref override env value (LLMANSPEC_BASE_REF), injected by caller. */
|
|
32
|
+
baseRefEnv?: string;
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
export interface ReviewResult {
|
|
@@ -43,6 +39,15 @@ export interface ReviewResult {
|
|
|
43
39
|
exitCode: number;
|
|
44
40
|
}
|
|
45
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Root-relative spec path for staleness (`specRel` contract): repo-root
|
|
44
|
+
* discovery yields `llmanspec/…` paths while bare stems get the specs prefix.
|
|
45
|
+
* Shared with the CLI's spec validate path.
|
|
46
|
+
*/
|
|
47
|
+
export function specRelFor(fileName: string): string {
|
|
48
|
+
return fileName.startsWith('llmanspec/') ? fileName : `llmanspec/specs/${fileName}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
47
52
|
const { entries, boundChangeCount } = input;
|
|
48
53
|
const strictChangeFails = (input.activeChanges ?? []).filter(
|
|
@@ -57,32 +62,29 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
57
62
|
const sorted = [...entries].toSorted((a, b) => a.fileName.localeCompare(b.fileName));
|
|
58
63
|
|
|
59
64
|
for (const entry of sorted) {
|
|
60
|
-
const cap = entry
|
|
65
|
+
const cap = specIdOf(entry);
|
|
61
66
|
// r33: per-capability signals honor the --capability filter; locked and
|
|
62
67
|
// validate stay global regardless.
|
|
63
68
|
if (input.capability !== undefined && cap !== input.capability) continue;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const unbound = acceptance.filter((s) => s.reqIds.length === 0);
|
|
69
|
+
// Native model: rules are `规则:` blocks; a rule without nested scenarios
|
|
70
|
+
// is "bare" (pending — candidate for conversion/compaction). Top-level
|
|
71
|
+
// scenarios outside any rule are plain feature-level examples with no
|
|
72
|
+
// special signal.
|
|
73
|
+
const pending = entry.doc.rules.filter((r) => r.scenarios.length === 0);
|
|
70
74
|
|
|
71
75
|
push('pending', cap, pending.length);
|
|
72
|
-
push('unbound', cap, unbound.length);
|
|
73
76
|
|
|
74
|
-
// staleness (
|
|
77
|
+
// staleness (predecessor evaluate): real base-ref/scope evaluation.
|
|
75
78
|
let staleInfo = notApplicableStaleness();
|
|
76
79
|
let staleCount = 0;
|
|
77
80
|
if (input.git !== undefined && input.root !== undefined) {
|
|
78
|
-
const specRel =
|
|
79
|
-
(entry.fileName.startsWith('llmanspec/') ? '' : 'llmanspec/specs/') + entry.fileName;
|
|
81
|
+
const specRel = specRelFor(entry.fileName);
|
|
80
82
|
const evalResult = evaluateStaleness({
|
|
81
83
|
git: input.git,
|
|
82
84
|
root: input.root,
|
|
83
85
|
specRel,
|
|
84
86
|
scope: entry.doc.header.scope?.split(',').map((x) => x.trim()) ?? [],
|
|
85
|
-
baseRefEnv:
|
|
87
|
+
baseRefEnv: input.baseRefEnv,
|
|
86
88
|
});
|
|
87
89
|
staleInfo = evalResult.info;
|
|
88
90
|
staleCount = staleInfo.status === 'OK' || staleInfo.status === 'NOTAPPLICABLE' ? 0 : 1;
|
|
@@ -91,7 +93,8 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
const failed = sweep.verdicts.filter((v) => !v.ok);
|
|
94
|
-
|
|
96
|
+
const sweepFailedCount = failed.length;
|
|
97
|
+
// predecessor sweep = `validate --all --strict --no-check`: pending tasks escalate a
|
|
95
98
|
// change to FAIL, and that feeds the review critical count.
|
|
96
99
|
for (const c of strictChangeFails) {
|
|
97
100
|
failed.push({
|
|
@@ -107,19 +110,30 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
107
110
|
],
|
|
108
111
|
});
|
|
109
112
|
}
|
|
110
|
-
//
|
|
113
|
+
// predecessor parity: `count` is the removed locked-rule-confirmation concept (always
|
|
111
114
|
// 0 now); the detail text carries the bound-change count separately.
|
|
112
115
|
push(
|
|
113
116
|
'locked',
|
|
114
117
|
'-',
|
|
115
118
|
0,
|
|
116
|
-
`${boundChangeCount} bound change(s); inspect with \`llman
|
|
119
|
+
`${boundChangeCount} bound change(s); inspect with \`llman-sdd change diff <id>\``,
|
|
117
120
|
);
|
|
121
|
+
// detail names the failing source: sweep failures point at `validate --all`;
|
|
122
|
+
// strict-only failures name the offending changes instead of a command that
|
|
123
|
+
// would report green.
|
|
124
|
+
const sweepDetail =
|
|
125
|
+
sweepFailedCount > 0 ? 'validate --all failed; run `llman-sdd validate --all` for details' : '';
|
|
126
|
+
const strictDetail =
|
|
127
|
+
strictChangeFails.length > 0
|
|
128
|
+
? `${strictChangeFails.length} active change(s) with unchecked tasks: ${strictChangeFails
|
|
129
|
+
.map((c) => `${c.name} (${c.totalTasks - c.completedTasks} unchecked)`)
|
|
130
|
+
.join(', ')}`
|
|
131
|
+
: '';
|
|
118
132
|
push(
|
|
119
133
|
'validate',
|
|
120
134
|
'-',
|
|
121
135
|
failed.length,
|
|
122
|
-
|
|
136
|
+
[sweepDetail, strictDetail].filter(Boolean).join('; ') || 'ok',
|
|
123
137
|
);
|
|
124
138
|
|
|
125
139
|
const warningCount = signals
|
|
@@ -128,8 +142,8 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
128
142
|
const criticalCount = failed.length;
|
|
129
143
|
|
|
130
144
|
const lines: string[] = [`Review: critical=${criticalCount} warning=${warningCount}`];
|
|
131
|
-
for (const cap of sorted.map((e) => e
|
|
132
|
-
for (const kind of ['pending', '
|
|
145
|
+
for (const cap of sorted.map((e) => specIdOf(e))) {
|
|
146
|
+
for (const kind of ['pending', 'stale'] as const) {
|
|
133
147
|
const s = signals.find((x) => x.kind === kind && x.capability === cap);
|
|
134
148
|
if (!s) continue;
|
|
135
149
|
lines.push(`${kind}: ${cap} (${s.count})`);
|
|
@@ -151,7 +165,7 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
151
165
|
};
|
|
152
166
|
}
|
|
153
167
|
|
|
154
|
-
/** `review --export-html`: fill the
|
|
168
|
+
/** `review --export-html`: fill the predecessor shared/review.html template (pure —
|
|
155
169
|
* callers read the template; CLI passes it via core's TEMPLATES_ROOT). */
|
|
156
170
|
export function renderReviewHtml(
|
|
157
171
|
template: string,
|
|
@@ -159,6 +173,7 @@ export function renderReviewHtml(
|
|
|
159
173
|
signals: readonly { kind: string; capability: string; count: number; detail: string }[];
|
|
160
174
|
summary: { criticalCount: number; warningCount: number };
|
|
161
175
|
},
|
|
176
|
+
now: Date,
|
|
162
177
|
): string {
|
|
163
178
|
const esc = (input: string): string =>
|
|
164
179
|
input
|
|
@@ -180,5 +195,5 @@ export function renderReviewHtml(
|
|
|
180
195
|
.replaceAll('__WARNING__', String(result.summary.warningCount))
|
|
181
196
|
.replaceAll('__SIGNALS__', JSON.stringify(sigJson))
|
|
182
197
|
.replaceAll('__MERMAID__', mermaid)
|
|
183
|
-
.replaceAll('__GENERATED__',
|
|
198
|
+
.replaceAll('__GENERATED__', now.toISOString());
|
|
184
199
|
}
|
package/src/spec/authoring.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Programming-style spec authoring helpers (r41-r43,
|
|
3
|
-
* append
|
|
2
|
+
* Programming-style spec authoring helpers (r41-r43, native v2):
|
|
3
|
+
* append `规则:` blocks / nested scenarios, resolve req ids, dedupe conflicts.
|
|
4
4
|
* Appends are text-level so existing file content (formatting, comments)
|
|
5
5
|
* stays untouched.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { CapabilityDoc } from './ir.ts';
|
|
9
|
-
|
|
10
|
-
const RULE_KEYWORDS = ['MUST', 'SHALL', '必须', '不得', '禁止'] as const;
|
|
8
|
+
import type { CapabilityDoc, RuleIR } from './ir.ts';
|
|
9
|
+
import { specIdOf } from './ir.ts';
|
|
11
10
|
|
|
12
11
|
export class AuthoringError extends Error {}
|
|
13
12
|
|
|
@@ -22,60 +21,44 @@ export interface WriteIo {
|
|
|
22
21
|
writeText(path: string, content: string): void;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
interface KeywordSet {
|
|
25
|
+
rule: string;
|
|
26
26
|
scenario: string;
|
|
27
27
|
given: string;
|
|
28
28
|
when: string;
|
|
29
29
|
thenText: string;
|
|
30
|
-
} {
|
|
31
|
-
return content.includes('功能:')
|
|
32
|
-
? { scenario: '场景', given: '假如', when: '当', thenText: '那么' }
|
|
33
|
-
: { scenario: 'Scenario', given: 'Given', when: 'When', thenText: 'Then' };
|
|
34
30
|
}
|
|
35
31
|
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
);
|
|
41
|
-
}
|
|
32
|
+
function keywordsOf(content: string): KeywordSet {
|
|
33
|
+
return content.includes('功能:')
|
|
34
|
+
? { rule: '规则', scenario: '场景', given: '假如', when: '当', thenText: '那么' }
|
|
35
|
+
: { rule: 'Rule', scenario: 'Scenario', given: 'Given', when: 'When', thenText: 'Then' };
|
|
42
36
|
}
|
|
43
37
|
|
|
44
|
-
function
|
|
38
|
+
function findRule(
|
|
45
39
|
entries: readonly SpecEntryLike[],
|
|
46
40
|
reqId: string,
|
|
47
|
-
): { entry: SpecEntryLike;
|
|
41
|
+
): { entry: SpecEntryLike; rule: RuleIR } | null {
|
|
48
42
|
for (const entry of entries) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return { entry, scenarioName: scenario.name, statement: scenario.statement };
|
|
52
|
-
}
|
|
53
|
-
}
|
|
43
|
+
const rule = entry.doc.rules.find((r) => r.reqId === reqId);
|
|
44
|
+
if (rule !== undefined) return { entry, rule };
|
|
54
45
|
}
|
|
55
46
|
return null;
|
|
56
47
|
}
|
|
57
48
|
|
|
58
|
-
/**
|
|
49
|
+
/** Requirement handles (`@req` on `规则:` headers) participate in the dedupe registry. */
|
|
59
50
|
export function ruleReqIds(entries: readonly SpecEntryLike[]): Set<string> {
|
|
60
51
|
const ids = new Set<string>();
|
|
61
52
|
for (const entry of entries) {
|
|
62
|
-
for (const
|
|
63
|
-
if (
|
|
64
|
-
for (const id of scenario.reqIds) ids.add(id);
|
|
65
|
-
}
|
|
53
|
+
for (const rule of entry.doc.rules) {
|
|
54
|
+
if (rule.reqId !== '') ids.add(rule.reqId);
|
|
66
55
|
}
|
|
67
56
|
}
|
|
68
57
|
return ids;
|
|
69
58
|
}
|
|
70
59
|
|
|
71
60
|
export function allReqIds(entries: readonly SpecEntryLike[]): Set<string> {
|
|
72
|
-
|
|
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;
|
|
61
|
+
return ruleReqIds(entries);
|
|
79
62
|
}
|
|
80
63
|
|
|
81
64
|
export interface AddReqOpts {
|
|
@@ -85,21 +68,43 @@ export interface AddReqOpts {
|
|
|
85
68
|
statement: string;
|
|
86
69
|
}
|
|
87
70
|
|
|
88
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Single write-target caliber (matches the spec show read side): the flat
|
|
73
|
+
* `specs/<capability>.feature` wins when present, else the discovered entry
|
|
74
|
+
* whose specId equals the capability exactly (covers directory-style
|
|
75
|
+
* `<capability>/<capability>.feature`); the flat path is returned unchanged
|
|
76
|
+
* when neither exists so callers raise their usual not-found error.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveWriteTarget(
|
|
79
|
+
io: WriteIo,
|
|
80
|
+
specsRoot: string,
|
|
81
|
+
capability: string,
|
|
82
|
+
entries: readonly SpecEntryLike[],
|
|
83
|
+
): string {
|
|
84
|
+
const flat = `${specsRoot}/${capability}.feature`;
|
|
85
|
+
if (io.exists(flat)) return flat;
|
|
86
|
+
const hit = entries.find((entry) => specIdOf(entry) === capability);
|
|
87
|
+
return hit !== undefined ? hit.fileName : flat;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Append a `规则:` block (with @req handle) to the resolved target spec (r41). */
|
|
89
91
|
export function addReq(
|
|
90
92
|
io: WriteIo,
|
|
91
93
|
specsRoot: string,
|
|
92
94
|
entries: readonly SpecEntryLike[],
|
|
93
95
|
opts: AddReqOpts,
|
|
94
96
|
): string {
|
|
95
|
-
const path =
|
|
97
|
+
const path = resolveWriteTarget(io, specsRoot, opts.capability, entries);
|
|
96
98
|
if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
|
|
97
99
|
if (allReqIds(entries).has(opts.reqId)) {
|
|
98
100
|
throw new AuthoringError(`req id already in use: ${opts.reqId}`);
|
|
99
101
|
}
|
|
100
|
-
assertRuleWording(opts.statement);
|
|
101
102
|
const kw = keywordsOf(io.readText(path));
|
|
102
|
-
const
|
|
103
|
+
const desc = opts.statement
|
|
104
|
+
.split('\n')
|
|
105
|
+
.map((l) => ` ${l.trim()}`)
|
|
106
|
+
.join('\n');
|
|
107
|
+
const block = `\n @req:${opts.reqId}\n ${kw.rule}: ${opts.title}\n${desc}\n`;
|
|
103
108
|
io.writeText(path, `${io.readText(path).replace(/\n+$/u, '')}${block}`);
|
|
104
109
|
return path;
|
|
105
110
|
}
|
|
@@ -113,25 +118,47 @@ export interface AddScenarioOpts {
|
|
|
113
118
|
thenText: string;
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* Insert a nested `场景:` under the target rule's block (r42). Finds the
|
|
123
|
+
* rule block boundary in the raw text (next 2-space top-level element) and
|
|
124
|
+
* inserts the scenario before it, keeping existing formatting untouched.
|
|
125
|
+
*/
|
|
117
126
|
export function addScenario(
|
|
118
127
|
io: WriteIo,
|
|
119
128
|
specsRoot: string,
|
|
120
129
|
entries: readonly SpecEntryLike[],
|
|
121
130
|
opts: AddScenarioOpts,
|
|
122
131
|
): string {
|
|
123
|
-
const path =
|
|
132
|
+
const path = resolveWriteTarget(io, specsRoot, opts.capability, entries);
|
|
124
133
|
if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
const found = findRule(entries, opts.reqId);
|
|
135
|
+
if (found === null) throw new AuthoringError(`req id not found: ${opts.reqId}`);
|
|
136
|
+
const content = io.readText(path);
|
|
137
|
+
const lines = content.split('\n');
|
|
138
|
+
const tagIdx = lines.findIndex((l) => l.trim() === `@req:${opts.reqId}`);
|
|
139
|
+
if (tagIdx === -1) throw new AuthoringError(`req id not found in file: ${opts.reqId}`);
|
|
140
|
+
// block end = first line after the tag's own `规则:` header at 2-space top-level
|
|
141
|
+
// indent that is a tag line or a `规则:/场景:` keyword line (2-space + keyword
|
|
142
|
+
// immediately). The rule header directly following the tag opens the block, so
|
|
143
|
+
// the scan starts after it — otherwise the header itself is mistaken for the
|
|
144
|
+
// boundary and the scenario is inserted before the `规则:` line (broken output).
|
|
145
|
+
let end = lines.length;
|
|
146
|
+
for (let i = tagIdx + 2; i < lines.length; i++) {
|
|
147
|
+
const l = lines[i] ?? '';
|
|
148
|
+
if (l.startsWith(' @') || /^ (规则|Rule|场景|Scenario|功能|Feature):/u.test(l)) {
|
|
149
|
+
end = i;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
127
152
|
}
|
|
128
|
-
const kw = keywordsOf(
|
|
153
|
+
const kw = keywordsOf(content);
|
|
129
154
|
const givenLine =
|
|
130
|
-
opts.given !== undefined && opts.given !== '' ? `
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
`${givenLine}
|
|
134
|
-
|
|
155
|
+
opts.given !== undefined && opts.given !== '' ? ` ${kw.given} ${opts.given}\n` : '';
|
|
156
|
+
const scenarioBlock =
|
|
157
|
+
` ${kw.scenario}: ${opts.scenarioId}\n` +
|
|
158
|
+
`${givenLine} ${kw.when} ${opts.when}\n ${kw.thenText} ${opts.thenText}\n`;
|
|
159
|
+
// insert with a leading blank separator so the file stays readable
|
|
160
|
+
lines.splice(end, 0, '', scenarioBlock.trimEnd());
|
|
161
|
+
io.writeText(path, lines.join('\n'));
|
|
135
162
|
return path;
|
|
136
163
|
}
|
|
137
164
|
|
|
@@ -145,19 +172,17 @@ export interface ResolvedReq {
|
|
|
145
172
|
|
|
146
173
|
/** Resolve an rN to capability/statement plus bound harness scenarios (r43). */
|
|
147
174
|
export function resolveReq(entries: readonly SpecEntryLike[], reqId: string): ResolvedReq | null {
|
|
148
|
-
const
|
|
149
|
-
if (
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
return { reqId, capability, title: rule.scenarioName, statement: rule.statement, harness };
|
|
175
|
+
const found = findRule(entries, reqId);
|
|
176
|
+
if (found === null) return null;
|
|
177
|
+
const { entry, rule } = found;
|
|
178
|
+
const harness = rule.scenarios.map((s) => `${entry.fileName}:${s.name}`);
|
|
179
|
+
return {
|
|
180
|
+
reqId,
|
|
181
|
+
capability: specIdOf(entry),
|
|
182
|
+
title: rule.title,
|
|
183
|
+
statement: rule.description,
|
|
184
|
+
harness,
|
|
185
|
+
};
|
|
161
186
|
}
|
|
162
187
|
|
|
163
188
|
export interface DedupePlanItem {
|
|
@@ -169,13 +194,15 @@ export interface DedupePlanItem {
|
|
|
169
194
|
|
|
170
195
|
/**
|
|
171
196
|
* 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).
|
|
197
|
+
* first file keeps the id, later files get the next free id (r43). `apply:
|
|
198
|
+
* false` (`--dry-run`) returns the plan without writing anything.
|
|
173
199
|
*/
|
|
174
200
|
export function planDedupe(
|
|
175
201
|
entries: readonly SpecEntryLike[],
|
|
176
202
|
io: WriteIo,
|
|
177
203
|
specsRoot: string,
|
|
178
204
|
duplicates: readonly { reqId: string; files: string[] }[],
|
|
205
|
+
opts: { apply?: boolean } = {},
|
|
179
206
|
): DedupePlanItem[] {
|
|
180
207
|
const used = ruleReqIds(entries);
|
|
181
208
|
let next = 1;
|
|
@@ -194,6 +221,7 @@ export function planDedupe(
|
|
|
194
221
|
}
|
|
195
222
|
}
|
|
196
223
|
}
|
|
224
|
+
if (opts.apply === false) return plan;
|
|
197
225
|
for (const item of plan) {
|
|
198
226
|
// registry duplicates carry bare file names; accept already-rooted paths too
|
|
199
227
|
const path = item.remapFile.startsWith(`${specsRoot}/`)
|
package/src/spec/ir.ts
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* Spec IR (spec-parsing capability): pure data shapes produced by
|
|
3
3
|
* parseCapability() and consumed by validation (Phase 3) and downstream
|
|
4
4
|
* commands. No IO of any kind lives here.
|
|
5
|
+
*
|
|
6
|
+
* Native model (v2): a capability feature is `功能:` → `规则:` blocks (the
|
|
7
|
+
* requirement: title + free-form description + `@req:<id>` handle on the block
|
|
8
|
+
* header) → nested `场景:` (executable GWT examples). Top-level scenarios that
|
|
9
|
+
* are not under any rule are orphans. Legacy tags (@human/@rule/@executable)
|
|
10
|
+
* are inert to parsing; migrate with `spec migrate-native`.
|
|
5
11
|
*/
|
|
6
12
|
|
|
7
13
|
export interface CapabilityHeader {
|
|
@@ -10,24 +16,40 @@ export interface CapabilityHeader {
|
|
|
10
16
|
scope: string | null;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
|
-
export type
|
|
19
|
+
export type ScenarioStepKind = 'given' | 'when' | 'then';
|
|
20
|
+
|
|
21
|
+
export interface ScenarioStep {
|
|
22
|
+
kind: ScenarioStepKind;
|
|
23
|
+
text: string;
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
export interface ScenarioIR {
|
|
16
27
|
name: string;
|
|
17
|
-
/**
|
|
28
|
+
/** Non-`@req` tags on this scenario (e.g. `@skip`, `@experimental`). */
|
|
18
29
|
tags: string[];
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
classification: ScenarioClassification;
|
|
22
|
-
/** Rule statement (description lines, trimmed) + step texts for executables. */
|
|
23
|
-
statement: string;
|
|
30
|
+
/** Whether the runner should execute this scenario (false for @skip etc.). */
|
|
31
|
+
runnable: boolean;
|
|
24
32
|
stepCount: number;
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
steps: ScenarioStep[];
|
|
34
|
+
/** Step-texts joined (retrieval/context surface); empty for a stepless scenario. */
|
|
35
|
+
statement: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RuleIR {
|
|
39
|
+
/** The `@req:<id>` handle on the rule block header (global-registry key). */
|
|
40
|
+
reqId: string;
|
|
41
|
+
/** The `规则:` block title. */
|
|
42
|
+
title: string;
|
|
43
|
+
/** Free-form requirement statement (block description lines, as authored). */
|
|
44
|
+
description: string;
|
|
45
|
+
/** Nested executable examples belonging to this rule. */
|
|
46
|
+
scenarios: ScenarioIR[];
|
|
47
|
+
/** Non-`@req` tags on the rule block header (legacy @human etc. — inert). */
|
|
48
|
+
tags: string[];
|
|
27
49
|
}
|
|
28
50
|
|
|
29
51
|
export interface SpecStructuralError {
|
|
30
|
-
/** Machine-ish anchor, e.g. `missing-header:purpose` or `scenario
|
|
52
|
+
/** Machine-ish anchor, e.g. `missing-header:purpose` or `scenario:样例`. */
|
|
31
53
|
code: string;
|
|
32
54
|
message: string;
|
|
33
55
|
}
|
|
@@ -37,13 +59,19 @@ export interface CapabilityDoc {
|
|
|
37
59
|
header: CapabilityHeader;
|
|
38
60
|
featureName: string;
|
|
39
61
|
language: string;
|
|
40
|
-
|
|
62
|
+
/** Requirement blocks (`规则:`); the canonical rule set. */
|
|
63
|
+
rules: RuleIR[];
|
|
64
|
+
/** Top-level scenarios not enclosed by any rule (orphans). */
|
|
65
|
+
orphans: ScenarioIR[];
|
|
41
66
|
errors: SpecStructuralError[];
|
|
42
67
|
}
|
|
43
68
|
|
|
44
69
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* the
|
|
70
|
+
* Single spec-id caliber (r25) for every consumer that labels a discovered
|
|
71
|
+
* spec entry: the `# capability:` header wins, else the fileName minus the
|
|
72
|
+
* `.feature` suffix. Unifies the former dual caliber (bare `fileName` vs
|
|
73
|
+
* stripped stem) shared by review/context-tree/specs-report/validate paths.
|
|
48
74
|
*/
|
|
49
|
-
export
|
|
75
|
+
export function specIdOf(entry: { fileName: string; doc: CapabilityDoc }): string {
|
|
76
|
+
return entry.doc.header.capability ?? entry.fileName.replace(/\.feature$/u, '');
|
|
77
|
+
}
|