@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.
- package/package.json +1 -1
- package/src/archive/freeze.ts +8 -5
- package/src/change/id.ts +51 -31
- package/src/change/lifecycle.ts +186 -23
- package/src/change/nextId.ts +55 -0
- package/src/change/resolve.ts +34 -0
- package/src/config/changeId.ts +87 -0
- package/src/config/surface.ts +59 -0
- package/src/context/indexStore.ts +41 -0
- package/src/git/spawnGit.ts +12 -0
- package/src/index.ts +50 -1
- package/src/report/collect.ts +30 -14
- package/src/report/graph.ts +339 -43
- package/src/report/show.ts +38 -15
- package/src/report/specHelpers.ts +13 -5
- package/src/report/specs.ts +14 -7
- package/src/review/review.ts +36 -19
- package/src/spec/authoring.ts +206 -0
- package/src/spec/ir.ts +0 -1
- package/src/spec/parser.ts +4 -7
- package/src/validation/changeCheck.ts +341 -0
- package/src/validation/staleness.ts +158 -0
- package/src/validation/validate.ts +125 -42
- package/templates/en/units/skills/validation-hints.md +1 -1
- package/templates/en/units/spec/feature-contract.md +1 -1
- package/templates/zh-Hans/units/skills/validation-hints.md +1 -1
- package/templates/zh-Hans/units/spec/feature-contract.md +1 -1
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { readBinding } from '../change/frontmatter.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Change-domain validation (v1 `commands/validate.rs` change path parity):
|
|
4
|
+
* frontmatter/depends_on gates, design/tasks constraints, completeness stage
|
|
5
|
+
* INFO, pattern gate and task gates. IO + git injected (pure).
|
|
6
|
+
*/
|
|
7
|
+
import type { GitLike } from '../git/spawnGit.ts';
|
|
8
|
+
|
|
9
|
+
export type ChangeIssueLevel = 'ERROR' | 'WARNING' | 'INFO';
|
|
10
|
+
|
|
11
|
+
export interface ChangeIssue {
|
|
12
|
+
level: ChangeIssueLevel;
|
|
13
|
+
/** v1-style anchor (issue path), e.g. `proposal.md/frontmatter.depends_on`. */
|
|
14
|
+
path: string;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ChangeCheckInput {
|
|
19
|
+
name: string;
|
|
20
|
+
stage: 'draft' | 'designed' | 'planned' | 'full';
|
|
21
|
+
hasBinding: boolean;
|
|
22
|
+
totalTasks: number;
|
|
23
|
+
completedTasks: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ChangeCheckConfig {
|
|
27
|
+
strict_defer?: boolean | null;
|
|
28
|
+
min_completion_ratio?: number | null;
|
|
29
|
+
change_id_pattern?: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ChangeFsIoLite {
|
|
33
|
+
exists(path: string): boolean;
|
|
34
|
+
readText(path: string): string;
|
|
35
|
+
listDir(path: string): string[];
|
|
36
|
+
isDirectory(path: string): boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const STAGE_ORDER = ['draft', 'designed', 'planned', 'full'] as const;
|
|
40
|
+
|
|
41
|
+
export type StageGate = (typeof STAGE_ORDER)[number];
|
|
42
|
+
|
|
43
|
+
export interface ChangeCheckResult {
|
|
44
|
+
id: string;
|
|
45
|
+
valid: boolean;
|
|
46
|
+
issues: ChangeIssue[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Legacy pure gate (r47 surface) — kept for existing callers/tests. */
|
|
50
|
+
export function checkChangeDoc(
|
|
51
|
+
input: ChangeCheckInput,
|
|
52
|
+
config: ChangeCheckConfig,
|
|
53
|
+
opts: { stage?: StageGate } = {},
|
|
54
|
+
): ChangeCheckResult {
|
|
55
|
+
const issues: ChangeIssue[] = [];
|
|
56
|
+
if (input.totalTasks > 0 && input.completedTasks < input.totalTasks) {
|
|
57
|
+
const pending = input.totalTasks - input.completedTasks;
|
|
58
|
+
issues.push({
|
|
59
|
+
level: config.strict_defer ? 'ERROR' : 'WARNING',
|
|
60
|
+
path: 'tasks.md',
|
|
61
|
+
message: `${pending} unchecked task(s) in tasks.md`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (
|
|
65
|
+
config.min_completion_ratio !== undefined &&
|
|
66
|
+
config.min_completion_ratio !== null &&
|
|
67
|
+
input.totalTasks > 0 &&
|
|
68
|
+
input.completedTasks / input.totalTasks < config.min_completion_ratio
|
|
69
|
+
) {
|
|
70
|
+
issues.push({
|
|
71
|
+
level: 'ERROR',
|
|
72
|
+
path: 'tasks.md',
|
|
73
|
+
message: `completion ratio below archive.min_completion_ratio (${config.min_completion_ratio})`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (!input.hasBinding) {
|
|
77
|
+
issues.push({
|
|
78
|
+
level: 'WARNING',
|
|
79
|
+
path: 'binding',
|
|
80
|
+
message: 'change has no branch binding (not started/attached)',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (config.change_id_pattern) {
|
|
84
|
+
const re = new RegExp(config.change_id_pattern, 'u');
|
|
85
|
+
if (!re.test(input.name)) {
|
|
86
|
+
issues.push({
|
|
87
|
+
level: 'ERROR',
|
|
88
|
+
path: 'change-id',
|
|
89
|
+
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).`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (opts.stage !== undefined) {
|
|
94
|
+
const currentIdx = STAGE_ORDER.indexOf(input.stage);
|
|
95
|
+
const requiredIdx = STAGE_ORDER.indexOf(opts.stage);
|
|
96
|
+
if (currentIdx < requiredIdx) {
|
|
97
|
+
issues.push({
|
|
98
|
+
level: 'ERROR',
|
|
99
|
+
path: 'stage',
|
|
100
|
+
message: `stage \`${input.stage}\` is below the required \`${opts.stage}\``,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { id: input.name, valid: issues.every((i) => i.level !== 'ERROR'), issues };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type ChangeValidationConfig = ChangeCheckConfig;
|
|
108
|
+
|
|
109
|
+
const COMPLETENESS: Record<string, string> = {
|
|
110
|
+
draft:
|
|
111
|
+
"Change is in 'draft' stage (next: add design.md + tasks.md, then `llman sdd change start <id>` to enter a feature branch)",
|
|
112
|
+
designed:
|
|
113
|
+
"Change is in 'designed' stage (next: add tasks.md to reach 'planned', then `llman sdd change start` to enter feature branch and reach 'full')",
|
|
114
|
+
planned:
|
|
115
|
+
"Change is in 'planned' stage (next: `llman sdd change start` to enter feature branch and reach 'full')",
|
|
116
|
+
full: "Change is bound and stage is 'full' (verify readiness via `llman sdd show <id> --json` readyToImplement)",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* File-aware change validation with v1 messages/paths (used by the validate
|
|
121
|
+
* command). `strict` escalates WARNING issues to ERROR (v1 build_report).
|
|
122
|
+
* `git` (optional) enables the r63 completeness WARNINGs — Full-not-ready
|
|
123
|
+
* with skill guidance, per change.
|
|
124
|
+
*/
|
|
125
|
+
export function validateChange(
|
|
126
|
+
io: ChangeFsIoLite,
|
|
127
|
+
root: string,
|
|
128
|
+
id: string,
|
|
129
|
+
config: ChangeValidationConfig,
|
|
130
|
+
opts: { stage?: StageGate; strict?: boolean; git?: GitLike } = {},
|
|
131
|
+
): ChangeCheckResult {
|
|
132
|
+
const issues: ChangeIssue[] = [];
|
|
133
|
+
const push = (level: ChangeIssueLevel, path: string, message: string): void => {
|
|
134
|
+
issues.push({ level, path, message });
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const dir = `${root}/llmanspec/changes/${id}/`;
|
|
138
|
+
const baseDir = `${root}/llmanspec/changes`;
|
|
139
|
+
const proposal = `${dir}proposal.md`;
|
|
140
|
+
if (!io.exists(proposal)) {
|
|
141
|
+
push('ERROR', 'proposal.md', 'Change is missing proposal.md.');
|
|
142
|
+
} else {
|
|
143
|
+
const text = io.readText(proposal);
|
|
144
|
+
const fmMatch = text.match(/^---\n([\s\S]*?)\n---/u);
|
|
145
|
+
const fm = fmMatch?.[1] ?? '';
|
|
146
|
+
const fmLines = fm.split('\n').map((l) => l.trim());
|
|
147
|
+
|
|
148
|
+
for (const key of ['depends_on', 'blocks'] as const) {
|
|
149
|
+
const lineIdx = fmLines.findIndex((l) => l.startsWith(`${key}:`));
|
|
150
|
+
if (lineIdx === -1) continue;
|
|
151
|
+
const value = (fmLines[lineIdx] ?? '').slice(key.length + 1).trim();
|
|
152
|
+
let items: string[] = [];
|
|
153
|
+
if (value === '') {
|
|
154
|
+
for (const l of fmLines.slice(lineIdx + 1)) {
|
|
155
|
+
if (l.startsWith('- ')) items.push(l.slice(2).trim());
|
|
156
|
+
else if (l === '') continue;
|
|
157
|
+
else break;
|
|
158
|
+
}
|
|
159
|
+
} else if (value.startsWith('[')) {
|
|
160
|
+
items = value
|
|
161
|
+
.slice(1, -1)
|
|
162
|
+
.split(',')
|
|
163
|
+
.map((s) => s.trim())
|
|
164
|
+
.filter((s) => s !== '');
|
|
165
|
+
} else {
|
|
166
|
+
push(
|
|
167
|
+
'ERROR',
|
|
168
|
+
`proposal.md/frontmatter.${key}`,
|
|
169
|
+
`proposal.md ${key} must be a list of change ID strings`,
|
|
170
|
+
);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
for (const item of items) {
|
|
174
|
+
if (item === '') continue;
|
|
175
|
+
if (/^\d/u.test(item)) {
|
|
176
|
+
push(
|
|
177
|
+
'ERROR',
|
|
178
|
+
`proposal.md/frontmatter.${key}`,
|
|
179
|
+
`proposal.md ${key} must be a list of change ID strings`,
|
|
180
|
+
);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const depDir = `${baseDir}/${item}`;
|
|
184
|
+
const archived = `${baseDir}/archive`;
|
|
185
|
+
if (
|
|
186
|
+
!io.exists(`${depDir}/proposal.md`) &&
|
|
187
|
+
!io.exists(dir.replace(/\/[^/]+\/$/u, '/archive/')) &&
|
|
188
|
+
!io.listDir(archived).some((n) => n.endsWith(`-${item}`))
|
|
189
|
+
) {
|
|
190
|
+
push(
|
|
191
|
+
'ERROR',
|
|
192
|
+
`proposal.md/frontmatter.${key}`,
|
|
193
|
+
`proposal.md ${key} references unknown change: ${item}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Unknown frontmatter field gate (v1).
|
|
200
|
+
for (const l of fmLines) {
|
|
201
|
+
if (l === '' || l.startsWith('-') || l.startsWith('#')) continue;
|
|
202
|
+
const m = l.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:/u);
|
|
203
|
+
if (m && m[1] !== undefined && !ALLOWED_FIELDS.includes(m[1])) {
|
|
204
|
+
push(
|
|
205
|
+
'ERROR',
|
|
206
|
+
'proposal.md/frontmatter',
|
|
207
|
+
`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.`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const hasDesign = io.exists(`${dir}design.md`);
|
|
213
|
+
const hasTasks = io.exists(`${dir}tasks.md`);
|
|
214
|
+
const binding = readBinding(text);
|
|
215
|
+
const stage =
|
|
216
|
+
hasDesign && hasTasks
|
|
217
|
+
? binding !== null
|
|
218
|
+
? 'full'
|
|
219
|
+
: 'planned'
|
|
220
|
+
: hasDesign
|
|
221
|
+
? 'designed'
|
|
222
|
+
: 'draft';
|
|
223
|
+
|
|
224
|
+
if (hasTasks && !hasDesign) {
|
|
225
|
+
push(
|
|
226
|
+
'ERROR',
|
|
227
|
+
'proposal.md/frontmatter',
|
|
228
|
+
'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.',
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (hasTasks) {
|
|
233
|
+
const tasks = io.readText(`${dir}tasks.md`);
|
|
234
|
+
let total = 0;
|
|
235
|
+
let completed = 0;
|
|
236
|
+
for (const line of tasks.split('\n')) {
|
|
237
|
+
const m = line.match(/^\s*-\s+\[( |x|X)\]/u);
|
|
238
|
+
if (m) {
|
|
239
|
+
total += 1;
|
|
240
|
+
if (m[1] !== ' ') completed += 1;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (total > 0 && completed < total) {
|
|
244
|
+
const n = total - completed;
|
|
245
|
+
push(
|
|
246
|
+
config.strict_defer ? 'ERROR' : 'WARNING',
|
|
247
|
+
'tasks.md',
|
|
248
|
+
`${n} unchecked task(s) in tasks.md`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// completeness INFO (v1 surface).
|
|
254
|
+
push('INFO', 'completeness', COMPLETENESS[stage] ?? '');
|
|
255
|
+
|
|
256
|
+
// r63: Full-but-not-ready WARNING with skill guidance (v1 r1 surface).
|
|
257
|
+
if (opts.git !== undefined && binding !== null && stage === 'full') {
|
|
258
|
+
const needs =
|
|
259
|
+
text.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1] !== undefined
|
|
260
|
+
? text.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1] === 'true'
|
|
261
|
+
: true;
|
|
262
|
+
const touched =
|
|
263
|
+
opts.git.runOpt(['diff', '--name-only', `${binding.baseBranch}...${binding.branch}`]) ?? '';
|
|
264
|
+
const landed = touched.includes('llmanspec/specs/');
|
|
265
|
+
if (!landed && needs) {
|
|
266
|
+
push(
|
|
267
|
+
'WARNING',
|
|
268
|
+
'proposal.md',
|
|
269
|
+
`specs not landed: change bound to \`${binding.branch}\` but no changes under \`llmanspec/specs/\` on its bound branch. Edit live specs there and commit (or set \`needs_specs_change: false\` if this change has no live contract edits). Skill: llman-sdd-propose — do NOT re-run change start when already attached; apply only when \`llman-sdd show <id> --json\` reports readyToImplement=true (llman-sdd-apply).`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// pattern gate (v1 change-id path).
|
|
276
|
+
if (config.change_id_pattern) {
|
|
277
|
+
try {
|
|
278
|
+
const re = new RegExp(config.change_id_pattern, 'u');
|
|
279
|
+
if (!re.test(id)) {
|
|
280
|
+
push(
|
|
281
|
+
'ERROR',
|
|
282
|
+
'change-id',
|
|
283
|
+
`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).`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
/* compile already validated at load; ignore */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// stage gate (v1 --stage: artifact presence per gate).
|
|
292
|
+
if (opts.stage !== undefined) {
|
|
293
|
+
const hasDesign = io.exists(`${dir}design.md`);
|
|
294
|
+
const hasTasks = io.exists(`${dir}tasks.md`);
|
|
295
|
+
if (opts.stage === 'designed' && !hasDesign) {
|
|
296
|
+
push('ERROR', 'design.md', `Stage forced to 'designed' but design.md is missing`);
|
|
297
|
+
}
|
|
298
|
+
if (opts.stage === 'planned') {
|
|
299
|
+
if (!hasDesign)
|
|
300
|
+
push('ERROR', 'design.md', `Stage forced to 'planned' but design.md is missing`);
|
|
301
|
+
if (!hasTasks) push('ERROR', 'tasks.md', `Stage forced to 'planned' but tasks.md is missing`);
|
|
302
|
+
}
|
|
303
|
+
if (opts.stage === 'full' && !hasTasks) {
|
|
304
|
+
push('ERROR', 'tasks.md', `Stage forced to 'full' but tasks.md is missing`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const effective =
|
|
309
|
+
opts.strict === true
|
|
310
|
+
? issues.map((i) => (i.level === 'WARNING' ? { ...i, level: 'ERROR' as const } : i))
|
|
311
|
+
: issues;
|
|
312
|
+
return { id, valid: effective.every((i) => i.level !== 'ERROR'), issues: effective };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const ALLOWED_FIELDS = [
|
|
316
|
+
'depends_on',
|
|
317
|
+
'blocks',
|
|
318
|
+
'branch',
|
|
319
|
+
'base_branch',
|
|
320
|
+
'base_sha',
|
|
321
|
+
'needs_specs_change',
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
export interface PlaceholderTarget {
|
|
325
|
+
featureDir: string;
|
|
326
|
+
featureName: string;
|
|
327
|
+
featurePath: string;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** True when run_command carries any r48 placeholder. */
|
|
331
|
+
export function hasPlaceholders(runCommand: string): boolean {
|
|
332
|
+
return /\{feature_dir\}|\{feature_name\}|\{feature_path\}/u.test(runCommand);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Expand {feature_dir}/{feature_name}/{feature_path} for one target (r48). */
|
|
336
|
+
export function expandRunCommand(runCommand: string, target: PlaceholderTarget): string {
|
|
337
|
+
return runCommand
|
|
338
|
+
.replaceAll('{feature_dir}', target.featureDir)
|
|
339
|
+
.replaceAll('{feature_name}', target.featureName)
|
|
340
|
+
.replaceAll('{feature_path}', target.featurePath);
|
|
341
|
+
}
|
|
@@ -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
|
+
}
|