@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.
@@ -41,8 +41,16 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
41
41
  ? countTasks(io.readText(`${dir}/tasks.md`))
42
42
  : { completed: 0, total: 0 };
43
43
 
44
- const cleanTree = isCleanTree(git);
45
- const onBoundBranch = binding !== null && currentBranch(git) === binding.branch;
44
+ // v1 parity: show works (gracefully degraded gates) outside a git repo.
45
+ let cleanTree = false;
46
+ let current = null;
47
+ try {
48
+ cleanTree = isCleanTree(git);
49
+ current = currentBranch(git);
50
+ } catch {
51
+ current = null;
52
+ }
53
+ const onBoundBranch = binding !== null && current === binding.branch;
46
54
 
47
55
  // specs landing: bound changes must have touched llmanspec/specs/ since base
48
56
  let specsLanded = false;
@@ -60,7 +68,17 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
60
68
  const specReport = validateAllSpecs(discoverSpecs(deps.specsDir, io), io);
61
69
  const validateOk = !specReport.failed && (total === 0 || tasksDone);
62
70
 
63
- const stageComplete = stage === 'full';
71
+ const stageHint = !hasDesign
72
+ ? 'add design.md (current: draft → designed)'
73
+ : !hasTasks
74
+ ? 'add tasks.md (current: designed → planned)'
75
+ : stage === 'planned'
76
+ ? 'bind via `llman sdd change start <id>` (planned → full)'
77
+ : '';
78
+ const tasksHint =
79
+ total > 0 && completed < total
80
+ ? `${total - completed} unchecked tasks`
81
+ : 'complete or check off remaining tasks';
64
82
 
65
83
  const gateChecks = [
66
84
  {
@@ -71,29 +89,30 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
71
89
  {
72
90
  name: 'on-bound-branch',
73
91
  pass: onBoundBranch,
74
- hint: onBoundBranch ? '' : 'change is not attached; run `llman-sdd change start <id>`',
92
+ hint: onBoundBranch ? '' : 'change is not attached; run `llman sdd change start <id>`',
75
93
  },
76
94
  {
77
95
  name: 'stage-complete',
78
- pass: stageComplete,
79
- hint: stageComplete ? '' : `add ${!hasDesign ? 'design.md' : 'tasks.md'} (current: ${stage})`,
96
+ pass: hasDesign && hasTasks,
97
+ hint: hasDesign && hasTasks ? '' : (stageHint as string),
80
98
  },
81
99
  {
82
100
  name: 'specs-landed',
83
- pass: specsLanded,
84
- hint: specsLanded
85
- ? ''
86
- : 'edit live specs on the bound branch and commit (or needs_specs_change: false)',
101
+ pass: specsLanded || !needsSpecsChange,
102
+ hint:
103
+ specsLanded || !needsSpecsChange
104
+ ? ''
105
+ : 'edit live specs on the bound branch and commit (or needs_specs_change: false)',
87
106
  },
88
107
  {
89
108
  name: 'tasks-done',
90
- pass: tasksDone,
91
- hint: tasksDone ? '' : `${total - completed} unchecked tasks`,
109
+ pass: tasksDone && total > 0,
110
+ hint: tasksDone && total > 0 ? '' : tasksHint,
92
111
  },
93
112
  {
94
113
  name: 'validate',
95
114
  pass: validateOk,
96
- hint: validateOk ? '' : 'fix issues reported by `llman-sdd validate <id> --strict`',
115
+ hint: validateOk ? '' : 'fix issues reported by `llman sdd validate <id> --strict`',
97
116
  },
98
117
  ];
99
118
 
@@ -14,9 +14,12 @@ export interface SpecHelperIo {
14
14
  listDir(path: string): string[];
15
15
  }
16
16
 
17
- /** Scan all specs under `specsDir` for @req:rN TAGS (not step text) and return the next free id. */
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
- let max = 0;
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
- max = Math.max(max, Math.trunc(Number(reqId.replace(/^r/u, ''))));
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
- return `r${max + 1}`;
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;
@@ -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, bindings, boundChangeCount } = input;
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
- const unbound = acceptance.filter((s) => !executableBound(s.tags, bindings));
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
- // staleness is deferred in v2 — placeholder signal (golden normalizes it)
73
- push('stale', cap, 0, 'DEFERRED');
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-sdd change diff <id>\``,
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-sdd validate --all` for details' : 'ok',
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
+ }
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * Embedded template table for single-file binaries.
3
3
  *
4
- * Bun <= 1.4.x has no working embedding mechanism (`assets` is a silent
5
- * no-op, `?raw`/`?asset` fail to resolve, `import.meta.glob` is unavailable
6
- * in compiled output), so templates are injected through the same build-time
7
- * `define` mechanism as LLMAN_SDD_VERSION: build-binary.ts collects
8
- * packages/core/templates into a root-relative path→content map and sets
9
- * process.env.LLMAN_SDD_EMBEDDED_TEMPLATES to that JSON. Bun inlines define
10
- * values as raw expressions — the JSON is a valid object literal, so in the
11
- * compiled binary the expression IS the table object itself, while a JSON
12
- * string reaches these callers through the same expression in other engines
13
- * or when force-set via env. Both forms are accepted below; every non-compiled
14
- * run (source, npm package, Node) leaves the define unset and falls back to
15
- * the real filesystem via TEMPLATES_ROOT.
4
+ * Bun 1.4.x offers no embedding that survives our Node >= 24 dual-runtime
5
+ * constraint: `with { type: "text" }` works in compiled output (probed on
6
+ * 1.4.2) but Node cannot load that attribute, and `assets`/`?raw`/`?asset`
7
+ * never land in the bundle — so templates are injected through the same
8
+ * build-time `define` mechanism as LLMAN_SDD_VERSION: build-binary.ts
9
+ * collects packages/core/templates into a root-relative path→content map and
10
+ * sets process.env.LLMAN_SDD_EMBEDDED_TEMPLATES to that JSON. Bun inlines
11
+ * define values as raw expressions — the JSON is a valid object literal, so
12
+ * in the compiled binary the expression IS the table object itself, while a
13
+ * JSON string reaches these callers through the same expression in other
14
+ * engines or when force-set via env. Both forms are accepted below; every
15
+ * non-compiled run (source, npm package, Node) leaves the define unset and
16
+ * falls back to the real filesystem via TEMPLATES_ROOT.
16
17
  */
17
18
  import type { TemplateIo } from './skills.ts';
18
19