@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.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Staleness evaluator (validation/staleness parity): v1 `sdd/spec/staleness.rs`
3
+ * observable contract — status/baseRef/scope/touchedPaths/specUpdated/dirty/
4
+ * notes plus per-capability staleness issues. Pure: git + env are injected.
5
+ */
6
+
7
+ import type { GitLike } from '../git/spawnGit.ts';
8
+
9
+ export type StalenessStatus = 'OK' | 'INFO' | 'WARN' | 'STALE' | 'NOTAPPLICABLE';
10
+
11
+ export interface StalenessInfo {
12
+ status: StalenessStatus;
13
+ baseRef: string | null;
14
+ scope: string[];
15
+ touchedPaths: string[];
16
+ specUpdated: boolean;
17
+ dirty: boolean;
18
+ notes: string[];
19
+ }
20
+
21
+ export interface StalenessIssue {
22
+ level: 'ERROR' | 'WARNING' | 'INFO';
23
+ path: string;
24
+ message: string;
25
+ }
26
+
27
+ export interface StalenessDeps {
28
+ git: GitLike;
29
+ root: string;
30
+ /** spec file path relative to root, e.g. `llmanspec/specs/auth.feature` */
31
+ specRel: string;
32
+ /** scope entries from the `# scope:` header (raw, unnormalized) */
33
+ scope: string[];
34
+ /** env override for LLMANSPEC_BASE_REF */
35
+ baseRefEnv?: string;
36
+ defaultBranchName?: string;
37
+ }
38
+
39
+ const normalizePath = (value: string): string =>
40
+ value.trim().replace(/^\.\//u, '').replace(/^\/+/u, '').replace(/\/+$/u, '');
41
+
42
+ const scopeMatches = (path: string, scope: readonly string[]): boolean => {
43
+ const p = normalizePath(path);
44
+ return scope.some((s) => p === s || p.startsWith(`${s}/`));
45
+ };
46
+
47
+ export function notApplicableStaleness(): StalenessInfo {
48
+ return {
49
+ status: 'NOTAPPLICABLE',
50
+ baseRef: null,
51
+ scope: [],
52
+ touchedPaths: [],
53
+ specUpdated: false,
54
+ dirty: false,
55
+ notes: [],
56
+ };
57
+ }
58
+
59
+ const DIRTY_MSG = 'Working tree is dirty; results may be unreliable.';
60
+ const SCOPE_MISSING_MSG = 'Note: Spec validation scope is missing.';
61
+ const BASE_MISSING_MSG =
62
+ "Note: Unable to resolve base ref for staleness check. Set LLMANSPEC_BASE_REF (e.g. 'main' or 'origin/main'), or upgrade llman if below 0.0.60.";
63
+
64
+ /**
65
+ * Evaluate staleness for one capability spec — v1 status semantics.
66
+ * Returns the info object plus warning/info issues (strict escalation is the
67
+ * caller's job, matching v1 `apply_strict`).
68
+ */
69
+ export function evaluateStaleness(deps: StalenessDeps): {
70
+ info: StalenessInfo;
71
+ issues: StalenessIssue[];
72
+ } {
73
+ const { git, specRel, scope, baseRefEnv } = deps;
74
+ const issues: StalenessIssue[] = [];
75
+ const notes: string[] = [];
76
+ const normScope = scope.map(normalizePath).filter((s) => s !== '');
77
+
78
+ const rawBaseRef =
79
+ baseRefEnv !== undefined && baseRefEnv.trim() !== '' ? baseRefEnv.trim() : null;
80
+ let baseRef: string | null = null;
81
+ let mergeBase: string | null = null;
82
+
83
+ if (rawBaseRef !== null) {
84
+ baseRef = rawBaseRef;
85
+ mergeBase = revParse(git, rawBaseRef);
86
+ } else {
87
+ const defaultBranchName = deps.defaultBranchName ?? defaultBranchNameFn(git);
88
+ mergeBase = git.runOpt(['merge-base', defaultBranchName, 'HEAD']);
89
+ if (mergeBase !== null) baseRef = mergeBase;
90
+ }
91
+
92
+ let status: StalenessStatus = 'OK';
93
+ if (normScope.length === 0) {
94
+ status = 'WARN';
95
+ notes.push(SCOPE_MISSING_MSG);
96
+ issues.push({ level: 'WARNING', path: `${specId()}/staleness`, message: SCOPE_MISSING_MSG });
97
+ }
98
+ if (mergeBase === null && rawBaseRef === null) {
99
+ status = 'WARN';
100
+ notes.push(BASE_MISSING_MSG);
101
+ issues.push({ level: 'WARNING', path: `${specId()}/staleness`, message: BASE_MISSING_MSG });
102
+ }
103
+
104
+ let touchedPaths: string[] = [];
105
+ let specUpdated = false;
106
+ if (status !== 'WARN' && baseRef !== null && mergeBase !== null) {
107
+ const diff = git.runOpt(['diff', '--name-only', `${baseRef}...HEAD`]) ?? '';
108
+ const paths =
109
+ diff === ''
110
+ ? []
111
+ : diff
112
+ .split('\n')
113
+ .map((l) => l.trim())
114
+ .filter((l) => l !== '');
115
+ if (paths.length > 0) {
116
+ specUpdated = paths.some((p) => normalizePath(p) === normalizePath(specRel));
117
+ touchedPaths = paths.filter((p) => scopeMatches(p, normScope));
118
+ }
119
+ if (touchedPaths.length > 0 && !specUpdated) {
120
+ status = 'STALE' as StalenessStatus;
121
+ issues.push({ level: 'WARNING', path: `${specId()}/staleness`, message: STALE_MSG });
122
+ } else if (specUpdated && touchedPaths.length === 0) {
123
+ status = 'INFO';
124
+ notes.push(SPEC_UPDATED_MSG);
125
+ }
126
+ }
127
+
128
+ // Tolerant: outside a git repo (or on git failure) treat as dirty (v1 unwrap_or(true)).
129
+ const dirty = (git.runOpt(['status', '--porcelain']) ?? 'dirty') !== '';
130
+ if (dirty) {
131
+ if (status === 'OK' || status === 'STALE') status = 'INFO';
132
+ notes.push(DIRTY_MSG);
133
+ issues.push({ level: 'INFO', path: `${specId()}/staleness`, message: DIRTY_MSG });
134
+ }
135
+
136
+ return {
137
+ info: { status, baseRef, scope: normScope, touchedPaths, specUpdated, dirty, notes },
138
+ issues,
139
+ };
140
+
141
+ function specId(): string {
142
+ return deps.specRel.replace(/^llmanspec\/specs\//u, '').replace(/\.feature$/u, '');
143
+ }
144
+ }
145
+
146
+ const STALE_MSG = 'Note: Spec files changed on the base branch; re-review the spec.';
147
+ const SPEC_UPDATED_MSG = 'Note: Spec updated on this branch.';
148
+
149
+ function revParse(git: GitLike, ref: string): string | null {
150
+ return git.runOpt(['rev-parse', '--verify', '--quiet', ref]) ?? null;
151
+ }
152
+
153
+ function defaultBranchNameFn(git: GitLike): string {
154
+ if (git.runOpt(['show-ref', '--verify', '--quiet', 'refs/heads/main']) !== null) return 'main';
155
+ if (git.runOpt(['show-ref', '--verify', '--quiet', 'refs/heads/master']) !== null)
156
+ return 'master';
157
+ return 'main';
158
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Validation engine (validation capability): aggregates Phase-2 parse errors
3
- * plus the verdict-equivalent gates probed from v1 (r11/r12). Pure —
4
- * filesystem access is injected via SpecIo.
3
+ * plus the verdict-equivalent gates (r11/r12, ordered to match v1
4
+ * `spec/validation.rs` observable issue order). Pure — filesystem access is
5
+ * injected via SpecIo.
5
6
  */
6
7
  import type { CapabilityDoc } from '../spec/ir.ts';
7
8
  import { MUST_WORD_RE } from '../spec/ir.ts';
@@ -11,7 +12,7 @@ export type ValidationLevel = 'ERROR' | 'WARNING' | 'INFO';
11
12
 
12
13
  export interface ValidationItem {
13
14
  level: ValidationLevel;
14
- /** Gate anchor, e.g. `t/rule/ok` or `t/valid_scope` (v1-style). */
15
+ /** Gate anchor, e.g. `t/rule/ok` or `t/valid_scope` (v1-style `path`). */
15
16
  id: string;
16
17
  message: string;
17
18
  }
@@ -39,79 +40,148 @@ export interface ValidationReport {
39
40
  failed: boolean;
40
41
  }
41
42
 
43
+ const rulesPath = (cap: string): string => `${cap}/rules`;
44
+ const acceptanceReqPath = (cap: string, name: string): string => `${cap}/acceptance/${name}/@req`;
45
+ const coveragePath = (cap: string): string => `${cap}/coverage`;
46
+
42
47
  export function validateCapability(
43
48
  entry: SpecEntry,
44
49
  duplicatesFor: (reqId: string) => boolean,
45
50
  io: SpecIo,
51
+ opts: { strict?: boolean } = {},
46
52
  ): SpecVerdict {
47
53
  const { doc } = entry;
48
54
  const cap = doc.header.capability ?? entry.fileName;
55
+ const strict = opts.strict === true;
49
56
  const items: ValidationItem[] = [];
50
57
 
51
- // Header gates (r12): capability header is an ERROR (v1 parity); missing
52
- // purpose/scope degrade to WARNING.
58
+ const push = (level: ValidationLevel, id: string, message: string): void => {
59
+ items.push({ level, id, message });
60
+ };
61
+
62
+ // Header gates (r12 / v1 spec_meta). v1 treats a missing `# capability:`
63
+ // header as a parse-level failure: only `file` + registry-scan issues are
64
+ // emitted and all single-track gates are skipped.
53
65
  if (doc.header.capability === null) {
54
- items.push({
55
- level: 'ERROR',
56
- id: 'file',
57
- message: 'missing `# capability:` header comment',
58
- });
66
+ const msg = `spec \`${cap}\`: missing \`# capability:\` header comment (spec-format r133)`;
67
+ push('ERROR', 'file', msg);
68
+ push('ERROR', 'llmanspec/specs', `Failed to scan req_id index: ${msg}`);
69
+ return { fileName: entry.fileName, capability: cap, ok: false, items };
59
70
  }
60
- if (doc.header.purpose === null) {
61
- items.push({ level: 'WARNING', id: 'file', message: 'missing `# purpose:` header comment' });
71
+ if (doc.header.purpose === null || doc.header.purpose.trim() === '') {
72
+ push(
73
+ 'ERROR',
74
+ `${cap}/purpose`,
75
+ '`# purpose:` header comment must not be empty (spec-format r133)',
76
+ );
62
77
  }
63
78
  if (doc.header.scope === null) {
64
- items.push({ level: 'WARNING', id: 'file', message: 'missing `# scope:` header comment' });
79
+ push(
80
+ 'ERROR',
81
+ `${cap}/valid_scope`,
82
+ 'Spec valid_scope must not be empty (add it inside the .toon document).',
83
+ );
65
84
  } else {
66
- for (const p of doc.header.scope
85
+ // r42: missing valid_scope paths are independent failures —
86
+ // ERROR (nonzero exit) under --strict, WARNING otherwise.
87
+ const missing = doc.header.scope
67
88
  .split(',')
68
89
  .map((s) => s.trim())
69
- .filter((s) => s !== '')) {
70
- if (!io.exists(p)) {
71
- items.push({
72
- level: 'ERROR',
73
- id: `${cap}/valid_scope`,
74
- message: `valid_scope path(s) do not exist on disk: ${p}`,
75
- });
76
- }
90
+ .filter((s) => s !== '')
91
+ .filter((s) => !io.exists(s));
92
+ if (missing.length > 0) {
93
+ push(
94
+ strict ? 'ERROR' : 'WARNING',
95
+ `${cap}/valid_scope`,
96
+ `valid_scope path(s) do not exist on disk: ${missing.join(', ')}`,
97
+ );
77
98
  }
78
99
  }
79
100
 
80
- // Structural errors from parsing (mutual exclusion, manual orphan, nested
81
- // rule scenarios) — all ERROR level (v1 verdict parity). Header gates and
82
- // the MUST-word gate are owned by this layer (mapped below), so the
83
- // parser's duplicate findings are skipped here.
101
+ // Spec name must match the capability id (flat stem).
102
+ if (doc.header.capability !== null && doc.header.capability.trim() !== cap) {
103
+ push(
104
+ 'WARNING',
105
+ `${cap}/meta.name`,
106
+ `Spec \`# capability:\` header must match spec id (flat file stem or directory name): \`${doc.header.capability.trim()}\` != \`${cap}\``,
107
+ );
108
+ }
109
+
110
+ // Feature title must be non-empty.
111
+ if ((doc.featureName ?? '').trim() === '') {
112
+ push('ERROR', `${cap}/feature`, 'Feature line must carry a title');
113
+ }
114
+
115
+ // Parser-level structural errors (mutual exclusion, manual orphan, nested
116
+ // rule scenarios). Header gates and the MUST-word gate are owned here
117
+ // (mapped below), so the parser's duplicate findings are skipped.
84
118
  const OWNED_BY_THIS_LAYER = ['missing-header:', 'rule:missing-must-word'];
85
119
  for (const err of doc.errors) {
86
120
  if (OWNED_BY_THIS_LAYER.some((prefix) => err.code.startsWith(prefix))) continue;
87
- items.push({ level: 'ERROR', id: `${cap}/${err.code}`, message: err.message });
121
+ push('ERROR', err.code.startsWith('file') ? 'file' : `${cap}/${err.code}`, err.message);
122
+ }
123
+
124
+ // Single-track gates (v1 validate_single_track order).
125
+ const human = doc.scenarios.filter((s) => s.classification === 'human');
126
+ const acceptance = doc.scenarios.filter((s) => s.classification === 'executable');
127
+
128
+ if (human.length === 0) {
129
+ push('ERROR', rulesPath(cap), 'spec must define at least one @human constraint scenario');
88
130
  }
89
131
 
90
132
  for (const scenario of doc.scenarios) {
91
133
  const anchor = `${cap}/rule/${scenario.name}`;
92
134
  if (scenario.classification === 'human') {
93
135
  if (scenario.reqIds.length === 0) {
94
- items.push({
95
- level: 'ERROR',
96
- id: anchor,
97
- message: '@human constraint scenario must carry an @req:<req_id> tag',
98
- });
136
+ push('ERROR', anchor, '@human constraint scenario must carry an @req:<req_id> tag');
99
137
  }
100
138
  if (!MUST_WORD_RE.test(scenario.statement)) {
101
- items.push({
102
- level: 'ERROR',
103
- id: anchor,
104
- message: 'constraint statement must contain MUST/SHALL (or 必须/不得/禁止)',
105
- });
139
+ push('ERROR', anchor, 'constraint statement must contain MUST/SHALL (or 必须/不得/禁止)');
106
140
  }
141
+ } else if (scenario.classification === 'executable') {
142
+ // (pairing handled below, after all rule req ids are known)
143
+ } else {
144
+ push(
145
+ 'WARNING',
146
+ `${cap}/scenario/${scenario.name}`,
147
+ `scenario \`${scenario.name}\` carries neither @human nor @executable; tag it or drop it`,
148
+ );
107
149
  }
108
150
  for (const reqId of scenario.reqIds) {
109
151
  if (duplicatesFor(reqId)) {
110
- items.push({
111
- level: 'ERROR',
112
- id: `${cap}/registry/${reqId}`,
113
- message: `global duplicate req_id \`${reqId}\` used by multiple capabilities`,
114
- });
152
+ push(
153
+ 'ERROR',
154
+ `${cap}/registry/${reqId}`,
155
+ `global duplicate req_id \`${reqId}\` used by multiple capabilities`,
156
+ );
157
+ }
158
+ }
159
+ }
160
+
161
+ // Dangling acceptance @req links (v1 order: acceptance/@req then coverage).
162
+ const ruleReqIds = new Set(human.flatMap((s) => s.reqIds));
163
+ for (const sc of acceptance) {
164
+ for (const rid of sc.reqIds) {
165
+ if (!ruleReqIds.has(rid)) {
166
+ push(
167
+ 'ERROR',
168
+ acceptanceReqPath(cap, sc.name),
169
+ `@req:${rid} on acceptance scenario \`${sc.name}\` has no matching @human constraint`,
170
+ );
171
+ }
172
+ }
173
+ }
174
+
175
+ // Rule coverage INFO (r134 pending rules).
176
+ for (const sc of human) {
177
+ for (const rid of sc.reqIds) {
178
+ const covered = acceptance.some((a) => a.reqIds.includes(rid));
179
+ if (!covered) {
180
+ push(
181
+ 'INFO',
182
+ coveragePath(cap),
183
+ `rule ${rid} is pending: no @executable acceptance scenario and no @manual waiver`,
184
+ );
115
185
  }
116
186
  }
117
187
  }
@@ -149,3 +219,8 @@ export function validateAllSpecs(entries: readonly SpecEntry[], io: SpecIo): Val
149
219
 
150
220
  return { verdicts, lines, failed };
151
221
  }
222
+
223
+ /** v1 `apply_strict`: WARNING issues escalate to ERROR when --strict. */
224
+ export function applyStrict<T extends { level: ValidationLevel }>(items: readonly T[]): T[] {
225
+ return items.map((i) => (i.level === 'WARNING' ? { ...i, level: 'ERROR' as const } : i));
226
+ }