@llman-sdd/core 0.1.4 → 0.3.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.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Validation engine (validation capability): aggregates Phase-2 parse errors
3
- * plus the verdict-equivalent gates probed from v1 (r11/r12). Pure —
4
- * filesystem access is injected via SpecIo.
3
+ * plus the verdict-equivalent gates (r11/r12, ordered to match v1
4
+ * `spec/validation.rs` observable issue order). Pure — filesystem access is
5
+ * injected via SpecIo.
5
6
  */
6
7
  import type { CapabilityDoc } from '../spec/ir.ts';
7
8
  import { MUST_WORD_RE } from '../spec/ir.ts';
@@ -11,7 +12,7 @@ export type ValidationLevel = 'ERROR' | 'WARNING' | 'INFO';
11
12
 
12
13
  export interface ValidationItem {
13
14
  level: ValidationLevel;
14
- /** Gate anchor, e.g. `t/rule/ok` or `t/valid_scope` (v1-style). */
15
+ /** Gate anchor, e.g. `t/rule/ok` or `t/valid_scope` (v1-style `path`). */
15
16
  id: string;
16
17
  message: string;
17
18
  }
@@ -39,79 +40,156 @@ export interface ValidationReport {
39
40
  failed: boolean;
40
41
  }
41
42
 
43
+ const rulesPath = (cap: string): string => `${cap}/rules`;
44
+ const acceptanceReqPath = (cap: string, name: string): string => `${cap}/acceptance/${name}/@req`;
45
+ const coveragePath = (cap: string): string => `${cap}/coverage`;
46
+
42
47
  export function validateCapability(
43
48
  entry: SpecEntry,
44
49
  duplicatesFor: (reqId: string) => boolean,
45
50
  io: SpecIo,
51
+ opts: { strict?: boolean } = {},
46
52
  ): SpecVerdict {
47
53
  const { doc } = entry;
48
54
  const cap = doc.header.capability ?? entry.fileName;
55
+ const strict = opts.strict === true;
49
56
  const items: ValidationItem[] = [];
50
57
 
51
- // Header gates (r12): capability header is an ERROR (v1 parity); missing
52
- // purpose/scope degrade to WARNING.
58
+ const push = (level: ValidationLevel, id: string, message: string): void => {
59
+ items.push({ level, id, message });
60
+ };
61
+
62
+ // Header gates (r12 / v1 spec_meta). v1 treats a missing `# capability:`
63
+ // header as a parse-level failure: only `file` + registry-scan issues are
64
+ // emitted and all single-track gates are skipped.
53
65
  if (doc.header.capability === null) {
54
- items.push({
55
- level: 'ERROR',
56
- id: 'file',
57
- message: 'missing `# capability:` header comment',
58
- });
66
+ const msg = `spec \`${cap}\`: missing \`# capability:\` header comment (spec-format r133)`;
67
+ push('ERROR', 'file', msg);
68
+ push('ERROR', 'llmanspec/specs', `Failed to scan req_id index: ${msg}`);
69
+ return { fileName: entry.fileName, capability: cap, ok: false, items };
59
70
  }
60
- if (doc.header.purpose === null) {
61
- items.push({ level: 'WARNING', id: 'file', message: 'missing `# purpose:` header comment' });
71
+ if (doc.header.purpose === null || doc.header.purpose.trim() === '') {
72
+ push(
73
+ 'ERROR',
74
+ `${cap}/purpose`,
75
+ '`# purpose:` header comment must not be empty (spec-format r133)',
76
+ );
62
77
  }
63
78
  if (doc.header.scope === null) {
64
- items.push({ level: 'WARNING', id: 'file', message: 'missing `# scope:` header comment' });
79
+ push(
80
+ 'ERROR',
81
+ `${cap}/valid_scope`,
82
+ 'Spec valid_scope must not be empty (add it inside the .toon document).',
83
+ );
65
84
  } else {
66
- for (const p of doc.header.scope
85
+ // r42: missing valid_scope paths are independent failures —
86
+ // ERROR (nonzero exit) under --strict, WARNING otherwise.
87
+ const missing = doc.header.scope
67
88
  .split(',')
68
89
  .map((s) => s.trim())
69
- .filter((s) => s !== '')) {
70
- if (!io.exists(p)) {
71
- items.push({
72
- level: 'ERROR',
73
- id: `${cap}/valid_scope`,
74
- message: `valid_scope path(s) do not exist on disk: ${p}`,
75
- });
76
- }
90
+ .filter((s) => s !== '')
91
+ .filter((s) => !io.exists(s));
92
+ if (missing.length > 0) {
93
+ push(
94
+ strict ? 'ERROR' : 'WARNING',
95
+ `${cap}/valid_scope`,
96
+ `valid_scope path(s) do not exist on disk: ${missing.join(', ')}`,
97
+ );
77
98
  }
78
99
  }
79
100
 
80
- // Structural errors from parsing (mutual exclusion, manual orphan, nested
81
- // rule scenarios) — all ERROR level (v1 verdict parity). Header gates and
82
- // the MUST-word gate are owned by this layer (mapped below), so the
83
- // parser's duplicate findings are skipped here.
101
+ // Spec name must match the capability id (flat stem).
102
+ if (doc.header.capability !== null && doc.header.capability.trim() !== cap) {
103
+ push(
104
+ 'WARNING',
105
+ `${cap}/meta.name`,
106
+ `Spec \`# capability:\` header must match spec id (flat file stem or directory name): \`${doc.header.capability.trim()}\` != \`${cap}\``,
107
+ );
108
+ }
109
+
110
+ // Feature title must be non-empty.
111
+ if ((doc.featureName ?? '').trim() === '') {
112
+ push('ERROR', `${cap}/feature`, 'Feature line must carry a title');
113
+ }
114
+
115
+ // Parser-level structural errors (mutual exclusion, removed-tag migration,
116
+ // nested rule scenarios). Header gates and the MUST-word gate are owned here
117
+ // (mapped below), so the parser's duplicate findings are skipped.
84
118
  const OWNED_BY_THIS_LAYER = ['missing-header:', 'rule:missing-must-word'];
85
119
  for (const err of doc.errors) {
86
120
  if (OWNED_BY_THIS_LAYER.some((prefix) => err.code.startsWith(prefix))) continue;
87
- items.push({ level: 'ERROR', id: `${cap}/${err.code}`, message: err.message });
121
+ push('ERROR', err.code.startsWith('file') ? 'file' : `${cap}/${err.code}`, err.message);
122
+ }
123
+
124
+ // Single-track gates (v1 validate_single_track order).
125
+ const human = doc.scenarios.filter((s) => s.classification === 'human');
126
+ const acceptance = doc.scenarios.filter((s) => s.classification === 'executable');
127
+
128
+ if (human.length === 0) {
129
+ push('ERROR', rulesPath(cap), 'spec must define at least one @human constraint scenario');
88
130
  }
89
131
 
90
132
  for (const scenario of doc.scenarios) {
91
133
  const anchor = `${cap}/rule/${scenario.name}`;
92
134
  if (scenario.classification === 'human') {
93
135
  if (scenario.reqIds.length === 0) {
94
- items.push({
95
- level: 'ERROR',
96
- id: anchor,
97
- message: '@human constraint scenario must carry an @req:<req_id> tag',
98
- });
136
+ push('ERROR', anchor, '@human constraint scenario must carry an @req:<req_id> tag');
99
137
  }
100
138
  if (!MUST_WORD_RE.test(scenario.statement)) {
101
- items.push({
102
- level: 'ERROR',
103
- id: anchor,
104
- message: 'constraint statement must contain MUST/SHALL (or 必须/不得/禁止)',
105
- });
139
+ push('ERROR', anchor, 'constraint statement must contain MUST/SHALL (or 必须/不得/禁止)');
106
140
  }
141
+ } else if (scenario.classification === 'executable') {
142
+ // (pairing handled below, after all rule req ids are known)
143
+ } else {
144
+ push(
145
+ 'WARNING',
146
+ `${cap}/scenario/${scenario.name}`,
147
+ `scenario \`${scenario.name}\` carries neither @human nor @executable; tag it or drop it`,
148
+ );
107
149
  }
108
150
  for (const reqId of scenario.reqIds) {
109
151
  if (duplicatesFor(reqId)) {
110
- items.push({
111
- level: 'ERROR',
112
- id: `${cap}/registry/${reqId}`,
113
- message: `global duplicate req_id \`${reqId}\` used by multiple capabilities`,
114
- });
152
+ push(
153
+ 'ERROR',
154
+ `${cap}/registry/${reqId}`,
155
+ `global duplicate req_id \`${reqId}\` used by multiple capabilities`,
156
+ );
157
+ }
158
+ }
159
+ }
160
+
161
+ // Dangling acceptance @req links (v1 order: acceptance/@req then coverage).
162
+ const ruleReqIds = new Set(human.flatMap((s) => s.reqIds));
163
+ for (const sc of acceptance) {
164
+ // r65: orphan acceptance scenario — no @req link at all (v1 r132 WARNING).
165
+ if (sc.reqIds.length === 0) {
166
+ push(
167
+ 'WARNING',
168
+ `${cap}/acceptance/${sc.name}`,
169
+ `orphan acceptance scenario \`${sc.name}\` has no @req:<req_id> link`,
170
+ );
171
+ }
172
+ for (const rid of sc.reqIds) {
173
+ if (!ruleReqIds.has(rid)) {
174
+ push(
175
+ 'ERROR',
176
+ acceptanceReqPath(cap, sc.name),
177
+ `@req:${rid} on acceptance scenario \`${sc.name}\` has no matching @human constraint`,
178
+ );
179
+ }
180
+ }
181
+ }
182
+
183
+ // Rule coverage INFO (r134 pending rules).
184
+ for (const sc of human) {
185
+ for (const rid of sc.reqIds) {
186
+ const covered = acceptance.some((a) => a.reqIds.includes(rid));
187
+ if (!covered) {
188
+ push(
189
+ 'INFO',
190
+ coveragePath(cap),
191
+ `rule ${rid} is pending: no @executable acceptance scenario`,
192
+ );
115
193
  }
116
194
  }
117
195
  }
@@ -149,3 +227,8 @@ export function validateAllSpecs(entries: readonly SpecEntry[], io: SpecIo): Val
149
227
 
150
228
  return { verdicts, lines, failed };
151
229
  }
230
+
231
+ /** v1 `apply_strict`: WARNING issues escalate to ERROR when --strict. */
232
+ export function applyStrict<T extends { level: ValidationLevel }>(items: readonly T[]): T[] {
233
+ return items.map((i) => (i.level === 'WARNING' ? { ...i, level: 'ERROR' as const } : i));
234
+ }
@@ -12,7 +12,7 @@ Every capability `.feature` (`llmanspec/specs/<capability>.feature` or `llmanspe
12
12
  2) Tag grammar (`@human constraint scenario must carry an @req:<req_id> tag` / `orphan acceptance scenario`):
13
13
  - Rules: `@req:<id> @human` — statement in the scenario description (MUST/SHALL required).
14
14
  - Acceptance: `@executable` + at least one `@req:<id>` linking a rule.
15
- - `@manual` requires `@human`. Never combine `@human` with `@executable`.
15
+ Never combine `@human` with `@executable`. (`@manual` was removed in 0.3.0 — drop it; `@human` already carries the human-judgement semantics.)
16
16
 
17
17
  3) Legacy `spec.toon` present (`legacy spec.toon found ... run ... toon2features`):
18
18
  Run `llman-sdd project migrate --kind toon2features --yes`, review the diff, commit.
@@ -25,5 +25,5 @@ It is the only spec artifact — there is no `spec.toon`.
25
25
  - Header comments (`# capability:` / `# purpose:` / `# scope:`) are REQUIRED; `scope` drives staleness.
26
26
  - `@human` scenarios are human-owned constraints; their description carries the normative statement verbatim. Editing/removing them yields a WARNING only (report-only, never blocks a gate) — compare via git branch diff; the legacy lock-ack metadata `rules_touched` / `agent_acked` / `@agent` is removed with no aliases and no compat layer (locked rules are report-only: a warning, never a block).
27
27
  - `@executable` scenarios are runner-bound acceptance; they link rules via `@req:<req_id>`.
28
- - Coverage tiers: enforced (has acceptance) / manual (`@manual`) / pending. `list --specs` reports all three.
28
+ - Coverage tiers: enforced (has acceptance) / pending. `list --specs` reports both.
29
29
  - Scenarios MUST stay top-level: `Rule:` blocks are rejected (the runner skips them silently).
@@ -12,7 +12,7 @@
12
12
  2)tag 语法(`@human constraint scenario must carry an @req:<req_id> tag` / `orphan acceptance scenario`):
13
13
  - 规则:`@req:<id> @human` —— statement 放场景描述(须含 MUST/SHALL)。
14
14
  - 验收:`@executable` 且至少一个 `@req:<id>` 挂到规则。
15
- - `@manual` 须与 `@human` 同用;禁止 `@human` 与 `@executable` 同场景。
15
+ - 禁止 `@human` 与 `@executable` 同场景;`@manual` 已在 0.3.0 移除——残留会被报迁移 ERROR,删掉该 tag 即可(`@human` 本身已承载人工判定语义)。
16
16
 
17
17
  3)遗留 `spec.toon`(`legacy spec.toon found ... run ... toon2features`):
18
18
  运行 `llman-sdd project migrate --kind toon2features --yes`,审阅 diff 后提交。
@@ -25,5 +25,5 @@
25
25
  - 头注释(`# capability:` / `# purpose:` / `# scope:`)必填;`scope` 驱动 staleness 检查。
26
26
  - `@human` 场景是人拥有的约束场景;规则 statement 全文放在场景描述里。改/删它只出 WARNING(报告制,不阻断门禁),用 git 分支对比审视;旧的锁定确认元数据 `rules_touched` / `agent_acked` / `@agent` 已删除,无别名也无兼容层。
27
27
  - `@executable` 场景是 runner 绑定的验收场景;用 `@req:<req_id>` 挂回规则。
28
- - 覆盖三态分级:enforced(有验收)/ manual(`@manual`)/ pending——`list --specs` 逐项输出。
28
+ - 覆盖两态分级:enforced(有验收)/ pending——`list --specs` 逐项输出。
29
29
  - 场景 MUST 保持顶层:`Rule:` 块会被拒绝(runner 会静默跳过其中场景)。