@llman-sdd/core 0.1.3 → 0.2.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.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Change-domain validation (v1 `commands/validate.rs` change path parity):
3
+ * frontmatter/depends_on gates, design/tasks constraints, completeness stage
4
+ * INFO, pattern gate and task gates. IO + git injected (pure).
5
+ */
6
+
7
+ export type ChangeIssueLevel = 'ERROR' | 'WARNING' | 'INFO';
8
+
9
+ export interface ChangeIssue {
10
+ level: ChangeIssueLevel;
11
+ /** v1-style anchor (issue path), e.g. `proposal.md/frontmatter.depends_on`. */
12
+ path: string;
13
+ message: string;
14
+ }
15
+
16
+ export interface ChangeCheckInput {
17
+ name: string;
18
+ stage: 'draft' | 'designed' | 'planned' | 'full';
19
+ hasBinding: boolean;
20
+ totalTasks: number;
21
+ completedTasks: number;
22
+ }
23
+
24
+ export interface ChangeCheckConfig {
25
+ strict_defer?: boolean | null;
26
+ min_completion_ratio?: number | null;
27
+ change_id_pattern?: string | null;
28
+ }
29
+
30
+ export interface ChangeFsIoLite {
31
+ exists(path: string): boolean;
32
+ readText(path: string): string;
33
+ listDir(path: string): string[];
34
+ isDirectory(path: string): boolean;
35
+ }
36
+
37
+ export const STAGE_ORDER = ['draft', 'designed', 'planned', 'full'] as const;
38
+
39
+ export type StageGate = (typeof STAGE_ORDER)[number];
40
+
41
+ export interface ChangeCheckResult {
42
+ id: string;
43
+ valid: boolean;
44
+ issues: ChangeIssue[];
45
+ }
46
+
47
+ /** Legacy pure gate (r47 surface) — kept for existing callers/tests. */
48
+ export function checkChangeDoc(
49
+ input: ChangeCheckInput,
50
+ config: ChangeCheckConfig,
51
+ opts: { stage?: StageGate } = {},
52
+ ): ChangeCheckResult {
53
+ const issues: ChangeIssue[] = [];
54
+ if (input.totalTasks > 0 && input.completedTasks < input.totalTasks) {
55
+ const pending = input.totalTasks - input.completedTasks;
56
+ issues.push({
57
+ level: config.strict_defer ? 'ERROR' : 'WARNING',
58
+ path: 'tasks.md',
59
+ message: `${pending} unchecked task(s) in tasks.md`,
60
+ });
61
+ }
62
+ if (
63
+ config.min_completion_ratio !== undefined &&
64
+ config.min_completion_ratio !== null &&
65
+ input.totalTasks > 0 &&
66
+ input.completedTasks / input.totalTasks < config.min_completion_ratio
67
+ ) {
68
+ issues.push({
69
+ level: 'ERROR',
70
+ path: 'tasks.md',
71
+ message: `completion ratio below archive.min_completion_ratio (${config.min_completion_ratio})`,
72
+ });
73
+ }
74
+ if (!input.hasBinding) {
75
+ issues.push({
76
+ level: 'WARNING',
77
+ path: 'binding',
78
+ message: 'change has no branch binding (not started/attached)',
79
+ });
80
+ }
81
+ if (config.change_id_pattern) {
82
+ const re = new RegExp(config.change_id_pattern, 'u');
83
+ if (!re.test(input.name)) {
84
+ issues.push({
85
+ level: 'ERROR',
86
+ path: 'change-id',
87
+ message: `Change id '${input.name}' does not match change_id.pattern '${config.change_id_pattern}' (sdd-workflow r29; scope: active changes only, archive/legacy shapes are not back-checked).`,
88
+ });
89
+ }
90
+ }
91
+ if (opts.stage !== undefined) {
92
+ const currentIdx = STAGE_ORDER.indexOf(input.stage);
93
+ const requiredIdx = STAGE_ORDER.indexOf(opts.stage);
94
+ if (currentIdx < requiredIdx) {
95
+ issues.push({
96
+ level: 'ERROR',
97
+ path: 'stage',
98
+ message: `stage \`${input.stage}\` is below the required \`${opts.stage}\``,
99
+ });
100
+ }
101
+ }
102
+ return { id: input.name, valid: issues.every((i) => i.level !== 'ERROR'), issues };
103
+ }
104
+
105
+ export type ChangeValidationConfig = ChangeCheckConfig;
106
+
107
+ const COMPLETENESS: Record<string, string> = {
108
+ draft:
109
+ "Change is in 'draft' stage (next: add design.md + tasks.md, then `llman sdd change start <id>` to enter a feature branch)",
110
+ designed:
111
+ "Change is in 'designed' stage (next: add tasks.md to reach 'planned', then `llman sdd change start` to enter feature branch and reach 'full')",
112
+ planned:
113
+ "Change is in 'planned' stage (next: `llman sdd change start` to enter feature branch and reach 'full')",
114
+ };
115
+
116
+ /**
117
+ * File-aware change validation with v1 messages/paths (used by the validate
118
+ * command). `strict` escalates WARNING issues to ERROR (v1 build_report).
119
+ */
120
+ export function validateChange(
121
+ io: ChangeFsIoLite,
122
+ root: string,
123
+ id: string,
124
+ config: ChangeValidationConfig,
125
+ opts: { stage?: StageGate; strict?: boolean } = {},
126
+ ): ChangeCheckResult {
127
+ const issues: ChangeIssue[] = [];
128
+ const push = (level: ChangeIssueLevel, path: string, message: string): void => {
129
+ issues.push({ level, path, message });
130
+ };
131
+
132
+ const dir = `${root}/llmanspec/changes/${id}/`;
133
+ const baseDir = `${root}/llmanspec/changes`;
134
+ const proposal = `${dir}proposal.md`;
135
+ if (!io.exists(proposal)) {
136
+ push('ERROR', 'proposal.md', 'Change is missing proposal.md.');
137
+ } else {
138
+ const text = io.readText(proposal);
139
+ const fmMatch = text.match(/^---\n([\s\S]*?)\n---/u);
140
+ const fm = fmMatch?.[1] ?? '';
141
+ const fmLines = fm.split('\n').map((l) => l.trim());
142
+
143
+ for (const key of ['depends_on', 'blocks'] as const) {
144
+ const lineIdx = fmLines.findIndex((l) => l.startsWith(`${key}:`));
145
+ if (lineIdx === -1) continue;
146
+ const value = (fmLines[lineIdx] ?? '').slice(key.length + 1).trim();
147
+ let items: string[] = [];
148
+ if (value === '') {
149
+ for (const l of fmLines.slice(lineIdx + 1)) {
150
+ if (l.startsWith('- ')) items.push(l.slice(2).trim());
151
+ else if (l === '') continue;
152
+ else break;
153
+ }
154
+ } else if (value.startsWith('[')) {
155
+ items = value
156
+ .slice(1, -1)
157
+ .split(',')
158
+ .map((s) => s.trim())
159
+ .filter((s) => s !== '');
160
+ } else {
161
+ push(
162
+ 'ERROR',
163
+ `proposal.md/frontmatter.${key}`,
164
+ `proposal.md ${key} must be a list of change ID strings`,
165
+ );
166
+ continue;
167
+ }
168
+ for (const item of items) {
169
+ if (item === '') continue;
170
+ if (/^\d/u.test(item)) {
171
+ push(
172
+ 'ERROR',
173
+ `proposal.md/frontmatter.${key}`,
174
+ `proposal.md ${key} must be a list of change ID strings`,
175
+ );
176
+ continue;
177
+ }
178
+ const depDir = `${baseDir}/${item}`;
179
+ const archived = `${baseDir}/archive`;
180
+ if (
181
+ !io.exists(`${depDir}/proposal.md`) &&
182
+ !io.exists(dir.replace(/\/[^/]+\/$/u, '/archive/')) &&
183
+ !io.listDir(archived).some((n) => n.endsWith(`-${item}`))
184
+ ) {
185
+ push(
186
+ 'ERROR',
187
+ `proposal.md/frontmatter.${key}`,
188
+ `proposal.md ${key} references unknown change: ${item}`,
189
+ );
190
+ }
191
+ }
192
+ }
193
+
194
+ // Unknown frontmatter field gate (v1).
195
+ for (const l of fmLines) {
196
+ if (l === '' || l.startsWith('-') || l.startsWith('#')) continue;
197
+ const m = l.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:/u);
198
+ if (m && m[1] !== undefined && !ALLOWED_FIELDS.includes(m[1])) {
199
+ push(
200
+ 'ERROR',
201
+ 'proposal.md/frontmatter',
202
+ `proposal.md frontmatter has unknown field '${m[1]}'; allowed fields are: ${ALLOWED_FIELDS.join(', ')}. Stage is inferred from on-disk artifacts (run \`llman sdd show\` / \`llman sdd list\`); do not store lifecycle state in frontmatter.`,
203
+ );
204
+ }
205
+ }
206
+
207
+ const hasDesign = io.exists(`${dir}design.md`);
208
+ const hasTasks = io.exists(`${dir}tasks.md`);
209
+ const stage = hasDesign && hasTasks ? 'planned' : hasDesign ? 'designed' : 'draft';
210
+
211
+ if (hasTasks && !hasDesign) {
212
+ push(
213
+ 'ERROR',
214
+ 'proposal.md/frontmatter',
215
+ 'Change has tasks.md but missing design.md. Tasks MUST be generated after design decisions are documented. Create design.md first, then regenerate tasks.md.',
216
+ );
217
+ }
218
+
219
+ if (hasTasks) {
220
+ const tasks = io.readText(`${dir}tasks.md`);
221
+ let total = 0;
222
+ let completed = 0;
223
+ for (const line of tasks.split('\n')) {
224
+ const m = line.match(/^\s*-\s+\[( |x|X)\]/u);
225
+ if (m) {
226
+ total += 1;
227
+ if (m[1] !== ' ') completed += 1;
228
+ }
229
+ }
230
+ if (total > 0 && completed < total) {
231
+ const n = total - completed;
232
+ push(
233
+ config.strict_defer ? 'ERROR' : 'WARNING',
234
+ 'tasks.md',
235
+ `${n} unchecked task(s) in tasks.md`,
236
+ );
237
+ }
238
+ }
239
+
240
+ // completeness INFO (v1 surface).
241
+ push('INFO', 'completeness', COMPLETENESS[stage] ?? '');
242
+ }
243
+
244
+ // pattern gate (v1 change-id path).
245
+ if (config.change_id_pattern) {
246
+ try {
247
+ const re = new RegExp(config.change_id_pattern, 'u');
248
+ if (!re.test(id)) {
249
+ push(
250
+ 'ERROR',
251
+ 'change-id',
252
+ `Change id '${id}' does not match change_id.pattern '${config.change_id_pattern}' (sdd-workflow r29; scope: active changes only, archive/legacy shapes are not back-checked).`,
253
+ );
254
+ }
255
+ } catch {
256
+ /* compile already validated at load; ignore */
257
+ }
258
+ }
259
+
260
+ // stage gate (v1 --stage: artifact presence per gate).
261
+ if (opts.stage !== undefined) {
262
+ const hasDesign = io.exists(`${dir}design.md`);
263
+ const hasTasks = io.exists(`${dir}tasks.md`);
264
+ if (opts.stage === 'designed' && !hasDesign) {
265
+ push('ERROR', 'design.md', `Stage forced to 'designed' but design.md is missing`);
266
+ }
267
+ if (opts.stage === 'planned') {
268
+ if (!hasDesign)
269
+ push('ERROR', 'design.md', `Stage forced to 'planned' but design.md is missing`);
270
+ if (!hasTasks) push('ERROR', 'tasks.md', `Stage forced to 'planned' but tasks.md is missing`);
271
+ }
272
+ if (opts.stage === 'full' && !hasTasks) {
273
+ push('ERROR', 'tasks.md', `Stage forced to 'full' but tasks.md is missing`);
274
+ }
275
+ }
276
+
277
+ const effective =
278
+ opts.strict === true
279
+ ? issues.map((i) => (i.level === 'WARNING' ? { ...i, level: 'ERROR' as const } : i))
280
+ : issues;
281
+ return { id, valid: effective.every((i) => i.level !== 'ERROR'), issues: effective };
282
+ }
283
+
284
+ const ALLOWED_FIELDS = [
285
+ 'depends_on',
286
+ 'blocks',
287
+ 'branch',
288
+ 'base_branch',
289
+ 'base_sha',
290
+ 'needs_specs_change',
291
+ ];
292
+
293
+ export interface PlaceholderTarget {
294
+ featureDir: string;
295
+ featureName: string;
296
+ featurePath: string;
297
+ }
298
+
299
+ /** True when run_command carries any r48 placeholder. */
300
+ export function hasPlaceholders(runCommand: string): boolean {
301
+ return /\{feature_dir\}|\{feature_name\}|\{feature_path\}/u.test(runCommand);
302
+ }
303
+
304
+ /** Expand {feature_dir}/{feature_name}/{feature_path} for one target (r48). */
305
+ export function expandRunCommand(runCommand: string, target: PlaceholderTarget): string {
306
+ return runCommand
307
+ .replaceAll('{feature_dir}', target.featureDir)
308
+ .replaceAll('{feature_name}', target.featureName)
309
+ .replaceAll('{feature_path}', target.featurePath);
310
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Staleness evaluator (validation/staleness parity): v1 `sdd/spec/staleness.rs`
3
+ * observable contract — status/baseRef/scope/touchedPaths/specUpdated/dirty/
4
+ * notes plus per-capability staleness issues. Pure: git + env are injected.
5
+ */
6
+
7
+ import type { GitLike } from '../git/spawnGit.ts';
8
+
9
+ export type StalenessStatus = 'OK' | 'INFO' | 'WARN' | 'STALE' | 'NOTAPPLICABLE';
10
+
11
+ export interface StalenessInfo {
12
+ status: StalenessStatus;
13
+ baseRef: string | null;
14
+ scope: string[];
15
+ touchedPaths: string[];
16
+ specUpdated: boolean;
17
+ dirty: boolean;
18
+ notes: string[];
19
+ }
20
+
21
+ export interface StalenessIssue {
22
+ level: 'ERROR' | 'WARNING' | 'INFO';
23
+ path: string;
24
+ message: string;
25
+ }
26
+
27
+ export interface StalenessDeps {
28
+ git: GitLike;
29
+ root: string;
30
+ /** spec file path relative to root, e.g. `llmanspec/specs/auth.feature` */
31
+ specRel: string;
32
+ /** scope entries from the `# scope:` header (raw, unnormalized) */
33
+ scope: string[];
34
+ /** env override for LLMANSPEC_BASE_REF */
35
+ baseRefEnv?: string;
36
+ defaultBranchName?: string;
37
+ }
38
+
39
+ const normalizePath = (value: string): string =>
40
+ value.trim().replace(/^\.\//u, '').replace(/^\/+/u, '').replace(/\/+$/u, '');
41
+
42
+ const scopeMatches = (path: string, scope: readonly string[]): boolean => {
43
+ const p = normalizePath(path);
44
+ return scope.some((s) => p === s || p.startsWith(`${s}/`));
45
+ };
46
+
47
+ export function notApplicableStaleness(): StalenessInfo {
48
+ return {
49
+ status: 'NOTAPPLICABLE',
50
+ baseRef: null,
51
+ scope: [],
52
+ touchedPaths: [],
53
+ specUpdated: false,
54
+ dirty: false,
55
+ notes: [],
56
+ };
57
+ }
58
+
59
+ const DIRTY_MSG = 'Working tree is dirty; results may be unreliable.';
60
+ const SCOPE_MISSING_MSG = 'Note: Spec validation scope is missing.';
61
+ const BASE_MISSING_MSG =
62
+ "Note: Unable to resolve base ref for staleness check. Set LLMANSPEC_BASE_REF (e.g. 'main' or 'origin/main'), or upgrade llman if below 0.0.60.";
63
+
64
+ /**
65
+ * Evaluate staleness for one capability spec — v1 status semantics.
66
+ * Returns the info object plus warning/info issues (strict escalation is the
67
+ * caller's job, matching v1 `apply_strict`).
68
+ */
69
+ export function evaluateStaleness(deps: StalenessDeps): {
70
+ info: StalenessInfo;
71
+ issues: StalenessIssue[];
72
+ } {
73
+ const { git, specRel, scope, baseRefEnv } = deps;
74
+ const issues: StalenessIssue[] = [];
75
+ const notes: string[] = [];
76
+ const normScope = scope.map(normalizePath).filter((s) => s !== '');
77
+
78
+ const rawBaseRef =
79
+ baseRefEnv !== undefined && baseRefEnv.trim() !== '' ? baseRefEnv.trim() : null;
80
+ let baseRef: string | null = null;
81
+ let mergeBase: string | null = null;
82
+
83
+ if (rawBaseRef !== null) {
84
+ baseRef = rawBaseRef;
85
+ mergeBase = revParse(git, rawBaseRef);
86
+ } else {
87
+ const defaultBranchName = deps.defaultBranchName ?? defaultBranchNameFn(git);
88
+ mergeBase = git.runOpt(['merge-base', defaultBranchName, 'HEAD']);
89
+ if (mergeBase !== null) baseRef = mergeBase;
90
+ }
91
+
92
+ let status: StalenessStatus = 'OK';
93
+ if (normScope.length === 0) {
94
+ status = 'WARN';
95
+ notes.push(SCOPE_MISSING_MSG);
96
+ issues.push({ level: 'WARNING', path: `${specId()}/staleness`, message: SCOPE_MISSING_MSG });
97
+ }
98
+ if (mergeBase === null && rawBaseRef === null) {
99
+ status = 'WARN';
100
+ notes.push(BASE_MISSING_MSG);
101
+ issues.push({ level: 'WARNING', path: `${specId()}/staleness`, message: BASE_MISSING_MSG });
102
+ }
103
+
104
+ let touchedPaths: string[] = [];
105
+ let specUpdated = false;
106
+ if (status !== 'WARN' && baseRef !== null && mergeBase !== null) {
107
+ const diff = git.runOpt(['diff', '--name-only', `${baseRef}...HEAD`]) ?? '';
108
+ const paths =
109
+ diff === ''
110
+ ? []
111
+ : diff
112
+ .split('\n')
113
+ .map((l) => l.trim())
114
+ .filter((l) => l !== '');
115
+ if (paths.length > 0) {
116
+ specUpdated = paths.some((p) => normalizePath(p) === normalizePath(specRel));
117
+ touchedPaths = paths.filter((p) => scopeMatches(p, normScope));
118
+ }
119
+ if (touchedPaths.length > 0 && !specUpdated) {
120
+ status = 'STALE' as StalenessStatus;
121
+ issues.push({ level: 'WARNING', path: `${specId()}/staleness`, message: STALE_MSG });
122
+ } else if (specUpdated && touchedPaths.length === 0) {
123
+ status = 'INFO';
124
+ notes.push(SPEC_UPDATED_MSG);
125
+ }
126
+ }
127
+
128
+ // Tolerant: outside a git repo (or on git failure) treat as dirty (v1 unwrap_or(true)).
129
+ const dirty = (git.runOpt(['status', '--porcelain']) ?? 'dirty') !== '';
130
+ if (dirty) {
131
+ if (status === 'OK' || status === 'STALE') status = 'INFO';
132
+ notes.push(DIRTY_MSG);
133
+ issues.push({ level: 'INFO', path: `${specId()}/staleness`, message: DIRTY_MSG });
134
+ }
135
+
136
+ return {
137
+ info: { status, baseRef, scope: normScope, touchedPaths, specUpdated, dirty, notes },
138
+ issues,
139
+ };
140
+
141
+ function specId(): string {
142
+ return deps.specRel.replace(/^llmanspec\/specs\//u, '').replace(/\.feature$/u, '');
143
+ }
144
+ }
145
+
146
+ const STALE_MSG = 'Note: Spec files changed on the base branch; re-review the spec.';
147
+ const SPEC_UPDATED_MSG = 'Note: Spec updated on this branch.';
148
+
149
+ function revParse(git: GitLike, ref: string): string | null {
150
+ return git.runOpt(['rev-parse', '--verify', '--quiet', ref]) ?? null;
151
+ }
152
+
153
+ function defaultBranchNameFn(git: GitLike): string {
154
+ if (git.runOpt(['show-ref', '--verify', '--quiet', 'refs/heads/main']) !== null) return 'main';
155
+ if (git.runOpt(['show-ref', '--verify', '--quiet', 'refs/heads/master']) !== null)
156
+ return 'master';
157
+ return 'main';
158
+ }
@@ -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,148 @@ 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, manual orphan, nested
116
+ // 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
+ for (const rid of sc.reqIds) {
165
+ if (!ruleReqIds.has(rid)) {
166
+ push(
167
+ 'ERROR',
168
+ acceptanceReqPath(cap, sc.name),
169
+ `@req:${rid} on acceptance scenario \`${sc.name}\` has no matching @human constraint`,
170
+ );
171
+ }
172
+ }
173
+ }
174
+
175
+ // Rule coverage INFO (r134 pending rules).
176
+ for (const sc of human) {
177
+ for (const rid of sc.reqIds) {
178
+ const covered = acceptance.some((a) => a.reqIds.includes(rid));
179
+ if (!covered) {
180
+ push(
181
+ 'INFO',
182
+ coveragePath(cap),
183
+ `rule ${rid} is pending: no @executable acceptance scenario and no @manual waiver`,
184
+ );
115
185
  }
116
186
  }
117
187
  }
@@ -149,3 +219,8 @@ export function validateAllSpecs(entries: readonly SpecEntry[], io: SpecIo): Val
149
219
 
150
220
  return { verdicts, lines, failed };
151
221
  }
222
+
223
+ /** v1 `apply_strict`: WARNING issues escalate to ERROR when --strict. */
224
+ export function applyStrict<T extends { level: ValidationLevel }>(items: readonly T[]): T[] {
225
+ return items.map((i) => (i.level === 'WARNING' ? { ...i, level: 'ERROR' as const } : i));
226
+ }