@llman-sdd/core 0.1.4 → 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 +8 -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 +46 -1
- 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/validation/changeCheck.ts +310 -0
- package/src/validation/staleness.ts +158 -0
- package/src/validation/validate.ts +117 -42
|
@@ -14,9 +14,12 @@ export interface SpecHelperIo {
|
|
|
14
14
|
listDir(path: string): string[];
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* v1 parity (`req_registry.rs::next_req_id_from_index`): smallest free rN over
|
|
19
|
+
* the RULE (@human) req ids only; acceptance-only req tags do not occupy ids.
|
|
20
|
+
*/
|
|
18
21
|
export function nextReqId(io: SpecHelperIo, specsDir: string): string {
|
|
19
|
-
|
|
22
|
+
const used = new Set<number>();
|
|
20
23
|
const walk = (dir: string): void => {
|
|
21
24
|
if (!io.exists(dir)) return;
|
|
22
25
|
for (const name of io.listDir(dir)) {
|
|
@@ -24,8 +27,10 @@ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
|
|
|
24
27
|
if (name.endsWith('.feature')) {
|
|
25
28
|
const doc = parseCapability(io.readText(full), full);
|
|
26
29
|
for (const scenario of doc.scenarios) {
|
|
30
|
+
if (scenario.classification !== 'human') continue;
|
|
27
31
|
for (const reqId of scenario.reqIds) {
|
|
28
|
-
|
|
32
|
+
const n = Math.trunc(Number(reqId.replace(/^r/u, '')));
|
|
33
|
+
if (Number.isFinite(n)) used.add(n);
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
} else if (io.isDirectory(full)) {
|
|
@@ -34,7 +39,9 @@ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
|
|
|
34
39
|
}
|
|
35
40
|
};
|
|
36
41
|
walk(specsDir);
|
|
37
|
-
|
|
42
|
+
let n = 1;
|
|
43
|
+
while (used.has(n)) n += 1;
|
|
44
|
+
return `r${n}`;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
export function skeletonContent(capability: string, reqId: string, locale: string): string {
|
|
@@ -53,10 +60,11 @@ export function scaffoldSpec(
|
|
|
53
60
|
specsDir: string,
|
|
54
61
|
capability: string,
|
|
55
62
|
locale: string,
|
|
63
|
+
opts: { force?: boolean } = {},
|
|
56
64
|
): string {
|
|
57
65
|
const reqId = nextReqId(io, specsDir);
|
|
58
66
|
const path = `${specsDir}/${capability}.feature`;
|
|
59
|
-
if (io.exists(path)) throw new Error(`spec already exists: ${path}`);
|
|
67
|
+
if (!opts.force && io.exists(path)) throw new Error(`spec already exists: ${path}`);
|
|
60
68
|
io.mkdirp('src/');
|
|
61
69
|
io.writeText(path, skeletonContent(capability, reqId, locale));
|
|
62
70
|
return path;
|
package/src/review/review.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { GitLike } from '../git/spawnGit.ts';
|
|
2
|
+
import { evaluateStaleness, notApplicableStaleness } from '../validation/staleness.ts';
|
|
1
3
|
/**
|
|
2
4
|
* Review aggregation (review-freeze capability, r23): five-signal review over
|
|
3
5
|
* spec IR + validate sweep. Port of v1 sdd/review.rs observable contract.
|
|
@@ -26,6 +28,12 @@ export interface ReviewInput {
|
|
|
26
28
|
boundChangeCount: number;
|
|
27
29
|
/** Active change summaries for the strict sweep (pending tasks → FAIL). */
|
|
28
30
|
activeChanges?: readonly { name: string; completedTasks: number; totalTasks: number }[];
|
|
31
|
+
/** Restrict per-capability signals (pending/manual/unbound/stale) to this capability. */
|
|
32
|
+
capability?: string;
|
|
33
|
+
/** git + root for real staleness evaluation (v1 parity). */
|
|
34
|
+
git?: GitLike;
|
|
35
|
+
root?: string;
|
|
36
|
+
specsDir?: string;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export interface ReviewResult {
|
|
@@ -35,17 +43,8 @@ export interface ReviewResult {
|
|
|
35
43
|
exitCode: number;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
function executableBound(
|
|
39
|
-
scenarioTags: readonly string[],
|
|
40
|
-
bindings: readonly TagBinding[] | null,
|
|
41
|
-
): boolean {
|
|
42
|
-
if (!bindings || bindings.length === 0) return false;
|
|
43
|
-
const has = (t: string): boolean => scenarioTags.includes(t);
|
|
44
|
-
return bindings.some((b) => (b.kind === 'tags' ? b.tags.every((t) => has(t)) : false));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
46
|
export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
48
|
-
const { entries,
|
|
47
|
+
const { entries, boundChangeCount } = input;
|
|
49
48
|
const strictChangeFails = (input.activeChanges ?? []).filter(
|
|
50
49
|
(c) => c.totalTasks > 0 && c.completedTasks < c.totalTasks,
|
|
51
50
|
);
|
|
@@ -59,18 +58,38 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
59
58
|
|
|
60
59
|
for (const entry of sorted) {
|
|
61
60
|
const cap = entry.doc.header.capability ?? entry.fileName;
|
|
61
|
+
// r33: per-capability signals honor the --capability filter; locked and
|
|
62
|
+
// validate stay global regardless.
|
|
63
|
+
if (input.capability !== undefined && cap !== input.capability) continue;
|
|
62
64
|
const rules = entry.doc.scenarios.filter((s) => s.classification === 'human');
|
|
63
65
|
const acceptance = entry.doc.scenarios.filter((s) => s.classification === 'executable');
|
|
64
66
|
const acceptanceReqIds = new Set(acceptance.flatMap((s) => s.reqIds));
|
|
65
67
|
const pending = rules.filter((r) => !r.reqIds.some((id) => acceptanceReqIds.has(id)));
|
|
66
68
|
const manual = rules.filter((r) => r.manual);
|
|
67
|
-
|
|
69
|
+
// v1 r5: unbound = orphan acceptance scenarios (no @req link).
|
|
70
|
+
const unbound = acceptance.filter((s) => s.reqIds.length === 0);
|
|
68
71
|
|
|
69
72
|
push('pending', cap, pending.length);
|
|
70
73
|
push('manual', cap, manual.length);
|
|
71
74
|
push('unbound', cap, unbound.length);
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
|
|
76
|
+
// staleness (v1 evaluate): real base-ref/scope evaluation.
|
|
77
|
+
let staleInfo = notApplicableStaleness();
|
|
78
|
+
let staleCount = 0;
|
|
79
|
+
if (input.git !== undefined && input.root !== undefined) {
|
|
80
|
+
const specRel =
|
|
81
|
+
(entry.fileName.startsWith('llmanspec/') ? '' : 'llmanspec/specs/') + entry.fileName;
|
|
82
|
+
const evalResult = evaluateStaleness({
|
|
83
|
+
git: input.git,
|
|
84
|
+
root: input.root,
|
|
85
|
+
specRel,
|
|
86
|
+
scope: entry.doc.header.scope?.split(',').map((x) => x.trim()) ?? [],
|
|
87
|
+
baseRefEnv: process.env.LLMANSPEC_BASE_REF,
|
|
88
|
+
});
|
|
89
|
+
staleInfo = evalResult.info;
|
|
90
|
+
staleCount = staleInfo.status === 'OK' || staleInfo.status === 'NOTAPPLICABLE' ? 0 : 1;
|
|
91
|
+
}
|
|
92
|
+
push('stale', cap, staleCount, staleInfo.status === 'NOTAPPLICABLE' ? '' : staleInfo.status);
|
|
74
93
|
}
|
|
75
94
|
|
|
76
95
|
const failed = sweep.verdicts.filter((v) => !v.ok);
|
|
@@ -96,13 +115,13 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
96
115
|
'locked',
|
|
97
116
|
'-',
|
|
98
117
|
0,
|
|
99
|
-
`${boundChangeCount} bound change(s); inspect with \`llman
|
|
118
|
+
`${boundChangeCount} bound change(s); inspect with \`llman sdd change diff <id>\``,
|
|
100
119
|
);
|
|
101
120
|
push(
|
|
102
121
|
'validate',
|
|
103
122
|
'-',
|
|
104
123
|
failed.length,
|
|
105
|
-
failed.length > 0 ? 'validate --all failed; run `llman
|
|
124
|
+
failed.length > 0 ? 'validate --all failed; run `llman sdd validate --all` for details' : 'ok',
|
|
106
125
|
);
|
|
107
126
|
|
|
108
127
|
const warningCount = signals
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programming-style spec authoring helpers (r41-r43, v1 parity):
|
|
3
|
+
* append rules / acceptance scenarios, resolve req ids, dedupe conflicts.
|
|
4
|
+
* Appends are text-level so existing file content (formatting, comments)
|
|
5
|
+
* stays untouched.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CapabilityDoc } from './ir.ts';
|
|
9
|
+
|
|
10
|
+
const RULE_KEYWORDS = ['MUST', 'SHALL', '必须', '不得', '禁止'] as const;
|
|
11
|
+
|
|
12
|
+
export class AuthoringError extends Error {}
|
|
13
|
+
|
|
14
|
+
export interface SpecEntryLike {
|
|
15
|
+
fileName: string;
|
|
16
|
+
doc: CapabilityDoc;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WriteIo {
|
|
20
|
+
exists(path: string): boolean;
|
|
21
|
+
readText(path: string): string;
|
|
22
|
+
writeText(path: string, content: string): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function keywordsOf(content: string): {
|
|
26
|
+
scenario: string;
|
|
27
|
+
given: string;
|
|
28
|
+
when: string;
|
|
29
|
+
thenText: string;
|
|
30
|
+
} {
|
|
31
|
+
return content.includes('功能:')
|
|
32
|
+
? { scenario: '场景', given: '假如', when: '当', thenText: '那么' }
|
|
33
|
+
: { scenario: 'Scenario', given: 'Given', when: 'When', thenText: 'Then' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertRuleWording(statement: string): void {
|
|
37
|
+
if (!RULE_KEYWORDS.some((k) => statement.includes(k))) {
|
|
38
|
+
throw new AuthoringError(
|
|
39
|
+
`statement must contain a rule keyword (${RULE_KEYWORDS.join('/')}): ${statement}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findReq(
|
|
45
|
+
entries: readonly SpecEntryLike[],
|
|
46
|
+
reqId: string,
|
|
47
|
+
): { entry: SpecEntryLike; scenarioName: string; statement: string } | null {
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
for (const scenario of entry.doc.scenarios) {
|
|
50
|
+
if (scenario.reqIds.includes(reqId) && scenario.classification === 'human') {
|
|
51
|
+
return { entry, scenarioName: scenario.name, statement: scenario.statement };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** v1 parity: only @human (rule) req ids participate in the dedupe registry. */
|
|
59
|
+
export function ruleReqIds(entries: readonly SpecEntryLike[]): Set<string> {
|
|
60
|
+
const ids = new Set<string>();
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
for (const scenario of entry.doc.scenarios) {
|
|
63
|
+
if (scenario.classification === 'human') {
|
|
64
|
+
for (const id of scenario.reqIds) ids.add(id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return ids;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function allReqIds(entries: readonly SpecEntryLike[]): Set<string> {
|
|
72
|
+
const ids = new Set<string>();
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
for (const scenario of entry.doc.scenarios) {
|
|
75
|
+
for (const id of scenario.reqIds) ids.add(id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return ids;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface AddReqOpts {
|
|
82
|
+
capability: string;
|
|
83
|
+
reqId: string;
|
|
84
|
+
title: string;
|
|
85
|
+
statement: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Append `@req:<id> @human` rule scenario to `<capability>.feature` (r41). */
|
|
89
|
+
export function addReq(
|
|
90
|
+
io: WriteIo,
|
|
91
|
+
specsRoot: string,
|
|
92
|
+
entries: readonly SpecEntryLike[],
|
|
93
|
+
opts: AddReqOpts,
|
|
94
|
+
): string {
|
|
95
|
+
const path = `${specsRoot}/${opts.capability}.feature`;
|
|
96
|
+
if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
|
|
97
|
+
if (allReqIds(entries).has(opts.reqId)) {
|
|
98
|
+
throw new AuthoringError(`req id already in use: ${opts.reqId}`);
|
|
99
|
+
}
|
|
100
|
+
assertRuleWording(opts.statement);
|
|
101
|
+
const kw = keywordsOf(io.readText(path));
|
|
102
|
+
const block = `\n @req:${opts.reqId} @human\n ${kw.scenario}: ${opts.title}\n ${opts.statement}\n`;
|
|
103
|
+
io.writeText(path, `${io.readText(path).replace(/\n+$/u, '')}${block}`);
|
|
104
|
+
return path;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface AddScenarioOpts {
|
|
108
|
+
capability: string;
|
|
109
|
+
reqId: string;
|
|
110
|
+
scenarioId: string;
|
|
111
|
+
given?: string;
|
|
112
|
+
when: string;
|
|
113
|
+
thenText: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Append `@req:<id> @executable` acceptance scenario (r42). */
|
|
117
|
+
export function addScenario(
|
|
118
|
+
io: WriteIo,
|
|
119
|
+
specsRoot: string,
|
|
120
|
+
entries: readonly SpecEntryLike[],
|
|
121
|
+
opts: AddScenarioOpts,
|
|
122
|
+
): string {
|
|
123
|
+
const path = `${specsRoot}/${opts.capability}.feature`;
|
|
124
|
+
if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
|
|
125
|
+
if (findReq(entries, opts.reqId) === null) {
|
|
126
|
+
throw new AuthoringError(`req id not found: ${opts.reqId}`);
|
|
127
|
+
}
|
|
128
|
+
const kw = keywordsOf(io.readText(path));
|
|
129
|
+
const givenLine =
|
|
130
|
+
opts.given !== undefined && opts.given !== '' ? ` ${kw.given} ${opts.given}\n` : '';
|
|
131
|
+
const block =
|
|
132
|
+
`\n @req:${opts.reqId} @executable\n ${kw.scenario}: ${opts.scenarioId}\n` +
|
|
133
|
+
`${givenLine} ${kw.when} ${opts.when}\n ${kw.thenText} ${opts.thenText}\n`;
|
|
134
|
+
io.writeText(path, `${io.readText(path).replace(/\n+$/u, '')}${block}`);
|
|
135
|
+
return path;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ResolvedReq {
|
|
139
|
+
reqId: string;
|
|
140
|
+
capability: string;
|
|
141
|
+
title: string;
|
|
142
|
+
statement: string;
|
|
143
|
+
harness: string[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Resolve an rN to capability/statement plus bound harness scenarios (r43). */
|
|
147
|
+
export function resolveReq(entries: readonly SpecEntryLike[], reqId: string): ResolvedReq | null {
|
|
148
|
+
const rule = findReq(entries, reqId);
|
|
149
|
+
if (rule === null) return null;
|
|
150
|
+
const capability =
|
|
151
|
+
rule.entry.doc.header.capability ?? rule.entry.fileName.replace(/\.feature$/u, '');
|
|
152
|
+
const harness: string[] = [];
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
for (const scenario of entry.doc.scenarios) {
|
|
155
|
+
if (scenario.classification === 'executable' && scenario.reqIds.includes(reqId)) {
|
|
156
|
+
harness.push(`${entry.fileName}:${scenario.name}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { reqId, capability, title: rule.scenarioName, statement: rule.statement, harness };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface DedupePlanItem {
|
|
164
|
+
reqId: string;
|
|
165
|
+
keepFile: string;
|
|
166
|
+
remapFile: string;
|
|
167
|
+
newReqId: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Plan (and optionally apply) a re-map of globally duplicated rN ids: the
|
|
172
|
+
* first file keeps the id, later files get the next free id (r43).
|
|
173
|
+
*/
|
|
174
|
+
export function planDedupe(
|
|
175
|
+
entries: readonly SpecEntryLike[],
|
|
176
|
+
io: WriteIo,
|
|
177
|
+
specsRoot: string,
|
|
178
|
+
duplicates: readonly { reqId: string; files: string[] }[],
|
|
179
|
+
): DedupePlanItem[] {
|
|
180
|
+
const used = ruleReqIds(entries);
|
|
181
|
+
let next = 1;
|
|
182
|
+
const fresh = (): string => {
|
|
183
|
+
while (used.has(`r${String(next)}`)) next += 1;
|
|
184
|
+
used.add(`r${String(next)}`);
|
|
185
|
+
return `r${String(next)}`;
|
|
186
|
+
};
|
|
187
|
+
const plan: DedupePlanItem[] = [];
|
|
188
|
+
for (const dup of duplicates) {
|
|
189
|
+
const [keep, ...rest] = dup.files;
|
|
190
|
+
for (const remapFile of rest) {
|
|
191
|
+
const newReqId = fresh();
|
|
192
|
+
if (keep !== undefined) {
|
|
193
|
+
plan.push({ reqId: dup.reqId, keepFile: keep, remapFile, newReqId });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const item of plan) {
|
|
198
|
+
// registry duplicates carry bare file names; accept already-rooted paths too
|
|
199
|
+
const path = item.remapFile.startsWith(`${specsRoot}/`)
|
|
200
|
+
? item.remapFile
|
|
201
|
+
: `${specsRoot}/${item.remapFile}`;
|
|
202
|
+
const content = io.readText(path);
|
|
203
|
+
io.writeText(path, content.replaceAll(`@req:${item.reqId}`, `@req:${item.newReqId}`));
|
|
204
|
+
}
|
|
205
|
+
return plan;
|
|
206
|
+
}
|
|
@@ -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
|
+
}
|