@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.
Files changed (107) 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 +67 -59
  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 +42 -21
  37. package/src/report/specs.ts +23 -25
  38. package/src/review/review.ts +45 -30
  39. package/src/spec/authoring.ts +91 -63
  40. package/src/spec/ir.ts +43 -15
  41. package/src/spec/migrateNative.ts +167 -0
  42. package/src/spec/parser.ts +73 -77
  43. package/src/spec/reqRegistry.ts +8 -9
  44. package/src/templates/embedded.ts +10 -16
  45. package/src/templates/engine.ts +10 -5
  46. package/src/templates/locale.ts +1 -1
  47. package/src/templates/skills.ts +4 -5
  48. package/src/validation/changeCheck.ts +128 -105
  49. package/src/validation/harness.ts +161 -0
  50. package/src/validation/staleness.ts +9 -5
  51. package/src/validation/validate.ts +60 -88
  52. package/templates/en/skills/llman-sdd-apply-cycle.md +20 -28
  53. package/templates/en/skills/llman-sdd-apply.md +58 -76
  54. package/templates/en/skills/llman-sdd-arch-review.md +12 -19
  55. package/templates/en/skills/llman-sdd-archive.md +27 -42
  56. package/templates/en/skills/llman-sdd-continue.md +17 -24
  57. package/templates/en/skills/llman-sdd-draft.md +17 -28
  58. package/templates/en/skills/llman-sdd-explore.md +29 -43
  59. package/templates/en/skills/llman-sdd-ff.md +12 -17
  60. package/templates/en/skills/llman-sdd-graph.md +14 -32
  61. package/templates/en/skills/llman-sdd-propose.md +48 -63
  62. package/templates/en/skills/llman-sdd-quick.md +12 -27
  63. package/templates/en/skills/llman-sdd-research.md +13 -24
  64. package/templates/en/skills/llman-sdd-specs-compact.md +14 -39
  65. package/templates/en/skills/llman-sdd-validate.md +11 -15
  66. package/templates/en/skills/llman-sdd-verify.md +23 -44
  67. package/templates/en/skills/llman-sdd-wayfinder.md +18 -22
  68. package/templates/en/units/skills/cli-footer.md +2 -0
  69. package/templates/en/units/skills/git-native-flow-brief.md +7 -6
  70. package/templates/en/units/skills/git-native-flow.md +21 -11
  71. package/templates/en/units/skills/human-readable-summary.md +2 -3
  72. package/templates/en/units/skills/stage-guard.md +7 -7
  73. package/templates/en/units/skills/structured-protocol.md +5 -8
  74. package/templates/en/units/skills/validation-hints.md +10 -14
  75. package/templates/en/units/spec/feature-contract.md +27 -16
  76. package/templates/en/units/workflow/archive-freeze-guidance.md +6 -3
  77. package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +23 -31
  78. package/templates/zh-Hans/skills/llman-sdd-apply.md +63 -81
  79. package/templates/zh-Hans/skills/llman-sdd-arch-review.md +21 -28
  80. package/templates/zh-Hans/skills/llman-sdd-archive.md +29 -44
  81. package/templates/zh-Hans/skills/llman-sdd-continue.md +17 -24
  82. package/templates/zh-Hans/skills/llman-sdd-draft.md +18 -29
  83. package/templates/zh-Hans/skills/llman-sdd-explore.md +34 -48
  84. package/templates/zh-Hans/skills/llman-sdd-ff.md +13 -18
  85. package/templates/zh-Hans/skills/llman-sdd-graph.md +16 -34
  86. package/templates/zh-Hans/skills/llman-sdd-propose.md +51 -65
  87. package/templates/zh-Hans/skills/llman-sdd-quick.md +15 -30
  88. package/templates/zh-Hans/skills/llman-sdd-research.md +17 -28
  89. package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +15 -40
  90. package/templates/zh-Hans/skills/llman-sdd-validate.md +11 -15
  91. package/templates/zh-Hans/skills/llman-sdd-verify.md +26 -47
  92. package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +25 -29
  93. package/templates/zh-Hans/units/skills/cli-footer.md +2 -0
  94. package/templates/zh-Hans/units/skills/git-native-flow-brief.md +7 -6
  95. package/templates/zh-Hans/units/skills/git-native-flow.md +22 -12
  96. package/templates/zh-Hans/units/skills/human-readable-summary.md +4 -5
  97. package/templates/zh-Hans/units/skills/stage-guard.md +9 -9
  98. package/templates/zh-Hans/units/skills/structured-protocol.md +5 -8
  99. package/templates/zh-Hans/units/skills/validation-hints.md +10 -14
  100. package/templates/zh-Hans/units/spec/feature-contract.md +25 -16
  101. package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +6 -2
  102. package/templates/en/skills/llman-sdd-onboard.md +0 -34
  103. package/templates/en/skills/llman-sdd-show.md +0 -24
  104. package/templates/en/units/migrate-prompt.md +0 -28
  105. package/templates/zh-Hans/skills/llman-sdd-onboard.md +0 -34
  106. package/templates/zh-Hans/skills/llman-sdd-show.md +0 -24
  107. 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,12 @@
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';
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
- function keywordsOf(content: string): {
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 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
- }
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 findReq(
38
+ function findRule(
45
39
  entries: readonly SpecEntryLike[],
46
40
  reqId: string,
47
- ): { entry: SpecEntryLike; scenarioName: string; statement: string } | null {
41
+ ): { entry: SpecEntryLike; rule: RuleIR } | null {
48
42
  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
- }
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
- /** v1 parity: only @human (rule) req ids participate in the dedupe registry. */
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 scenario of entry.doc.scenarios) {
63
- if (scenario.classification === 'human') {
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
- 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;
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
- /** Append `@req:<id> @human` rule scenario to `<capability>.feature` (r41). */
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 = `${specsRoot}/${opts.capability}.feature`;
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 block = `\n @req:${opts.reqId} @human\n ${kw.scenario}: ${opts.title}\n ${opts.statement}\n`;
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
- /** Append `@req:<id> @executable` acceptance scenario (r42). */
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 = `${specsRoot}/${opts.capability}.feature`;
132
+ const path = resolveWriteTarget(io, specsRoot, opts.capability, entries);
124
133
  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}`);
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(io.readText(path));
153
+ const kw = keywordsOf(content);
129
154
  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}`);
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 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 };
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 ScenarioClassification = 'human' | 'executable' | 'unclassified';
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
- /** Tag names without the leading `@`. */
28
+ /** Non-`@req` tags on this scenario (e.g. `@skip`, `@experimental`). */
18
29
  tags: string[];
19
- /** `@req:rN` links, normalized to `rN`. */
20
- reqIds: string[];
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
- /** Executable-scenario steps with their keyword kinds (context-index tree). */
26
- steps: { kind: 'given' | 'when' | 'then'; text: string }[];
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
- scenarios: ScenarioIR[];
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
- * v1 wording: constraint statements must contain one of these tokens.
46
- * Shared by the parser (structural error) and validation (verdict gate) so
47
- * the two MUST-word checks cannot drift apart.
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 const MUST_WORD_RE = /\bMUST\b|\bSHALL\b|必须|不得|禁止/u;
75
+ export function specIdOf(entry: { fileName: string; doc: CapabilityDoc }): string {
76
+ return entry.doc.header.capability ?? entry.fileName.replace(/\.feature$/u, '');
77
+ }