@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.
- package/package.json +1 -1
- package/src/archive/freeze.ts +14 -9
- package/src/archive/sevenzip.ts +34 -5
- package/src/change/id.ts +51 -31
- package/src/change/lifecycle.ts +178 -23
- package/src/change/nextId.ts +55 -0
- package/src/config/changeId.ts +87 -0
- package/src/config/surface.ts +59 -0
- package/src/git/spawnGit.ts +12 -0
- package/src/index.ts +53 -2
- package/src/report/collect.ts +30 -14
- package/src/report/graph.ts +339 -43
- package/src/report/show.ts +32 -13
- package/src/report/specHelpers.ts +13 -5
- package/src/review/review.ts +34 -15
- package/src/spec/authoring.ts +206 -0
- package/src/templates/embedded.ts +13 -12
- package/src/validation/changeCheck.ts +310 -0
- package/src/validation/staleness.ts +158 -0
- package/src/validation/validate.ts +117 -42
|
@@ -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
|
|
4
|
-
* filesystem access is
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|