@llman-sdd/core 0.3.1 → 0.5.1
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 +81 -54
- 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 +45 -22
- package/src/report/specs.ts +23 -25
- package/src/review/review.ts +45 -30
- package/src/spec/authoring.ts +147 -71
- package/src/spec/ir.ts +43 -15
- package/src/spec/keywords.ts +147 -0
- package/src/spec/migrateNative.ts +201 -0
- package/src/spec/parser.ts +95 -83
- package/src/spec/reqRegistry.ts +31 -15
- 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,15 @@
|
|
|
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
|
-
|
|
8
|
+
import type { CapabilityDoc, RuleIR } from './ir.ts';
|
|
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';
|
|
11
13
|
|
|
12
14
|
export class AuthoringError extends Error {}
|
|
13
15
|
|
|
@@ -22,60 +24,49 @@ export interface WriteIo {
|
|
|
22
24
|
writeText(path: string, content: string): void;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
interface KeywordSet {
|
|
28
|
+
rule: string;
|
|
26
29
|
scenario: string;
|
|
27
30
|
given: string;
|
|
28
31
|
when: string;
|
|
29
32
|
thenText: string;
|
|
30
|
-
} {
|
|
31
|
-
return content.includes('功能:')
|
|
32
|
-
? { scenario: '场景', given: '假如', when: '当', thenText: '那么' }
|
|
33
|
-
: { scenario: 'Scenario', given: 'Given', when: 'When', thenText: 'Then' };
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
function keywordsOf(content: string): KeywordSet {
|
|
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
|
+
};
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
function
|
|
46
|
+
function findRule(
|
|
45
47
|
entries: readonly SpecEntryLike[],
|
|
46
48
|
reqId: string,
|
|
47
|
-
): { entry: SpecEntryLike;
|
|
49
|
+
): { entry: SpecEntryLike; rule: RuleIR } | null {
|
|
48
50
|
for (const entry of entries) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return { entry, scenarioName: scenario.name, statement: scenario.statement };
|
|
52
|
-
}
|
|
53
|
-
}
|
|
51
|
+
const rule = entry.doc.rules.find((r) => r.reqId === reqId);
|
|
52
|
+
if (rule !== undefined) return { entry, rule };
|
|
54
53
|
}
|
|
55
54
|
return null;
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
/**
|
|
57
|
+
/** Requirement handles (`@req` on `规则:` headers) participate in the dedupe registry. */
|
|
59
58
|
export function ruleReqIds(entries: readonly SpecEntryLike[]): Set<string> {
|
|
60
59
|
const ids = new Set<string>();
|
|
61
60
|
for (const entry of entries) {
|
|
62
|
-
for (const
|
|
63
|
-
if (
|
|
64
|
-
for (const id of scenario.reqIds) ids.add(id);
|
|
65
|
-
}
|
|
61
|
+
for (const rule of entry.doc.rules) {
|
|
62
|
+
if (rule.reqId !== '') ids.add(rule.reqId);
|
|
66
63
|
}
|
|
67
64
|
}
|
|
68
65
|
return ids;
|
|
69
66
|
}
|
|
70
67
|
|
|
71
68
|
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;
|
|
69
|
+
return ruleReqIds(entries);
|
|
79
70
|
}
|
|
80
71
|
|
|
81
72
|
export interface AddReqOpts {
|
|
@@ -85,21 +76,43 @@ export interface AddReqOpts {
|
|
|
85
76
|
statement: string;
|
|
86
77
|
}
|
|
87
78
|
|
|
88
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Single write-target caliber (matches the spec show read side): the flat
|
|
81
|
+
* `specs/<capability>.feature` wins when present, else the discovered entry
|
|
82
|
+
* whose specId equals the capability exactly (covers directory-style
|
|
83
|
+
* `<capability>/<capability>.feature`); the flat path is returned unchanged
|
|
84
|
+
* when neither exists so callers raise their usual not-found error.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveWriteTarget(
|
|
87
|
+
io: WriteIo,
|
|
88
|
+
specsRoot: string,
|
|
89
|
+
capability: string,
|
|
90
|
+
entries: readonly SpecEntryLike[],
|
|
91
|
+
): string {
|
|
92
|
+
const flat = `${specsRoot}/${capability}.feature`;
|
|
93
|
+
if (io.exists(flat)) return flat;
|
|
94
|
+
const hit = entries.find((entry) => specIdOf(entry) === capability);
|
|
95
|
+
return hit !== undefined ? hit.fileName : flat;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Append a `规则:` block (with @req handle) to the resolved target spec (r41). */
|
|
89
99
|
export function addReq(
|
|
90
100
|
io: WriteIo,
|
|
91
101
|
specsRoot: string,
|
|
92
102
|
entries: readonly SpecEntryLike[],
|
|
93
103
|
opts: AddReqOpts,
|
|
94
104
|
): string {
|
|
95
|
-
const path =
|
|
105
|
+
const path = resolveWriteTarget(io, specsRoot, opts.capability, entries);
|
|
96
106
|
if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
|
|
97
107
|
if (allReqIds(entries).has(opts.reqId)) {
|
|
98
108
|
throw new AuthoringError(`req id already in use: ${opts.reqId}`);
|
|
99
109
|
}
|
|
100
|
-
assertRuleWording(opts.statement);
|
|
101
110
|
const kw = keywordsOf(io.readText(path));
|
|
102
|
-
const
|
|
111
|
+
const desc = opts.statement
|
|
112
|
+
.split('\n')
|
|
113
|
+
.map((l) => ` ${l.trim()}`)
|
|
114
|
+
.join('\n');
|
|
115
|
+
const block = `\n @req:${opts.reqId}\n ${kw.rule}: ${opts.title}\n${desc}\n`;
|
|
103
116
|
io.writeText(path, `${io.readText(path).replace(/\n+$/u, '')}${block}`);
|
|
104
117
|
return path;
|
|
105
118
|
}
|
|
@@ -113,25 +126,48 @@ export interface AddScenarioOpts {
|
|
|
113
126
|
thenText: string;
|
|
114
127
|
}
|
|
115
128
|
|
|
116
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Insert a nested `场景:` under the target rule's block (r42). Finds the
|
|
131
|
+
* rule block boundary in the raw text (next 2-space top-level element) and
|
|
132
|
+
* inserts the scenario before it, keeping existing formatting untouched.
|
|
133
|
+
*/
|
|
117
134
|
export function addScenario(
|
|
118
135
|
io: WriteIo,
|
|
119
136
|
specsRoot: string,
|
|
120
137
|
entries: readonly SpecEntryLike[],
|
|
121
138
|
opts: AddScenarioOpts,
|
|
122
139
|
): string {
|
|
123
|
-
const path =
|
|
140
|
+
const path = resolveWriteTarget(io, specsRoot, opts.capability, entries);
|
|
124
141
|
if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
|
|
125
|
-
|
|
126
|
-
|
|
142
|
+
const found = findRule(entries, opts.reqId);
|
|
143
|
+
if (found === null) throw new AuthoringError(`req id not found: ${opts.reqId}`);
|
|
144
|
+
const content = io.readText(path);
|
|
145
|
+
const lines = content.split('\n');
|
|
146
|
+
const tagIdx = lines.findIndex((l) => l.trim() === `@req:${opts.reqId}`);
|
|
147
|
+
if (tagIdx === -1) throw new AuthoringError(`req id not found in file: ${opts.reqId}`);
|
|
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).
|
|
154
|
+
let end = lines.length;
|
|
155
|
+
for (let i = tagIdx + 2; i < lines.length; i++) {
|
|
156
|
+
const l = lines[i] ?? '';
|
|
157
|
+
if (l.startsWith(' @') || BLOCK_KEYWORD_LINE_RE.test(l)) {
|
|
158
|
+
end = i;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
127
161
|
}
|
|
128
|
-
const kw = keywordsOf(
|
|
162
|
+
const kw = keywordsOf(content);
|
|
129
163
|
const givenLine =
|
|
130
|
-
opts.given !== undefined && opts.given !== '' ? `
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
`${givenLine}
|
|
134
|
-
|
|
164
|
+
opts.given !== undefined && opts.given !== '' ? ` ${kw.given} ${opts.given}\n` : '';
|
|
165
|
+
const scenarioBlock =
|
|
166
|
+
` ${kw.scenario}: ${opts.scenarioId}\n` +
|
|
167
|
+
`${givenLine} ${kw.when} ${opts.when}\n ${kw.thenText} ${opts.thenText}\n`;
|
|
168
|
+
// insert with a leading blank separator so the file stays readable
|
|
169
|
+
lines.splice(end, 0, '', scenarioBlock.trimEnd());
|
|
170
|
+
io.writeText(path, lines.join('\n'));
|
|
135
171
|
return path;
|
|
136
172
|
}
|
|
137
173
|
|
|
@@ -145,37 +181,60 @@ export interface ResolvedReq {
|
|
|
145
181
|
|
|
146
182
|
/** Resolve an rN to capability/statement plus bound harness scenarios (r43). */
|
|
147
183
|
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 };
|
|
184
|
+
const found = findRule(entries, reqId);
|
|
185
|
+
if (found === null) return null;
|
|
186
|
+
const { entry, rule } = found;
|
|
187
|
+
const harness = rule.scenarios.map((s) => `${entry.fileName}:${s.name}`);
|
|
188
|
+
return {
|
|
189
|
+
reqId,
|
|
190
|
+
capability: specIdOf(entry),
|
|
191
|
+
title: rule.title,
|
|
192
|
+
statement: rule.description,
|
|
193
|
+
harness,
|
|
194
|
+
};
|
|
161
195
|
}
|
|
162
196
|
|
|
163
197
|
export interface DedupePlanItem {
|
|
164
198
|
reqId: string;
|
|
199
|
+
/** File of the kept (first) occurrence. */
|
|
165
200
|
keepFile: string;
|
|
166
201
|
remapFile: string;
|
|
167
202
|
newReqId: string;
|
|
203
|
+
/** 1-based ordinal of the remapped `@req:<id>` occurrence within remapFile's text. */
|
|
204
|
+
occurrenceOrdinal: number;
|
|
168
205
|
}
|
|
169
206
|
|
|
170
207
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
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.
|
|
173
231
|
*/
|
|
174
232
|
export function planDedupe(
|
|
175
233
|
entries: readonly SpecEntryLike[],
|
|
176
234
|
io: WriteIo,
|
|
177
235
|
specsRoot: string,
|
|
178
|
-
duplicates: readonly
|
|
236
|
+
duplicates: readonly RegistryDuplicate[],
|
|
237
|
+
opts: { apply?: boolean } = {},
|
|
179
238
|
): DedupePlanItem[] {
|
|
180
239
|
const used = ruleReqIds(entries);
|
|
181
240
|
let next = 1;
|
|
@@ -186,21 +245,38 @@ export function planDedupe(
|
|
|
186
245
|
};
|
|
187
246
|
const plan: DedupePlanItem[] = [];
|
|
188
247
|
for (const dup of duplicates) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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;
|
|
194
259
|
}
|
|
260
|
+
plan.push({
|
|
261
|
+
reqId: dup.reqId,
|
|
262
|
+
keepFile,
|
|
263
|
+
remapFile: occ.fileName,
|
|
264
|
+
newReqId: fresh(),
|
|
265
|
+
occurrenceOrdinal: ordinal,
|
|
266
|
+
});
|
|
195
267
|
}
|
|
196
268
|
}
|
|
269
|
+
if (opts.apply === false) return plan;
|
|
197
270
|
for (const item of plan) {
|
|
198
271
|
// registry duplicates carry bare file names; accept already-rooted paths too
|
|
199
272
|
const path = item.remapFile.startsWith(`${specsRoot}/`)
|
|
200
273
|
? item.remapFile
|
|
201
274
|
: `${specsRoot}/${item.remapFile}`;
|
|
202
275
|
const content = io.readText(path);
|
|
203
|
-
io.writeText(
|
|
276
|
+
io.writeText(
|
|
277
|
+
path,
|
|
278
|
+
replaceNthReqTag(content, item.reqId, item.newReqId, item.occurrenceOrdinal),
|
|
279
|
+
);
|
|
204
280
|
}
|
|
205
281
|
return plan;
|
|
206
282
|
}
|