@llman-sdd/core 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llman-sdd/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Pure domain logic for llman-sdd (config, gherkin specs, validation, templates)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,29 +1,33 @@
1
1
  /**
2
- * Whether a close-out (finalize / archive) must run bdd.run_command before
2
+ * Whether a close-out (finalize / archive) must run specs.check_command before
3
3
  * any merge or rename. Pure: the CLI supplies the flags, the spec scan, and
4
4
  * the nested-invocation bit.
5
5
  */
6
6
 
7
7
  export type CloseOutHarnessDecision =
8
- | { kind: 'skip'; announce: boolean }
8
+ | { kind: 'skip'; announce: boolean; warning?: string }
9
9
  | { kind: 'abort'; message: string }
10
10
  | { kind: 'run'; command: string };
11
11
 
12
12
  export function decideCloseOutHarness(input: {
13
13
  noCheck: boolean;
14
14
  needsSpecsChange: boolean;
15
- hasExecutable: boolean;
16
15
  runCommand: string | null;
17
16
  nested: boolean;
18
17
  }): CloseOutHarnessDecision {
19
18
  if (input.noCheck) return { kind: 'skip', announce: true };
20
- if (!input.needsSpecsChange || !input.hasExecutable) return { kind: 'skip', announce: false };
19
+ if (!input.needsSpecsChange) return { kind: 'skip', announce: false };
21
20
  const command = input.runCommand?.trim() ?? '';
22
21
  if (command === '') {
23
- return { kind: 'abort', message: 'executable scenarios have no bdd.run_command' };
22
+ return {
23
+ kind: 'skip',
24
+ announce: false,
25
+ warning:
26
+ 'specs.check_command is not configured — close-out skips spec verification. Configure it to gate close-out (see migrations/v0.5-v0.6/README.md).',
27
+ };
24
28
  }
25
29
  if (input.nested) {
26
- return { kind: 'abort', message: 'bdd harness skipped: nested invocation' };
30
+ return { kind: 'abort', message: 'spec check skipped: nested invocation' };
27
31
  }
28
32
  return { kind: 'run', command };
29
33
  }
@@ -28,7 +28,52 @@ export class ConfigValidationError extends Error {
28
28
  }
29
29
  }
30
30
 
31
- export function loadConfig(source: string): SddConfig {
31
+ /**
32
+ * Legacy `bdd:` section elevation (specs-check-config-and-unbound-feed):
33
+ * a present `bdd` block is recognised at load time and elevated onto the new
34
+ * `specs` semantics (`run_command` → `check_command`, `framework`/`verify_prompt`
35
+ * carried under the same names). The new-form `specs` block wins on key conflicts;
36
+ * the legacy block only fills gaps. Any undeclared legacy subkeys (bindings,
37
+ * default_language, feature_dir …) are dropped. Pure — never mutates the input;
38
+ * the caller decides how to surface the migration warning.
39
+ */
40
+ export function elevateLegacyBdd(data: unknown): { data: unknown; legacyBddElevated: boolean } {
41
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
42
+ return { data, legacyBddElevated: false };
43
+ }
44
+ const rec: Record<string, unknown> = { ...(data as Record<string, unknown>) };
45
+ const bdd = rec['bdd'];
46
+ if (bdd === undefined || bdd === null || typeof bdd !== 'object' || Array.isArray(bdd)) {
47
+ return { data, legacyBddElevated: false };
48
+ }
49
+ const legacy = bdd as Record<string, unknown>;
50
+ const specs =
51
+ typeof rec['specs'] === 'object' && rec['specs'] !== null && !Array.isArray(rec['specs'])
52
+ ? (rec['specs'] as Record<string, unknown>)
53
+ : {};
54
+ const merged: Record<string, unknown> = { ...specs };
55
+ if (merged['check_command'] === undefined && typeof legacy['run_command'] === 'string') {
56
+ merged['check_command'] = legacy['run_command'];
57
+ }
58
+ if (merged['framework'] === undefined && typeof legacy['framework'] === 'string') {
59
+ merged['framework'] = legacy['framework'];
60
+ }
61
+ if (merged['verify_prompt'] === undefined && typeof legacy['verify_prompt'] === 'string') {
62
+ merged['verify_prompt'] = legacy['verify_prompt'];
63
+ }
64
+ delete rec['bdd'];
65
+ rec['specs'] = merged;
66
+ return { data: rec, legacyBddElevated: true };
67
+ }
68
+
69
+ /**
70
+ * Load + validate, returning the parsed config and whether a legacy `bdd:`
71
+ * section was elevated. The CLI surfaces the migration warning from the flag.
72
+ */
73
+ export function loadConfigDetail(source: string): {
74
+ config: SddConfig;
75
+ legacyBddElevated: boolean;
76
+ } {
32
77
  let data: unknown;
33
78
  try {
34
79
  data = parse(source);
@@ -37,7 +82,8 @@ export function loadConfig(source: string): SddConfig {
37
82
  `YAML parse error: ${error instanceof Error ? error.message : String(error)}`,
38
83
  ]);
39
84
  }
40
- const result = sddConfigSchema.safeParse(data);
85
+ const elevated = elevateLegacyBdd(data);
86
+ const result = sddConfigSchema.safeParse(elevated.data);
41
87
  if (!result.success) {
42
88
  const issues = result.error.issues.map((iss) => {
43
89
  const path = iss.path.map(String).join('/');
@@ -58,5 +104,9 @@ export function loadConfig(source: string): SddConfig {
58
104
  throw new ConfigValidationError([(error as Error).message]);
59
105
  }
60
106
  }
61
- return result.data;
107
+ return { config: result.data, legacyBddElevated: elevated.legacyBddElevated };
108
+ }
109
+
110
+ export function loadConfig(source: string): SddConfig {
111
+ return loadConfigDetail(source).config;
62
112
  }
@@ -15,18 +15,18 @@ export const EXTRA_SKILLS = [
15
15
  'llman-sdd-research',
16
16
  ] as const;
17
17
 
18
- export const bddSchema = z.object({
18
+ export const specsSchema = z.object({
19
19
  framework: z
20
20
  .string()
21
21
  .default('')
22
22
  .describe(
23
- 'BDD framework identifier (optional). Only used to derive a default run_command when run_command is unset.',
23
+ 'BDD framework identifier (optional). Only used to derive a default check_command when check_command is unset.',
24
24
  ),
25
- run_command: z
25
+ check_command: z
26
26
  .string()
27
27
  .nullish()
28
28
  .describe(
29
- 'Harness command executed by validate for spec targets (skip with --no-check). Placeholders: {feature_path}, {feature_dir}, {feature_name}; without placeholders it runs once per validate invocation (batch-once).',
29
+ 'Spec verification command executed by validate for spec targets (skip with --no-check). Placeholders: {feature_path}, {feature_dir}, {feature_name}; without placeholders it runs once per validate invocation (batch-once). Legacy `bdd.run_command` is elevated onto this key at load time.',
30
30
  ),
31
31
  verify_prompt: z.string().nullish().describe('Extra prompt text injected during verify phase.'),
32
32
  });
@@ -82,10 +82,10 @@ export const sddConfigSchema = z.object({
82
82
  archive: archiveSchema
83
83
  .nullish()
84
84
  .describe('Archive behaviour settings (defer tracking, completion gates).'),
85
- bdd: bddSchema
85
+ specs: specsSchema
86
86
  .nullish()
87
87
  .describe(
88
- 'BDD integration settings. When defined, enables feature-as-spec mode and BDD-aware verify prompts.',
88
+ 'Spec verification integration settings. When defined, enables feature-as-spec mode and verification-aware verify prompts. Legacy `bdd` section is elevated onto this field at load time.',
89
89
  ),
90
90
  sdd: sddSchema
91
91
  .nullish()
@@ -18,7 +18,7 @@ export function renderConfigOverview(source: string): string[] {
18
18
  `schema: ${config.schema}`,
19
19
  `locale: ${config.locale}`,
20
20
  `extra_skills (enabled/total): ${enabled} / ${EXTRA_SKILLS.length}`,
21
- `bdd: ${config.bdd ? 'on' : 'off'}`,
21
+ `specs: ${config.specs ? 'on' : 'off'}`,
22
22
  `archive: ${archiveConfigured ? 'configured' : 'default'}`,
23
23
  ];
24
24
  }
package/src/index.ts CHANGED
@@ -3,14 +3,20 @@ export * from './ports.ts';
3
3
  export {
4
4
  EXTRA_SKILLS,
5
5
  archiveSchema,
6
- bddSchema,
7
6
  changeIdSchema,
8
7
  sddConfigSchema,
8
+ specsSchema,
9
9
  sddSchema,
10
10
  type SddConfig,
11
11
  type SddConfigInput,
12
12
  } from './config/schema.ts';
13
- export { ConfigValidationError, MAX_REPORTED_ISSUES, loadConfig } from './config/load.ts';
13
+ export {
14
+ ConfigValidationError,
15
+ MAX_REPORTED_ISSUES,
16
+ elevateLegacyBdd,
17
+ loadConfig,
18
+ loadConfigDetail,
19
+ } from './config/load.ts';
14
20
  export {
15
21
  ChangeIdError,
16
22
  compileChangeIdPattern,
@@ -27,14 +33,28 @@ export type {
27
33
  ScenarioStep,
28
34
  SpecStructuralError,
29
35
  } from './spec/ir.ts';
30
- export { specIdOf } from './spec/ir.ts';
36
+ export { ruleHasRunnableScenario, specIdOf } from './spec/ir.ts';
31
37
  export {
32
38
  SpecParseError,
33
39
  localeToGherkinLang,
34
40
  parseCapability,
35
41
  parseFeatureSource,
42
+ sourceDialect,
36
43
  } from './spec/parser.ts';
37
- export { buildReqRegistry, type ReqRegistry, type RegistryDuplicate } from './spec/reqRegistry.ts';
44
+ export {
45
+ BLOCK_KEYWORD_LINE_RE,
46
+ officialKeywords,
47
+ officialKeywordsOrEn,
48
+ stepKeywordToOfficialKind,
49
+ STEP_KIND_BY_KEYWORD,
50
+ type GherkinKeywords,
51
+ } from './spec/keywords.ts';
52
+ export {
53
+ buildReqRegistry,
54
+ type RegistryDuplicate,
55
+ type RegistryOccurrence,
56
+ type ReqRegistry,
57
+ } from './spec/reqRegistry.ts';
38
58
  export {
39
59
  analyzeLegacy,
40
60
  hasNativeRules,
@@ -150,7 +170,7 @@ export {
150
170
  export {
151
171
  ETHICS_KEYS,
152
172
  buildTemplateVars,
153
- effectiveRunCommand,
173
+ effectiveCheckCommand,
154
174
  enforceEthicsGovernance,
155
175
  loadLocaleResource,
156
176
  loadSkillTemplates,
@@ -183,11 +203,22 @@ export {
183
203
  type ChangeSummary,
184
204
  } from './change/collect.ts';
185
205
  export { renderChangesJson, renderChangesList } from './report/collect.ts';
206
+ export {
207
+ buildUnboundFeed,
208
+ collectUnboundRequirements,
209
+ type UnboundFeed,
210
+ type UnboundRequirement,
211
+ } from './report/unbound.ts';
186
212
  export { graphData, graphMermaid, type GraphDataIr, type GraphFsIo } from './report/graph.ts';
187
213
  export { parseDeps } from './report/graph.ts';
188
214
  export { renderMachine, type MachineFormat } from './render/machine.ts';
189
215
  export { showChangeJson, type ShowDeps, type ShowFsIo } from './report/show.ts';
190
- export { nextReqId, scaffoldSpec, type SpecHelperIo } from './report/specHelpers.ts';
216
+ export {
217
+ nextReqId,
218
+ scaffoldSpec,
219
+ skeletonContent,
220
+ type SpecHelperIo,
221
+ } from './report/specHelpers.ts';
191
222
  export {
192
223
  collectSpecs,
193
224
  morphologyOf,
@@ -23,13 +23,13 @@ locale: en
23
23
  # - llman-sdd-wayfinder
24
24
  # - llman-sdd-research
25
25
 
26
- # BDD integration (optional, uncomment to enable)
27
- # bdd:
26
+ # Spec verification (optional, uncomment to enable)
27
+ # specs:
28
28
  # framework: pytest-bdd
29
29
  # # Filtered runners: include {feature_*} so validate --all/--specs runs per capability.
30
- # # run_command: "pytest {feature_dir} -k {feature_name} -v"
30
+ # # check_command: "pytest {feature_dir} -k {feature_name} -v"
31
31
  # # Project-wide runners (no placeholders): validate --all/--specs runs the suite once (batch-once).
32
- # # run_command: "cargo test --features bdd"
32
+ # # check_command: "cargo test --features bdd"
33
33
  # # verify_prompt: |
34
34
  # # Map test failures to requirement IDs.
35
35
  `;
@@ -51,13 +51,13 @@ locale: zh-Hans
51
51
  # - llman-sdd-wayfinder
52
52
  # - llman-sdd-research
53
53
 
54
- # BDD 集成(可选,取消注释以启用)
55
- # bdd:
54
+ # Spec 验证(可选,取消注释以启用)
55
+ # specs:
56
56
  # framework: pytest-bdd
57
57
  # # 过滤型 runner:写 {feature_*},validate --all/--specs 按 capability 分别执行。
58
- # # run_command: "pytest {feature_dir} -k {feature_name} -v"
58
+ # # check_command: "pytest {feature_dir} -k {feature_name} -v"
59
59
  # # 项目级 runner(无占位符):validate --all/--specs 整批只跑一次(batch-once)。
60
- # # run_command: "cargo test --features bdd"
60
+ # # check_command: "cargo test --features bdd"
61
61
  # # verify_prompt: |
62
62
  # # 将测试失败映射到对应的 requirement ID。
63
63
  `;
package/src/init/init.ts CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  prependSchemaHeader,
27
27
  } from './defaultConfig.ts';
28
28
 
29
- export { effectiveRunCommand } from '../templates/skills.ts';
29
+ export { effectiveCheckCommand } from '../templates/skills.ts';
30
30
 
31
31
  const MARKER_START = '<!-- LLMANSPEC:START -->';
32
32
  const MARKER_END = '<!-- LLMANSPEC:END -->';
@@ -1,3 +1,4 @@
1
+ import { officialKeywords, officialKeywordsOrEn } from '../spec/keywords.ts';
1
2
  import { localeToGherkinLang, parseCapability } from '../spec/parser.ts';
2
3
  import { buildReqRegistry } from '../spec/reqRegistry.ts';
3
4
  /**
@@ -57,22 +58,23 @@ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
57
58
  export function skeletonContent(capability: string, reqId: string, locale: string): string {
58
59
  const zh = localeFallbacks(locale)[0] === 'zh-Hans';
59
60
  // r7: the `# language:` header derives from the locale mapping — never a
60
- // second hardcoded caliber.
61
- const language = localeToGherkinLang(zh ? 'zh-Hans' : 'en');
61
+ // second hardcoded caliber. Table-less locales fall back to en wholesale
62
+ // (header + keywords same-source) so the skeleton stays parseable.
63
+ const mapped = localeToGherkinLang(locale);
64
+ const language = officialKeywords(mapped) !== null ? mapped : 'en';
65
+ const kw = officialKeywordsOrEn(language);
62
66
  const header = zh
63
67
  ? `# language: ${language}\n# capability: ${capability}\n# purpose: TODO: 一句话描述该能力与其目的。\n# scope: llmanspec/`
64
68
  : `# language: ${language}\n# capability: ${capability}\n# purpose: TODO: Describe this capability and its purpose.\n# scope: llmanspec/`;
65
- const feature = zh ? `功能: ${capability}` : `Feature: ${capability}`;
66
- const ruleKw = zh ? '规则' : 'Rule';
67
- const scenarioKw = zh ? '场景' : 'Scenario';
68
- const ruleTitle = 'TODO-rule';
69
69
  const ruleDesc = zh ? 'TODO: 需求描述(自由文本)。' : 'TODO: requirement statement (free text).';
70
- const scTitle = 'TODO-acceptance';
71
- // native v2 skeleton: a `规则:` block (with @req handle) + one nested example
70
+ // Skeleton placeholder prose is not vocabulary — the zh/en copy stays; keywords do not.
71
+ const steps = zh
72
+ ? ` ${kw.given} TODO 前置\n ${kw.when} TODO 动作\n ${kw.thenText} TODO 断言`
73
+ : ` ${kw.given} TODO precondition\n ${kw.when} TODO action\n ${kw.thenText} TODO assertion`;
74
+ // native v2 skeleton: a rule block (with @req handle) + one nested example
72
75
  return (
73
- `${header}\n\n${feature}\n\n @req:${reqId}\n ${ruleKw}: ${ruleTitle}\n` +
74
- ` ${ruleDesc}\n\n ${scenarioKw}: ${scTitle}\n` +
75
- ` 假如 TODO 前置\n 当 TODO 动作\n 那么 TODO 断言\n`
76
+ `${header}\n\n${kw.feature}: ${capability}\n\n @req:${reqId}\n ${kw.rule}: TODO-rule\n` +
77
+ ` ${ruleDesc}\n\n ${kw.scenario}: TODO-acceptance\n${steps}\n`
76
78
  );
77
79
  }
78
80
 
@@ -1,19 +1,20 @@
1
1
  /**
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.
2
+ * Specs listing (peripheral-commands capability, r20/r21/r90): morphology
3
+ * counts. Native model — rules = `规则:` blocks; bound = rules with at least
4
+ * one runnable nested executable scenario; unbound = rules without any runnable
5
+ * nested scenario (@skip/@experimental-only or stepless scenarios do not bind);
6
+ * acceptance = nested scenarios; featureScenarioCount = top-level examples.
7
+ * Machine field names follow the requirement terminology (r90).
6
8
  */
7
9
  import { renderMachine } from '../render/machine.ts';
8
- import { specIdOf } from '../spec/ir.ts';
10
+ import { ruleHasRunnableScenario, specIdOf } from '../spec/ir.ts';
9
11
  import type { CapabilityDoc } from '../spec/ir.ts';
10
12
  import type { SpecEntry } from '../validation/validate.ts';
11
13
  import { pad } from './collect.ts';
12
14
 
13
15
  export interface SpecMorphology {
14
- ruleCount: number;
15
- ruleEnforcedCount: number;
16
- rulePendingCount: number;
16
+ requirementBoundCount: number;
17
+ requirementUnboundCount: number;
17
18
  acceptanceCount: number;
18
19
  featureScenarioCount: number;
19
20
  }
@@ -30,16 +31,14 @@ export interface SpecSummary {
30
31
  }
31
32
 
32
33
  /** Morphology counts shared by `list --specs`, `show <spec> --json`, and the
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. */
34
+ * CLI text render — bound/unbound follow the runnable-scenario definition; top-level
35
+ * examples are counted separately with no rule accounting. */
36
36
  export function morphologyOf(doc: CapabilityDoc): SpecMorphology {
37
37
  const rules = doc.rules;
38
38
  const acceptance = rules.flatMap((r) => r.scenarios);
39
39
  return {
40
- ruleCount: rules.length,
41
- ruleEnforcedCount: rules.filter((r) => r.scenarios.length > 0).length,
42
- rulePendingCount: rules.filter((r) => r.scenarios.length === 0).length,
40
+ requirementBoundCount: rules.filter(ruleHasRunnableScenario).length,
41
+ requirementUnboundCount: rules.filter((r) => !ruleHasRunnableScenario(r)).length,
43
42
  acceptanceCount: acceptance.length,
44
43
  featureScenarioCount: doc.orphans.length,
45
44
  };
@@ -48,6 +47,7 @@ export function morphologyOf(doc: CapabilityDoc): SpecMorphology {
48
47
  export function collectSpecs(entries: readonly SpecEntry[]): SpecSummary[] {
49
48
  return entries.map((e) => {
50
49
  const morphology = morphologyOf(e.doc);
50
+ const total = morphology.requirementBoundCount + morphology.requirementUnboundCount;
51
51
  return {
52
52
  id: specIdOf(e),
53
53
  title: specIdOf(e),
@@ -56,7 +56,7 @@ export function collectSpecs(entries: readonly SpecEntry[]): SpecSummary[] {
56
56
  .split(',')
57
57
  .map((s) => s.trim())
58
58
  .filter((s) => s !== ''),
59
- requirementCount: morphology.ruleCount,
59
+ requirementCount: total,
60
60
  health: null,
61
61
  staleness: null,
62
62
  morphology,
@@ -70,7 +70,7 @@ export function renderSpecsList(summaries: readonly SpecSummary[]): string[] {
70
70
  for (const s of summaries) {
71
71
  const m = s.morphology;
72
72
  lines.push(
73
- ` ${pad(s.id, idWidth)}rules ${m.ruleCount} enforced ${m.ruleEnforcedCount} pending ${m.rulePendingCount} acceptance ${m.acceptanceCount}`,
73
+ ` ${pad(s.id, idWidth)}requirements ${s.requirementCount} bound ${m.requirementBoundCount} unbound ${m.requirementUnboundCount} acceptance ${m.acceptanceCount}`,
74
74
  );
75
75
  }
76
76
  return lines;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `spec unbound` retrieval (peripheral-commands capability, r89): the
3
+ * implementation-readiness feed of unbound requirements. "Unbound" follows the
4
+ * single global definition — a requirement without any runnable nested scenario
5
+ * (@skip/@experimental-only or stepless included) — same口径 as review's
6
+ * `unbound` signal, validate's aggregate INFO and list/show morphology (r90).
7
+ *
8
+ * Ordering is deterministic: capability files in discovery (sorted) order,
9
+ * then rules in document order — agents can consume the feed incrementally.
10
+ * Pure — no filesystem access.
11
+ */
12
+ import { ruleHasRunnableScenario, specIdOf } from '../spec/ir.ts';
13
+ import type { SpecEntry } from '../validation/validate.ts';
14
+
15
+ export interface UnboundRequirement {
16
+ /** The `@req:<id>` handle (requirement id). */
17
+ reqId: string;
18
+ /** The `规则:` block title. */
19
+ title: string;
20
+ /** Free-form requirement statement (block description, as authored). */
21
+ statement: string;
22
+ /** Capability (spec) id owning the requirement. */
23
+ capability: string;
24
+ /** Repo-root-relative path of the capability's main .feature file. */
25
+ featurePath: string;
26
+ }
27
+
28
+ /** All unbound requirements across the given specs, in deterministic order. */
29
+ export function collectUnboundRequirements(entries: readonly SpecEntry[]): UnboundRequirement[] {
30
+ const out: UnboundRequirement[] = [];
31
+ for (const entry of entries) {
32
+ const capability = specIdOf(entry);
33
+ for (const rule of entry.doc.rules) {
34
+ if (ruleHasRunnableScenario(rule)) continue;
35
+ out.push({
36
+ reqId: rule.reqId,
37
+ title: rule.title,
38
+ statement: rule.description,
39
+ capability,
40
+ featurePath: entry.fileName,
41
+ });
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ export interface UnboundFeed {
48
+ /** Total unbound requirements before any limit. */
49
+ total: number;
50
+ /** Entries actually returned (limit applied). */
51
+ returned: number;
52
+ /** `total - returned`; 0 means everything was returned. */
53
+ remaining: number;
54
+ /** Self-introspection hint when remaining > 0 (empty otherwise). */
55
+ hint: string;
56
+ requirements: UnboundRequirement[];
57
+ }
58
+
59
+ /**
60
+ * Build the `spec unbound` feed under a hard limit. `limit` semantics are
61
+ * uniform across every output mode: default 1, `--limit 0` returns all.
62
+ */
63
+ export function buildUnboundFeed(entries: readonly SpecEntry[], limit: number): UnboundFeed {
64
+ const all = collectUnboundRequirements(entries);
65
+ const shown = limit === 0 ? all : all.slice(0, limit);
66
+ const returned = shown.length;
67
+ const remaining = all.length - returned;
68
+ const hint = remaining > 0 ? `… 还有 ${remaining} 条未绑定需求,用 --limit 0 列出全部` : '';
69
+ return { total: all.length, returned, remaining, hint, requirements: shown };
70
+ }
@@ -1,5 +1,5 @@
1
1
  import type { GitLike } from '../git/spawnGit.ts';
2
- import { specIdOf } from '../spec/ir.ts';
2
+ import { ruleHasRunnableScenario, specIdOf } from '../spec/ir.ts';
3
3
  import { evaluateStaleness, notApplicableStaleness } from '../validation/staleness.ts';
4
4
  /**
5
5
  * Review aggregation (review-freeze capability, r23): five-signal review over
@@ -7,7 +7,7 @@ import { evaluateStaleness, notApplicableStaleness } from '../validation/stalene
7
7
  */
8
8
  import { validateAllSpecs, type SpecEntry, type SpecIo } from '../validation/validate.ts';
9
9
 
10
- export type ReviewKind = 'pending' | 'stale' | 'locked' | 'validate';
10
+ export type ReviewKind = 'unbound' | 'stale' | 'locked' | 'validate';
11
11
 
12
12
  export interface ReviewSignal {
13
13
  kind: ReviewKind;
@@ -66,13 +66,13 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
66
66
  // r33: per-capability signals honor the --capability filter; locked and
67
67
  // validate stay global regardless.
68
68
  if (input.capability !== undefined && cap !== input.capability) continue;
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);
69
+ // Native model: rules are `规则:` blocks; a rule without any runnable
70
+ // nested scenario is unbound (not bound to real logic — 0 scenarios or
71
+ // all @skip/@experimental/stepless). Top-level scenarios outside any rule
72
+ // are plain feature-level examples with no special signal.
73
+ const unbound = entry.doc.rules.filter((r) => !ruleHasRunnableScenario(r)).length;
74
74
 
75
- push('pending', cap, pending.length);
75
+ push('unbound', cap, unbound);
76
76
 
77
77
  // staleness (predecessor evaluate): real base-ref/scope evaluation.
78
78
  let staleInfo = notApplicableStaleness();
@@ -143,7 +143,7 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
143
143
 
144
144
  const lines: string[] = [`Review: critical=${criticalCount} warning=${warningCount}`];
145
145
  for (const cap of sorted.map((e) => specIdOf(e))) {
146
- for (const kind of ['pending', 'stale'] as const) {
146
+ for (const kind of ['unbound', 'stale'] as const) {
147
147
  const s = signals.find((x) => x.kind === kind && x.capability === cap);
148
148
  if (!s) continue;
149
149
  lines.push(`${kind}: ${cap} (${s.count})`);