@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
|
@@ -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
|
+
}
|