@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
package/package.json
CHANGED
package/src/archive/freeze.ts
CHANGED
|
@@ -88,7 +88,7 @@ export async function runFreeze(
|
|
|
88
88
|
export async function runList(io: FreezeIo, sz: SevenZipPort, rootAbs: string): Promise<string[]> {
|
|
89
89
|
const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
|
|
90
90
|
if (!io.exists(archiveAbs)) {
|
|
91
|
-
return [`
|
|
91
|
+
return [`freeze archive not found: ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`];
|
|
92
92
|
}
|
|
93
93
|
// Real 7z lists file paths under their directory (`<dir>/proposal.md`);
|
|
94
94
|
// directory entries themselves are skipped by parseListNames. Derive the
|
|
@@ -117,10 +117,13 @@ export async function runThaw(
|
|
|
117
117
|
sz: SevenZipPort,
|
|
118
118
|
rootAbs: string,
|
|
119
119
|
names: string[],
|
|
120
|
+
opts: { dest?: string } = {},
|
|
120
121
|
): Promise<ThawResult> {
|
|
122
|
+
// r56: restore target override (created on demand); default = changes/archive
|
|
123
|
+
const destRel = opts.dest ?? ARCHIVE_DIR_REL;
|
|
121
124
|
const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
|
|
122
125
|
if (!io.exists(`${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`)) {
|
|
123
|
-
throw new Error(`
|
|
126
|
+
throw new Error(`freeze archive not found: ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`);
|
|
124
127
|
}
|
|
125
128
|
const tmpRel = 'llmanspec/.thaw-tmp';
|
|
126
129
|
const tmpAbs = join(rootAbs, tmpRel);
|
|
@@ -136,15 +139,15 @@ export async function runThaw(
|
|
|
136
139
|
}
|
|
137
140
|
const restored: string[] = [];
|
|
138
141
|
for (const name of names.toSorted()) {
|
|
139
|
-
if (io.exists(`${
|
|
142
|
+
if (io.exists(`${destRel}/${name}`)) {
|
|
140
143
|
throw new Error(`target already exists: ${name}`);
|
|
141
144
|
}
|
|
142
|
-
io.moveDir(`${tmpRel}/${name}`, `${
|
|
145
|
+
io.moveDir(`${tmpRel}/${name}`, `${destRel}/${name}`);
|
|
143
146
|
restored.push(name);
|
|
144
147
|
}
|
|
145
148
|
return {
|
|
146
149
|
restored,
|
|
147
|
-
lines: [`Thawed ${restored.length} selected archived changes to ${
|
|
150
|
+
lines: [`Thawed ${restored.length} selected archived changes to ${destRel}`],
|
|
148
151
|
};
|
|
149
152
|
} finally {
|
|
150
153
|
io.removeDir(tmpRel);
|
package/src/change/id.ts
CHANGED
|
@@ -1,43 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Change id derivation (change-lifecycle capability):
|
|
3
|
-
*
|
|
2
|
+
* Change id derivation (change-lifecycle capability): v1 `change/new.rs`
|
|
3
|
+
* `derive_change_id` parity — pure kebab sanitization (lowercase alnum,
|
|
4
|
+
* separators), no verb requirement, empty/oversized handling.
|
|
4
5
|
*/
|
|
5
|
-
const VERBS = [
|
|
6
|
-
'bootstrap',
|
|
7
|
-
'port',
|
|
8
|
-
'add',
|
|
9
|
-
'update',
|
|
10
|
-
'remove',
|
|
11
|
-
'refactor',
|
|
12
|
-
'fix',
|
|
13
|
-
'release',
|
|
14
|
-
'migrate',
|
|
15
|
-
'init',
|
|
16
|
-
'draft',
|
|
17
|
-
'archive',
|
|
18
|
-
] as const;
|
|
19
6
|
|
|
20
7
|
const ID_CAP = 60;
|
|
21
8
|
|
|
9
|
+
export class ChangeIdError extends Error {}
|
|
10
|
+
|
|
22
11
|
export function isLegalChangeId(id: string): boolean {
|
|
23
|
-
return (
|
|
24
|
-
VERBS.some((v) => id === v || id.startsWith(`${v}-`)) &&
|
|
25
|
-
/^[a-z0-9-]+$/u.test(id) &&
|
|
26
|
-
id.length <= ID_CAP
|
|
27
|
-
);
|
|
12
|
+
return /^[a-z0-9-]+$/u.test(id) && id.length > 0 && id.length <= ID_CAP;
|
|
28
13
|
}
|
|
29
14
|
|
|
15
|
+
/** v1 parity: sanitize to lowercase kebab (ASCII alnum only; CJK/punct dropped). */
|
|
30
16
|
export function deriveChangeId(description: string): string {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
17
|
+
const trimmed = description.trim();
|
|
18
|
+
if (trimmed === '') {
|
|
19
|
+
throw new ChangeIdError('--from <DESCRIPTION> must be non-empty');
|
|
20
|
+
}
|
|
21
|
+
let id = '';
|
|
22
|
+
let prevDash = true;
|
|
23
|
+
for (const ch of trimmed) {
|
|
24
|
+
if (/[a-zA-Z0-9]/u.test(ch)) {
|
|
25
|
+
id += ch.toLowerCase();
|
|
26
|
+
prevDash = false;
|
|
27
|
+
} else if (
|
|
28
|
+
/\s/u.test(ch) ||
|
|
29
|
+
ch === '_' ||
|
|
30
|
+
ch === '.' ||
|
|
31
|
+
ch === '-' ||
|
|
32
|
+
ch === '/' ||
|
|
33
|
+
ch === '\\'
|
|
34
|
+
) {
|
|
35
|
+
if (!prevDash) {
|
|
36
|
+
id += '-';
|
|
37
|
+
prevDash = true;
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
// Punctuation / CJK etc.: drop (CJK intentionally dropped to keep ids
|
|
41
|
+
// ASCII-friendly); a dropped char at a word boundary yields a `-`.
|
|
42
|
+
if (!prevDash) {
|
|
43
|
+
id += '-';
|
|
44
|
+
prevDash = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
while (id.endsWith('-')) id = id.slice(0, -1);
|
|
49
|
+
if (id === '') {
|
|
50
|
+
throw new ChangeIdError(
|
|
51
|
+
'--from <DESCRIPTION> yielded an empty id after sanitizing; provide a description with at least one alphanumeric character',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (id.length > ID_CAP) {
|
|
55
|
+
const cutoff = id.slice(0, ID_CAP).lastIndexOf('-');
|
|
56
|
+
const at = cutoff === -1 ? ID_CAP : cutoff;
|
|
57
|
+
id = id.slice(0, at);
|
|
58
|
+
while (id.endsWith('-')) id = id.slice(0, -1);
|
|
59
|
+
}
|
|
60
|
+
return id;
|
|
41
61
|
}
|
|
42
62
|
|
|
43
63
|
export const DRAFT_PROPOSAL_TEMPLATE = `## Why
|
package/src/change/lifecycle.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
currentBranch,
|
|
3
3
|
defaultBranch,
|
|
4
|
+
dirtyCount,
|
|
4
5
|
isCleanTree,
|
|
6
|
+
mergeBase,
|
|
5
7
|
revParseHead,
|
|
6
8
|
type GitLike,
|
|
7
9
|
} from '../git/spawnGit.ts';
|
|
@@ -33,14 +35,18 @@ export function changeExists(io: FsIo, id: string): boolean {
|
|
|
33
35
|
return io.exists(proposalPath(id));
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
/** `change new [id] --from DESC`: derive a legal id and write the draft shell. */
|
|
38
|
+
/** `change new [id] --from DESC --force`: derive a legal id and write the draft shell. */
|
|
37
39
|
export function newChange(
|
|
38
40
|
io: FsIo,
|
|
39
|
-
opts: { id?: string; from?: string },
|
|
41
|
+
opts: { id?: string; from?: string; force?: boolean },
|
|
40
42
|
): { id: string; path: string } {
|
|
41
43
|
const id = opts.id ?? deriveChangeId(opts.from ?? '');
|
|
42
44
|
const path = proposalPath(id);
|
|
43
|
-
if (io.exists(path)
|
|
45
|
+
if (io.exists(path) && !opts.force) {
|
|
46
|
+
throw new LifecycleError(
|
|
47
|
+
`change proposal already exists: ./${path} (pass --force to overwrite)`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
44
50
|
io.writeText(path, `---\ndepends_on: []\n---\n\n${DRAFT_PROPOSAL_TEMPLATE}`);
|
|
45
51
|
return { id, path };
|
|
46
52
|
}
|
|
@@ -54,35 +60,73 @@ export function startChange(
|
|
|
54
60
|
): { branch: string; baseBranch: string; baseSha: string } {
|
|
55
61
|
const path = proposalPath(id);
|
|
56
62
|
if (!io.exists(path)) throw new LifecycleError(`proposal not found: ${path}`);
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
const dirty = dirtyCount(git);
|
|
64
|
+
if (dirty > 0)
|
|
65
|
+
throw new LifecycleError(
|
|
66
|
+
`dirty tree: ${dirty} uncommitted files; commit/stash before \`change start\``,
|
|
67
|
+
);
|
|
59
68
|
const baseBranch = defaultBranch(git);
|
|
60
69
|
const here = currentBranch(git);
|
|
61
|
-
if (here !== baseBranch) {
|
|
70
|
+
if (here !== null && here !== baseBranch) {
|
|
62
71
|
throw new LifecycleError(
|
|
63
|
-
`
|
|
72
|
+
`already on non-default branch \`${here}\`; use \`change attach\` to bind it, or switch to the default branch before \`change start\``,
|
|
64
73
|
);
|
|
65
74
|
}
|
|
75
|
+
if (here === null) throw new LifecycleError('detached HEAD is not allowed for change binding');
|
|
66
76
|
const branchPrefix = opts.branchPrefix ?? 'sdd/';
|
|
67
77
|
const branch = `${branchPrefix}${id}`;
|
|
68
78
|
git.run(['switch', '-c', branch]);
|
|
69
|
-
const baseSha =
|
|
79
|
+
const baseSha =
|
|
80
|
+
currentBranch(git) !== null ? mergeBase(git, 'HEAD', baseBranch) : revParseHead(git);
|
|
70
81
|
io.writeText(path, writeBinding(io.readText(path), { branch, baseBranch, baseSha }));
|
|
71
82
|
return { branch, baseBranch, baseSha };
|
|
72
83
|
}
|
|
73
84
|
|
|
74
|
-
/** `change attach`: bind the current branch
|
|
75
|
-
export function attachChange(
|
|
85
|
+
/** `change attach`: bind the current branch — same branch gate family as start (r31). */
|
|
86
|
+
export function attachChange(
|
|
87
|
+
git: GitLike,
|
|
88
|
+
io: FsIo,
|
|
89
|
+
id: string,
|
|
90
|
+
opts: { force?: boolean; base?: string } = {},
|
|
91
|
+
): { branch: string; baseBranch: string; baseSha: string } {
|
|
76
92
|
const path = proposalPath(id);
|
|
77
93
|
if (!io.exists(path)) throw new LifecycleError(`proposal not found: ${path}`);
|
|
94
|
+
const existing = readBinding(io.readText(path));
|
|
95
|
+
if (!opts.force && existing !== null) {
|
|
96
|
+
throw new LifecycleError(
|
|
97
|
+
`change \`${id}\` already attached to branch \`${existing.branch}\` (base ${existing.baseSha}); pass --force to rebind`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const configuredBase = opts.base ?? defaultBranch(git);
|
|
78
101
|
const branch = currentBranch(git);
|
|
79
|
-
if (branch === null
|
|
80
|
-
|
|
102
|
+
if (branch === null || branch === '') {
|
|
103
|
+
throw new LifecycleError('detached HEAD is not allowed for change binding');
|
|
104
|
+
}
|
|
105
|
+
if (opts.base !== undefined) {
|
|
106
|
+
if (
|
|
107
|
+
git.runOpt(['show-ref', '--verify', '--quiet', `refs/heads/${opts.base}`]) === null &&
|
|
108
|
+
git.runOpt(['show-ref', '--verify', '--quiet', `refs/remotes/${opts.base}`]) === null
|
|
109
|
+
) {
|
|
110
|
+
throw new LifecycleError(
|
|
111
|
+
`base branch \`${opts.base}\` does not exist; --base records the fork source branch for merge-target resolution (r111)`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (opts.base === branch) {
|
|
115
|
+
throw new LifecycleError(`--base must differ from the bound branch \`${branch}\``);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (branch === configuredBase) {
|
|
119
|
+
throw new LifecycleError(
|
|
120
|
+
`changes must not attach on the default branch (\`${branch}\`); ` +
|
|
121
|
+
'create/switch to a feature branch first (or use `change start`)',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const baseSha = mergeBase(git, branch, configuredBase);
|
|
81
125
|
io.writeText(
|
|
82
126
|
path,
|
|
83
|
-
writeBinding(io.readText(path), { branch, baseBranch:
|
|
127
|
+
writeBinding(io.readText(path), { branch, baseBranch: configuredBase, baseSha }),
|
|
84
128
|
);
|
|
85
|
-
return { branch };
|
|
129
|
+
return { branch, baseBranch: configuredBase, baseSha };
|
|
86
130
|
}
|
|
87
131
|
|
|
88
132
|
export interface FinalizeResult {
|
|
@@ -92,46 +136,149 @@ export interface FinalizeResult {
|
|
|
92
136
|
commitSubject: string;
|
|
93
137
|
}
|
|
94
138
|
|
|
139
|
+
export interface ArchiveTaskGate {
|
|
140
|
+
blocked: boolean;
|
|
141
|
+
reasons: string[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** r40 task gate: unchecked tasks always block; ratio gate when configured. */
|
|
145
|
+
export function archiveTaskGate(
|
|
146
|
+
tasksMd: string | null,
|
|
147
|
+
minCompletionRatio: number | undefined,
|
|
148
|
+
): ArchiveTaskGate {
|
|
149
|
+
const reasons: string[] = [];
|
|
150
|
+
if (tasksMd !== null) {
|
|
151
|
+
let completed = 0;
|
|
152
|
+
let total = 0;
|
|
153
|
+
for (const line of tasksMd.split('\n')) {
|
|
154
|
+
const m = line.match(/^\s*-\s+\[( |x|X)\]/u);
|
|
155
|
+
if (m) {
|
|
156
|
+
total += 1;
|
|
157
|
+
if (m[1] !== ' ') completed += 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (total > 0 && completed < total) {
|
|
161
|
+
reasons.push(`archive blocked by unchecked tasks (${total - completed}/${total} pending)`);
|
|
162
|
+
for (const line of tasksMd.split('\n')) {
|
|
163
|
+
if (/^\s*-\s+\[ \]/u.test(line)) reasons.push(line.trim());
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (minCompletionRatio !== undefined && total > 0 && completed / total < minCompletionRatio) {
|
|
167
|
+
reasons.push(
|
|
168
|
+
`completion ${((completed / total) * 100).toFixed(0)}% below archive.min_completion_ratio ${(minCompletionRatio * 100).toFixed(0)}%`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { blocked: reasons.length > 0, reasons };
|
|
173
|
+
}
|
|
174
|
+
|
|
95
175
|
/** `change finalize`: merge (squash default) + archive rename + close-out commit. */
|
|
96
176
|
export function finalizeChange(
|
|
97
177
|
git: GitLike,
|
|
98
178
|
io: FsIo,
|
|
99
179
|
id: string,
|
|
100
|
-
opts: { into?: string; method?: 'squash' | 'ff'; today?: string } = {},
|
|
180
|
+
opts: { into?: string; method?: 'squash' | 'ff'; today?: string; noCommit?: boolean } = {},
|
|
101
181
|
): FinalizeResult {
|
|
102
182
|
const path = proposalPath(id);
|
|
103
183
|
const binding = readBinding(io.readText(path));
|
|
104
184
|
if (binding === null) {
|
|
105
185
|
throw new LifecycleError(`change \`${id}\` has no branch binding — run start/attach first`);
|
|
106
186
|
}
|
|
187
|
+
// r15 (v1 r94): finalize runs on the bound branch — any other branch must
|
|
188
|
+
// fail before any write (no switch, no merge, no rename).
|
|
189
|
+
const current = currentBranch(git);
|
|
190
|
+
if (current !== binding.branch) {
|
|
191
|
+
throw new LifecycleError(
|
|
192
|
+
`finalize must run on the bound branch \`${binding.branch}\` (current: ${current ?? 'detached HEAD'})`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
107
195
|
const method = opts.method ?? 'squash';
|
|
108
196
|
const target = opts.into ?? binding.baseBranch ?? defaultBranch(git);
|
|
109
|
-
|
|
197
|
+
return mergeRenameCommit(git, io, id, binding.branch, target, method, opts.today, opts.noCommit);
|
|
198
|
+
}
|
|
110
199
|
|
|
111
|
-
|
|
112
|
-
|
|
200
|
+
/** Shared close-out: merge → archive rename → single archive(sdd) commit. */
|
|
201
|
+
function mergeRenameCommit(
|
|
202
|
+
git: GitLike,
|
|
203
|
+
io: FsIo,
|
|
204
|
+
id: string,
|
|
205
|
+
featureBranch: string,
|
|
206
|
+
target: string,
|
|
207
|
+
method: 'squash' | 'ff',
|
|
208
|
+
today?: string,
|
|
209
|
+
noCommit?: boolean,
|
|
210
|
+
): FinalizeResult {
|
|
211
|
+
const warnings: string[] = [];
|
|
113
212
|
git.run(['switch', target]);
|
|
114
213
|
const mergeArgs =
|
|
115
|
-
method === 'ff'
|
|
116
|
-
? ['merge', '--ff-only', binding.branch]
|
|
117
|
-
: ['merge', '--squash', binding.branch];
|
|
214
|
+
method === 'ff' ? ['merge', '--ff-only', featureBranch] : ['merge', '--squash', featureBranch];
|
|
118
215
|
if (git.runOpt(mergeArgs) === null) {
|
|
119
216
|
git.runOpt(['merge', '--abort']);
|
|
120
217
|
warnings.push(
|
|
121
|
-
`merge ${method} failed — resolve manually, e.g. \`git merge ${method === 'ff' ? '--ff-only' : '--squash'} ${
|
|
218
|
+
`merge ${method} failed — resolve manually, e.g. \`git merge ${method === 'ff' ? '--ff-only' : '--squash'} ${featureBranch}\``,
|
|
122
219
|
);
|
|
123
220
|
}
|
|
124
221
|
|
|
125
|
-
const date =
|
|
222
|
+
const date = today ?? new Date().toISOString().slice(0, 10);
|
|
126
223
|
const archiveDir = `${CHANGES_DIR}/archive/${date}-${id}`;
|
|
127
224
|
io.rename(`${CHANGES_DIR}/${id}`, archiveDir);
|
|
128
225
|
|
|
226
|
+
if (noCommit) return { target, archiveDir, warnings, commitSubject: '' };
|
|
129
227
|
git.run(['add', '-A']);
|
|
130
228
|
const commitSubject = `archive(sdd): ${id}`;
|
|
131
229
|
git.run(['commit', '-m', commitSubject]);
|
|
132
230
|
return { target, archiveDir, warnings, commitSubject };
|
|
133
231
|
}
|
|
134
232
|
|
|
233
|
+
export interface ArchiveChangeResult {
|
|
234
|
+
result: FinalizeResult;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `change archive`: independent seal-off with task + strict git gates (r39/r40). */
|
|
238
|
+
export function archiveChange(
|
|
239
|
+
git: GitLike,
|
|
240
|
+
io: FsIo,
|
|
241
|
+
id: string,
|
|
242
|
+
opts: {
|
|
243
|
+
into?: string;
|
|
244
|
+
method?: 'squash' | 'ff';
|
|
245
|
+
force?: boolean;
|
|
246
|
+
minCompletionRatio?: number;
|
|
247
|
+
today?: string;
|
|
248
|
+
} = {},
|
|
249
|
+
): FinalizeResult {
|
|
250
|
+
const path = proposalPath(id);
|
|
251
|
+
const binding = readBinding(io.readText(path));
|
|
252
|
+
if (!opts.force && binding === null) {
|
|
253
|
+
throw new LifecycleError(`change \`${id}\` has no branch binding — run start/attach first`);
|
|
254
|
+
}
|
|
255
|
+
if (!opts.force) {
|
|
256
|
+
const tasksPath = `${CHANGES_DIR}/${id}/tasks.md`;
|
|
257
|
+
const gate = archiveTaskGate(
|
|
258
|
+
io.exists(tasksPath) ? io.readText(tasksPath) : null,
|
|
259
|
+
opts.minCompletionRatio,
|
|
260
|
+
);
|
|
261
|
+
if (gate.blocked) throw new LifecycleError(gate.reasons.join('\n'));
|
|
262
|
+
const current = currentBranch(git);
|
|
263
|
+
if (current === null || current === '')
|
|
264
|
+
throw new LifecycleError('detached HEAD — cannot archive');
|
|
265
|
+
if (current !== binding?.branch) {
|
|
266
|
+
throw new LifecycleError(
|
|
267
|
+
`archive must run on attached branch \`${binding?.branch}\` (current: \`${current}\`)`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (current === defaultBranch(git)) {
|
|
271
|
+
throw new LifecycleError('archive must not run on the default branch');
|
|
272
|
+
}
|
|
273
|
+
if (!isCleanTree(git)) throw new LifecycleError('working tree must be clean to archive');
|
|
274
|
+
} else if (binding === null) {
|
|
275
|
+
throw new LifecycleError(`change \`${id}\` has no branch binding — cannot merge`);
|
|
276
|
+
}
|
|
277
|
+
const method = opts.method ?? 'squash';
|
|
278
|
+
const target = opts.into ?? binding?.baseBranch ?? defaultBranch(git);
|
|
279
|
+
return mergeRenameCommit(git, io, id, binding?.branch as string, target, method, opts.today);
|
|
280
|
+
}
|
|
281
|
+
|
|
135
282
|
/** `change diff <id>`: full diff of the bound branch vs base. */
|
|
136
283
|
export function changeDiff(git: GitLike, io: FsIo, id: string): string {
|
|
137
284
|
const binding = readBinding(io.readText(proposalPath(id)));
|
|
@@ -139,3 +286,19 @@ export function changeDiff(git: GitLike, io: FsIo, id: string): string {
|
|
|
139
286
|
const base = binding.baseBranch ?? defaultBranch(git);
|
|
140
287
|
return git.run(['diff', `${base}...${binding.branch}`]);
|
|
141
288
|
}
|
|
289
|
+
|
|
290
|
+
export interface ChangeDiffInfo {
|
|
291
|
+
change: string;
|
|
292
|
+
branch: string;
|
|
293
|
+
base: string;
|
|
294
|
+
commitCount: number;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** `change diff --json` (r46): structured bound-branch summary. */
|
|
298
|
+
export function changeDiffInfo(git: GitLike, io: FsIo, id: string): ChangeDiffInfo {
|
|
299
|
+
const binding = readBinding(io.readText(proposalPath(id)));
|
|
300
|
+
if (binding === null) throw new LifecycleError(`change \`${id}\` has no branch binding`);
|
|
301
|
+
const count =
|
|
302
|
+
git.runOpt(['rev-list', '--count', `${binding.baseSha}...${binding.branch}`]) ?? '0';
|
|
303
|
+
return { change: id, branch: binding.branch, base: binding.baseSha, commitCount: Number(count) };
|
|
304
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whole-tree change-id number harvest (r35, v1 `change next-id` parity).
|
|
3
|
+
* Read-only: walks directory names at any depth under `llmanspec/` and
|
|
4
|
+
* extracts `c<digits>` tokens at token boundaries — the same value
|
|
5
|
+
* `change new --from` used to inject as `llman_sdd_unique_id`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface NextIdIo {
|
|
9
|
+
listDir(path: string): string[];
|
|
10
|
+
isDirectory(path: string): boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface IdHarvest {
|
|
14
|
+
maxNumber: number | null;
|
|
15
|
+
nextNumber: number;
|
|
16
|
+
warnings: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const C_TOKEN_RE = /(?:^|[^a-z0-9])c([0-9]+)(?:$|[^a-z0-9])/iu;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract the number carried by a change-id-shaped directory name: the first
|
|
23
|
+
* `c<digits>` run at a token boundary (matches `c2790`, `c10-active`, and the
|
|
24
|
+
* `2026-01-01-c20-slug` date-prefixed archive shape; glued names like `cab12`
|
|
25
|
+
* match no position). Leading digits (`3-third`) do not count.
|
|
26
|
+
*/
|
|
27
|
+
export function extractUniqueNumber(name: string): number | null {
|
|
28
|
+
const m = C_TOKEN_RE.exec(name);
|
|
29
|
+
return m ? Number(m[1]) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function harvestUniqueNumbers(io: NextIdIo, root: string): IdHarvest {
|
|
33
|
+
const numbers: number[] = [];
|
|
34
|
+
const warnings: string[] = [];
|
|
35
|
+
const walk = (dir: string): void => {
|
|
36
|
+
let names: string[];
|
|
37
|
+
try {
|
|
38
|
+
names = io.listDir(dir);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
warnings.push(`cannot list ${dir}: ${(error as Error).message}`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const name of names.toSorted()) {
|
|
44
|
+
if (name.startsWith('.')) continue;
|
|
45
|
+
const full = `${dir}/${name}`;
|
|
46
|
+
if (!io.isDirectory(full)) continue;
|
|
47
|
+
const n = extractUniqueNumber(name);
|
|
48
|
+
if (n !== null) numbers.push(n);
|
|
49
|
+
walk(full);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
walk(root);
|
|
53
|
+
const maxNumber = numbers.length > 0 ? Math.max(...numbers) : null;
|
|
54
|
+
return { maxNumber, nextNumber: maxNumber === null ? 1 : maxNumber + 1, warnings };
|
|
55
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change-id prefix resolution (peripheral-commands r61, v1 cli spec r112):
|
|
3
|
+
* exact match > unique prefix > multiple candidates > no match, all
|
|
4
|
+
* case-sensitive. Candidates are the active change ids discovered via
|
|
5
|
+
* collectChanges (depth-limited, archive-skipping). Pure — IO is injected.
|
|
6
|
+
*/
|
|
7
|
+
import { collectChanges, type ChangeFsIo } from '../report/collect.ts';
|
|
8
|
+
|
|
9
|
+
export interface ResolvedChangeId {
|
|
10
|
+
id: string;
|
|
11
|
+
viaPrefix: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ChangeIdResolveError extends Error {}
|
|
15
|
+
|
|
16
|
+
export function resolveChangeId(
|
|
17
|
+
io: ChangeFsIo,
|
|
18
|
+
root: string,
|
|
19
|
+
input: string,
|
|
20
|
+
opts: { maxScanDepth?: number } = {},
|
|
21
|
+
): ResolvedChangeId {
|
|
22
|
+
const ids = collectChanges(io, root, new Date(0), opts).map((c) => c.name);
|
|
23
|
+
if (ids.includes(input)) return { id: input, viaPrefix: false };
|
|
24
|
+
const matches = ids.filter((id) => id.startsWith(input));
|
|
25
|
+
if (matches.length === 1) return { id: matches[0] as string, viaPrefix: true };
|
|
26
|
+
if (matches.length > 1) {
|
|
27
|
+
throw new ChangeIdResolveError(
|
|
28
|
+
`change '${input}' matches multiple active changes:\n${matches
|
|
29
|
+
.map((m) => ` - ${m}`)
|
|
30
|
+
.join('\n')}\nDid you mean one of these?`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
throw new ChangeIdResolveError(`change not found: ${input}`);
|
|
34
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* change_id contract (r59/r60): pattern compile-check at load time, validate
|
|
3
|
+
* ERROR for active changes violating the pattern, and `change new --from`
|
|
4
|
+
* template rendering (nunjucks Strict) with the v1 preset variables.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import nunjucks from 'nunjucks';
|
|
8
|
+
|
|
9
|
+
import { harvestUniqueNumbers, type NextIdIo } from '../change/nextId.ts';
|
|
10
|
+
|
|
11
|
+
export class ChangeIdError extends Error {}
|
|
12
|
+
|
|
13
|
+
/** Compile-check a configured change_id.pattern (r59). */
|
|
14
|
+
export function compileChangeIdPattern(pattern: string | null | undefined): RegExp | null {
|
|
15
|
+
if (pattern === null || pattern === undefined || pattern === '') return null;
|
|
16
|
+
try {
|
|
17
|
+
return new RegExp(pattern, 'u');
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw new ChangeIdError(`change_id.pattern is not a valid regex: ${(error as Error).message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ChangeIdVars {
|
|
24
|
+
/** whole-tree next free number (v1 llman_sdd_unique_id) */
|
|
25
|
+
llman_sdd_unique_id: number;
|
|
26
|
+
/** explicit --verb value; undefined when not provided (Strict render errors) */
|
|
27
|
+
verb?: string;
|
|
28
|
+
/** slugified description subject */
|
|
29
|
+
subject: string;
|
|
30
|
+
/** YYYY-MM-DD */
|
|
31
|
+
date: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Render a change_id.template with v1 preset vars (r60). Strict semantics:
|
|
36
|
+
* referencing a variable that was not provided errors out (v1 parity —
|
|
37
|
+
* `{{ verb }}` without --verb fails).
|
|
38
|
+
*/
|
|
39
|
+
export function renderChangeIdTemplate(template: string, vars: ChangeIdVars): string {
|
|
40
|
+
// Named pre-check (v1 parity): referencing an unprovided variable errors with
|
|
41
|
+
// the variable name and the preset list, instead of a generic render error.
|
|
42
|
+
const provided = new Set(
|
|
43
|
+
Object.keys(vars).filter((k) => vars[k as keyof ChangeIdVars] !== undefined),
|
|
44
|
+
);
|
|
45
|
+
for (const m of template.matchAll(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/gu)) {
|
|
46
|
+
const name = m[1] as string;
|
|
47
|
+
if (!provided.has(name)) {
|
|
48
|
+
throw new ChangeIdError(
|
|
49
|
+
`change_id.template references unprovided variable(s): ${name} — preset vars are llman_sdd_unique_id, verb, subject, date (pass --verb when it uses {{ verb }})`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const env = new nunjucks.Environment(undefined, { throwOnUndefined: true });
|
|
54
|
+
const rendered = env.renderString(template, vars as unknown as Record<string, unknown>).trim();
|
|
55
|
+
if (rendered === '') throw new ChangeIdError('change_id.template rendered to an empty id');
|
|
56
|
+
return rendered;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whole-tree next free number for the llman_sdd_unique_id preset var. */
|
|
60
|
+
export function nextUniqueNumber(io: NextIdIo, llmanspecRoot: string): number {
|
|
61
|
+
return harvestUniqueNumbers(io, llmanspecRoot).nextNumber;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const VERB_TABLE = ['add', 'update', 'remove', 'refactor', 'fix'] as const;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* v1 `new.rs::split_verb` parity: the subject is the derived id minus a
|
|
68
|
+
* detected table-verb prefix; the verb is the explicit override when given,
|
|
69
|
+
* otherwise the auto-detected one (v1 renders `{{ verb }}` without --verb for
|
|
70
|
+
* descriptions that carry a verb token).
|
|
71
|
+
*/
|
|
72
|
+
export function splitVerb(
|
|
73
|
+
derived: string,
|
|
74
|
+
verbOverride: string | undefined,
|
|
75
|
+
): { verb?: string; subject: string } {
|
|
76
|
+
let subject = derived;
|
|
77
|
+
let detected: string | undefined;
|
|
78
|
+
for (const verb of VERB_TABLE) {
|
|
79
|
+
if (derived.startsWith(`${verb}-`)) {
|
|
80
|
+
detected = verb;
|
|
81
|
+
subject = derived.slice(verb.length + 1);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const verb = verbOverride !== undefined ? verbOverride : detected;
|
|
86
|
+
return { verb, subject };
|
|
87
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `config` command surface (r37/r38): read-only overview rendering and
|
|
3
|
+
* non-interactive extra_skills management with comment-preserving writeback.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { parseDocument } from 'yaml';
|
|
7
|
+
|
|
8
|
+
import { loadConfig } from './load.ts';
|
|
9
|
+
import { EXTRA_SKILLS } from './schema.ts';
|
|
10
|
+
|
|
11
|
+
/** Render the five-element overview (v1 parity wording). */
|
|
12
|
+
export function renderConfigOverview(source: string): string[] {
|
|
13
|
+
const config = loadConfig(source);
|
|
14
|
+
const enabled = (config.extra_skills ?? []).length;
|
|
15
|
+
const archive = config.archive;
|
|
16
|
+
const archiveConfigured =
|
|
17
|
+
archive !== null &&
|
|
18
|
+
archive !== undefined &&
|
|
19
|
+
(archive.strict_defer || archive.min_completion_ratio !== undefined);
|
|
20
|
+
return [
|
|
21
|
+
`schema: ${config.schema}`,
|
|
22
|
+
`locale: ${config.locale}`,
|
|
23
|
+
`extra_skills (enabled/total): ${enabled} / ${EXTRA_SKILLS.length}`,
|
|
24
|
+
`bdd: ${config.bdd ? 'on' : 'off'}`,
|
|
25
|
+
`archive: ${archiveConfigured ? 'configured' : 'default'}`,
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ExtraSkillsError extends Error {}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Add/remove extra_skills entries with full comment preservation: only the
|
|
33
|
+
* `extra_skills` list node is touched, everything else (including the
|
|
34
|
+
* `$schema` header comment) stays byte-identical.
|
|
35
|
+
*/
|
|
36
|
+
export function setExtraSkills(
|
|
37
|
+
source: string,
|
|
38
|
+
change: { set?: readonly string[]; unset?: readonly string[] },
|
|
39
|
+
): string {
|
|
40
|
+
for (const name of [...(change.set ?? []), ...(change.unset ?? [])]) {
|
|
41
|
+
if (!(EXTRA_SKILLS as readonly string[]).includes(name)) {
|
|
42
|
+
throw new ExtraSkillsError(`unknown extra skill: ${name}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const wanted = new Set<string>(loadConfig(source).extra_skills ?? []);
|
|
46
|
+
for (const name of change.set ?? []) wanted.add(name);
|
|
47
|
+
for (const name of change.unset ?? []) wanted.delete(name);
|
|
48
|
+
|
|
49
|
+
const doc = parseDocument(source);
|
|
50
|
+
const list = [...wanted].toSorted();
|
|
51
|
+
if (list.length > 0) doc.set('extra_skills', list);
|
|
52
|
+
else doc.delete('extra_skills');
|
|
53
|
+
return doc.toString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Shape of `config skills --json` (v1 parity). */
|
|
57
|
+
export function skillsJson(source: string): { enabled: string[]; available: readonly string[] } {
|
|
58
|
+
return { enabled: [...(loadConfig(source).extra_skills ?? [])], available: EXTRA_SKILLS };
|
|
59
|
+
}
|