@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.
Files changed (108) hide show
  1. package/package.json +2 -1
  2. package/src/archive/freeze.ts +86 -18
  3. package/src/archive/frozenCard.ts +105 -0
  4. package/src/archive/sevenzip.ts +15 -13
  5. package/src/change/closeOutHarness.ts +29 -0
  6. package/src/change/collect.ts +140 -0
  7. package/src/change/frontmatter.ts +48 -6
  8. package/src/change/id.ts +2 -6
  9. package/src/change/lifecycle.ts +285 -86
  10. package/src/change/nextId.ts +63 -2
  11. package/src/change/resolve.ts +2 -2
  12. package/src/change/tasks.ts +59 -0
  13. package/src/config/changeId.ts +14 -12
  14. package/src/config/load.ts +14 -0
  15. package/src/config/schema.ts +4 -41
  16. package/src/config/surface.ts +6 -36
  17. package/src/context/indexStore.ts +7 -3
  18. package/src/context/retrieve.ts +8 -10
  19. package/src/context/tree.ts +28 -24
  20. package/src/git/spawnGit.ts +90 -2
  21. package/src/index.ts +81 -54
  22. package/src/init/defaultConfig.ts +1 -5
  23. package/src/init/init.ts +19 -4
  24. package/src/ports.ts +1 -7
  25. package/src/project/migrateNotes.ts +104 -0
  26. package/src/render/machine.ts +30 -0
  27. package/src/report/collect.ts +11 -127
  28. package/src/report/graph/analysis.ts +152 -0
  29. package/src/report/graph/deps.ts +30 -0
  30. package/src/report/graph/graphData.ts +53 -0
  31. package/src/report/graph/nodes.ts +130 -0
  32. package/src/report/graph/render.ts +83 -0
  33. package/src/report/graph/types.ts +47 -0
  34. package/src/report/graph.ts +9 -381
  35. package/src/report/show.ts +20 -22
  36. package/src/report/specHelpers.ts +45 -22
  37. package/src/report/specs.ts +23 -25
  38. package/src/review/review.ts +45 -30
  39. package/src/spec/authoring.ts +147 -71
  40. package/src/spec/ir.ts +43 -15
  41. package/src/spec/keywords.ts +147 -0
  42. package/src/spec/migrateNative.ts +201 -0
  43. package/src/spec/parser.ts +95 -83
  44. package/src/spec/reqRegistry.ts +31 -15
  45. package/src/templates/embedded.ts +10 -16
  46. package/src/templates/engine.ts +10 -5
  47. package/src/templates/locale.ts +1 -1
  48. package/src/templates/skills.ts +4 -5
  49. package/src/validation/changeCheck.ts +128 -105
  50. package/src/validation/harness.ts +161 -0
  51. package/src/validation/staleness.ts +9 -5
  52. package/src/validation/validate.ts +60 -88
  53. package/templates/en/skills/llman-sdd-apply-cycle.md +20 -28
  54. package/templates/en/skills/llman-sdd-apply.md +58 -76
  55. package/templates/en/skills/llman-sdd-arch-review.md +12 -19
  56. package/templates/en/skills/llman-sdd-archive.md +27 -42
  57. package/templates/en/skills/llman-sdd-continue.md +17 -24
  58. package/templates/en/skills/llman-sdd-draft.md +17 -28
  59. package/templates/en/skills/llman-sdd-explore.md +29 -43
  60. package/templates/en/skills/llman-sdd-ff.md +12 -17
  61. package/templates/en/skills/llman-sdd-graph.md +14 -32
  62. package/templates/en/skills/llman-sdd-propose.md +48 -63
  63. package/templates/en/skills/llman-sdd-quick.md +12 -27
  64. package/templates/en/skills/llman-sdd-research.md +13 -24
  65. package/templates/en/skills/llman-sdd-specs-compact.md +14 -39
  66. package/templates/en/skills/llman-sdd-validate.md +11 -15
  67. package/templates/en/skills/llman-sdd-verify.md +23 -44
  68. package/templates/en/skills/llman-sdd-wayfinder.md +18 -22
  69. package/templates/en/units/skills/cli-footer.md +2 -0
  70. package/templates/en/units/skills/git-native-flow-brief.md +7 -6
  71. package/templates/en/units/skills/git-native-flow.md +21 -11
  72. package/templates/en/units/skills/human-readable-summary.md +2 -3
  73. package/templates/en/units/skills/stage-guard.md +7 -7
  74. package/templates/en/units/skills/structured-protocol.md +5 -8
  75. package/templates/en/units/skills/validation-hints.md +10 -14
  76. package/templates/en/units/spec/feature-contract.md +27 -16
  77. package/templates/en/units/workflow/archive-freeze-guidance.md +6 -3
  78. package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +23 -31
  79. package/templates/zh-Hans/skills/llman-sdd-apply.md +63 -81
  80. package/templates/zh-Hans/skills/llman-sdd-arch-review.md +21 -28
  81. package/templates/zh-Hans/skills/llman-sdd-archive.md +29 -44
  82. package/templates/zh-Hans/skills/llman-sdd-continue.md +17 -24
  83. package/templates/zh-Hans/skills/llman-sdd-draft.md +18 -29
  84. package/templates/zh-Hans/skills/llman-sdd-explore.md +34 -48
  85. package/templates/zh-Hans/skills/llman-sdd-ff.md +13 -18
  86. package/templates/zh-Hans/skills/llman-sdd-graph.md +16 -34
  87. package/templates/zh-Hans/skills/llman-sdd-propose.md +51 -65
  88. package/templates/zh-Hans/skills/llman-sdd-quick.md +15 -30
  89. package/templates/zh-Hans/skills/llman-sdd-research.md +17 -28
  90. package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +15 -40
  91. package/templates/zh-Hans/skills/llman-sdd-validate.md +11 -15
  92. package/templates/zh-Hans/skills/llman-sdd-verify.md +26 -47
  93. package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +25 -29
  94. package/templates/zh-Hans/units/skills/cli-footer.md +2 -0
  95. package/templates/zh-Hans/units/skills/git-native-flow-brief.md +7 -6
  96. package/templates/zh-Hans/units/skills/git-native-flow.md +22 -12
  97. package/templates/zh-Hans/units/skills/human-readable-summary.md +4 -5
  98. package/templates/zh-Hans/units/skills/stage-guard.md +9 -9
  99. package/templates/zh-Hans/units/skills/structured-protocol.md +5 -8
  100. package/templates/zh-Hans/units/skills/validation-hints.md +10 -14
  101. package/templates/zh-Hans/units/spec/feature-contract.md +25 -16
  102. package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +6 -2
  103. package/templates/en/skills/llman-sdd-onboard.md +0 -34
  104. package/templates/en/skills/llman-sdd-show.md +0 -24
  105. package/templates/en/units/migrate-prompt.md +0 -28
  106. package/templates/zh-Hans/skills/llman-sdd-onboard.md +0 -34
  107. package/templates/zh-Hans/skills/llman-sdd-show.md +0 -24
  108. package/templates/zh-Hans/units/migrate-prompt.md +0 -28
@@ -1,8 +1,11 @@
1
1
  /**
2
- * Specs listing (peripheral-commands capability, r20/r21): morphology counts
3
- * aligned with v1 — rules = @human scenarios; enforced = rules carrying an
4
- * @req link that has an executable acceptance scenario; pending = the rest.
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
- orphanAcceptanceCount: number;
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 — the single source of the enforced/pending two-state rule
31
- * (rules = @human; enforced = @req-linked acceptance coverage; pending = rest). */
32
- export function morphologyOfScenarios(
33
- scenarios: readonly { classification: string; reqIds: readonly string[] }[],
34
- ): SpecMorphology {
35
- const rules = scenarios.filter((s) => s.classification === 'human');
36
- const acceptance = scenarios.filter((s) => s.classification === 'executable');
37
- const acceptanceReqIds = new Set(acceptance.flatMap((s) => [...s.reqIds]));
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: enforced.length,
43
- rulePendingCount: rules.length - enforced.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
- orphanAcceptanceCount: orphan.length,
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.doc.header.capability ?? e.fileName,
58
- title: e.doc.header.capability ?? e.fileName,
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(summaries: readonly SpecSummary[]): string {
85
- return JSON.stringify(summaries, null, 2);
79
+ export function renderSpecsJson(
80
+ summaries: readonly SpecSummary[],
81
+ mode: 'json' | 'compact-json' | 'toon' = 'json',
82
+ ): string {
83
+ return renderMachine(summaries, mode);
86
84
  }
@@ -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 v1 sdd/review.rs observable contract.
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' | 'unbound' | 'stale' | 'locked' | 'validate';
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 (v1 parity). */
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.doc.header.capability ?? entry.fileName;
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
- const rules = entry.doc.scenarios.filter((s) => s.classification === 'human');
65
- const acceptance = entry.doc.scenarios.filter((s) => s.classification === 'executable');
66
- const acceptanceReqIds = new Set(acceptance.flatMap((s) => s.reqIds));
67
- const pending = rules.filter((r) => !r.reqIds.some((id) => acceptanceReqIds.has(id)));
68
- // v1 r5: unbound = orphan acceptance scenarios (no @req link).
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 (v1 evaluate): real base-ref/scope evaluation.
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: process.env.LLMANSPEC_BASE_REF,
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
- // v1 sweep = `validate --all --strict --no-check`: pending tasks escalate a
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
- // v1 parity: `count` is the removed locked-rule-confirmation concept (always
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 sdd change diff <id>\``,
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
- failed.length > 0 ? 'validate --all failed; run `llman sdd validate --all` for details' : 'ok',
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.doc.header.capability ?? e.fileName)) {
132
- for (const kind of ['pending', 'unbound', 'stale'] as const) {
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 v1 shared/review.html template (pure —
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__', new Date().toISOString());
198
+ .replaceAll('__GENERATED__', now.toISOString());
184
199
  }
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Programming-style spec authoring helpers (r41-r43, v1 parity):
3
- * append rules / acceptance scenarios, resolve req ids, dedupe conflicts.
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';
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
- function keywordsOf(content: string): {
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 assertRuleWording(statement: string): void {
37
- if (!RULE_KEYWORDS.some((k) => statement.includes(k))) {
38
- throw new AuthoringError(
39
- `statement must contain a rule keyword (${RULE_KEYWORDS.join('/')}): ${statement}`,
40
- );
41
- }
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 findReq(
46
+ function findRule(
45
47
  entries: readonly SpecEntryLike[],
46
48
  reqId: string,
47
- ): { entry: SpecEntryLike; scenarioName: string; statement: string } | null {
49
+ ): { entry: SpecEntryLike; rule: RuleIR } | null {
48
50
  for (const entry of entries) {
49
- for (const scenario of entry.doc.scenarios) {
50
- if (scenario.reqIds.includes(reqId) && scenario.classification === 'human') {
51
- return { entry, scenarioName: scenario.name, statement: scenario.statement };
52
- }
53
- }
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
- /** v1 parity: only @human (rule) req ids participate in the dedupe registry. */
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 scenario of entry.doc.scenarios) {
63
- if (scenario.classification === 'human') {
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
- const ids = new Set<string>();
73
- for (const entry of entries) {
74
- for (const scenario of entry.doc.scenarios) {
75
- for (const id of scenario.reqIds) ids.add(id);
76
- }
77
- }
78
- return ids;
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
- /** Append `@req:<id> @human` rule scenario to `<capability>.feature` (r41). */
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 = `${specsRoot}/${opts.capability}.feature`;
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 block = `\n @req:${opts.reqId} @human\n ${kw.scenario}: ${opts.title}\n ${opts.statement}\n`;
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
- /** Append `@req:<id> @executable` acceptance scenario (r42). */
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 = `${specsRoot}/${opts.capability}.feature`;
140
+ const path = resolveWriteTarget(io, specsRoot, opts.capability, entries);
124
141
  if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
125
- if (findReq(entries, opts.reqId) === null) {
126
- throw new AuthoringError(`req id not found: ${opts.reqId}`);
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(io.readText(path));
162
+ const kw = keywordsOf(content);
129
163
  const givenLine =
130
- opts.given !== undefined && opts.given !== '' ? ` ${kw.given} ${opts.given}\n` : '';
131
- const block =
132
- `\n @req:${opts.reqId} @executable\n ${kw.scenario}: ${opts.scenarioId}\n` +
133
- `${givenLine} ${kw.when} ${opts.when}\n ${kw.thenText} ${opts.thenText}\n`;
134
- io.writeText(path, `${io.readText(path).replace(/\n+$/u, '')}${block}`);
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 rule = findReq(entries, reqId);
149
- if (rule === null) return null;
150
- const capability =
151
- rule.entry.doc.header.capability ?? rule.entry.fileName.replace(/\.feature$/u, '');
152
- const harness: string[] = [];
153
- for (const entry of entries) {
154
- for (const scenario of entry.doc.scenarios) {
155
- if (scenario.classification === 'executable' && scenario.reqIds.includes(reqId)) {
156
- harness.push(`${entry.fileName}:${scenario.name}`);
157
- }
158
- }
159
- }
160
- return { reqId, capability, title: rule.scenarioName, statement: rule.statement, harness };
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
- * 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).
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 { reqId: string; files: string[] }[],
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
- const [keep, ...rest] = dup.files;
190
- for (const remapFile of rest) {
191
- const newReqId = fresh();
192
- if (keep !== undefined) {
193
- plan.push({ reqId: dup.reqId, keepFile: keep, remapFile, newReqId });
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(path, content.replaceAll(`@req:${item.reqId}`, `@req:${item.newReqId}`));
276
+ io.writeText(
277
+ path,
278
+ replaceNthReqTag(content, item.reqId, item.newReqId, item.occurrenceOrdinal),
279
+ );
204
280
  }
205
281
  return plan;
206
282
  }