@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.
- package/package.json +1 -1
- package/src/archive/freeze.ts +14 -9
- package/src/archive/sevenzip.ts +34 -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 +53 -2
- 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/templates/embedded.ts +13 -12
- package/src/validation/changeCheck.ts +310 -0
- package/src/validation/staleness.ts +158 -0
- package/src/validation/validate.ts +117 -42
package/package.json
CHANGED
package/src/archive/freeze.ts
CHANGED
|
@@ -88,12 +88,14 @@ 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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
93
|
+
// Real 7z lists file paths under their directory (`<dir>/proposal.md`);
|
|
94
|
+
// directory entries themselves are skipped by parseListNames. Derive the
|
|
95
|
+
// archived change names from the leading path segment.
|
|
96
|
+
const entries = (await sz.listEntries(archiveAbs)).map((n) => n.replace(/\/$/u, ''));
|
|
97
|
+
const top = (n: string): string => n.split('/')[0] ?? n;
|
|
98
|
+
const unique = [...new Set(entries.map(top).filter((n) => DATED_RE.test(n)))].toSorted();
|
|
97
99
|
if (unique.length === 0) {
|
|
98
100
|
return [
|
|
99
101
|
`Freeze archive ${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME} contains no archived changes`,
|
|
@@ -115,10 +117,13 @@ export async function runThaw(
|
|
|
115
117
|
sz: SevenZipPort,
|
|
116
118
|
rootAbs: string,
|
|
117
119
|
names: string[],
|
|
120
|
+
opts: { dest?: string } = {},
|
|
118
121
|
): Promise<ThawResult> {
|
|
122
|
+
// r56: restore target override (created on demand); default = changes/archive
|
|
123
|
+
const destRel = opts.dest ?? ARCHIVE_DIR_REL;
|
|
119
124
|
const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
|
|
120
125
|
if (!io.exists(`${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`)) {
|
|
121
|
-
throw new Error(`
|
|
126
|
+
throw new Error(`freeze archive not found: ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`);
|
|
122
127
|
}
|
|
123
128
|
const tmpRel = 'llmanspec/.thaw-tmp';
|
|
124
129
|
const tmpAbs = join(rootAbs, tmpRel);
|
|
@@ -134,15 +139,15 @@ export async function runThaw(
|
|
|
134
139
|
}
|
|
135
140
|
const restored: string[] = [];
|
|
136
141
|
for (const name of names.toSorted()) {
|
|
137
|
-
if (io.exists(`${
|
|
142
|
+
if (io.exists(`${destRel}/${name}`)) {
|
|
138
143
|
throw new Error(`target already exists: ${name}`);
|
|
139
144
|
}
|
|
140
|
-
io.moveDir(`${tmpRel}/${name}`, `${
|
|
145
|
+
io.moveDir(`${tmpRel}/${name}`, `${destRel}/${name}`);
|
|
141
146
|
restored.push(name);
|
|
142
147
|
}
|
|
143
148
|
return {
|
|
144
149
|
restored,
|
|
145
|
-
lines: [`Thawed ${restored.length} selected archived changes to ${
|
|
150
|
+
lines: [`Thawed ${restored.length} selected archived changes to ${destRel}`],
|
|
146
151
|
};
|
|
147
152
|
} finally {
|
|
148
153
|
io.removeDir(tmpRel);
|
package/src/archive/sevenzip.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
1
2
|
import { mkdirSync } from 'node:fs';
|
|
2
3
|
import { basename, join } from 'node:path';
|
|
3
4
|
|
|
@@ -7,6 +8,13 @@ import { basename, join } from 'node:path';
|
|
|
7
8
|
* instance per operation; extraction target must be a fresh directory
|
|
8
9
|
* (WASM 7z refuses existing -o dirs). Spike-proven on Bun: compress / list /
|
|
9
10
|
* extract keep directory structure.
|
|
11
|
+
*
|
|
12
|
+
* Compiled single-file binaries have no on-disk 7zz.wasm (Emscripten would
|
|
13
|
+
* probe $bunfs and abort), so build-binary.ts additionally injects the wasm
|
|
14
|
+
* base64 via the literal define `process.env.LLMAN_SDD_EMBEDDED_7ZZ_WASM_B64`
|
|
15
|
+
* (define only rewrites literal member access — do not route through a
|
|
16
|
+
* variable). Unset in source/npm/Node runs → glue loads the .wasm from disk
|
|
17
|
+
* as before.
|
|
10
18
|
*/
|
|
11
19
|
import SevenZip from '7z-wasm';
|
|
12
20
|
|
|
@@ -35,14 +43,34 @@ type ModuleFactory = (opts: {
|
|
|
35
43
|
noInitialRun: boolean;
|
|
36
44
|
print?: (text: string) => void;
|
|
37
45
|
printErr?: (text: string) => void;
|
|
46
|
+
wasmBinary?: Uint8Array;
|
|
38
47
|
}) => Promise<EmscriptenModule>;
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d] as const;
|
|
50
|
+
|
|
51
|
+
/** Decode a base64 wasm blob; undefined when absent or not a WASM binary.
|
|
52
|
+
* Base64 decoding itself never throws, so the magic bytes are the guard. */
|
|
53
|
+
export function resolveEmbeddedWasmB64(value: unknown): Uint8Array | undefined {
|
|
54
|
+
if (typeof value !== 'string' || value === '') return undefined;
|
|
55
|
+
const bytes = Buffer.from(value, 'base64');
|
|
56
|
+
if (bytes.length < 4 || WASM_MAGIC.some((b, i) => bytes[i] !== b)) return undefined;
|
|
57
|
+
return new Uint8Array(bytes);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read the build-injected wasm; undefined when absent or malformed. */
|
|
61
|
+
export function embeddedWasmBinary(): Uint8Array | undefined {
|
|
62
|
+
return resolveEmbeddedWasmB64(process.env.LLMAN_SDD_EMBEDDED_7ZZ_WASM_B64);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function initModule(
|
|
66
|
+
print?: (text: string) => void,
|
|
67
|
+
wasmBinary?: Uint8Array,
|
|
68
|
+
): Promise<EmscriptenModule> {
|
|
41
69
|
const sink = (text: string): void => {
|
|
42
70
|
if (print) print(text);
|
|
43
71
|
};
|
|
44
72
|
const factory = SevenZip as unknown as ModuleFactory;
|
|
45
|
-
return factory({ noInitialRun: true, print: sink, printErr: sink });
|
|
73
|
+
return factory({ noInitialRun: true, print: sink, printErr: sink, wasmBinary });
|
|
46
74
|
}
|
|
47
75
|
|
|
48
76
|
function mount(mod: EmscriptenModule, seq: number, hostDir: string): string {
|
|
@@ -78,9 +106,10 @@ function parseListNames(lines: string[]): string[] {
|
|
|
78
106
|
}
|
|
79
107
|
|
|
80
108
|
export async function makeWasmSevenZip(): Promise<SevenZipPort> {
|
|
109
|
+
const wasmBinary = embeddedWasmBinary();
|
|
81
110
|
return {
|
|
82
111
|
async add(archivePath: string, baseDir: string, entries: string[]): Promise<void> {
|
|
83
|
-
const mod = await initModule();
|
|
112
|
+
const mod = await initModule(undefined, wasmBinary);
|
|
84
113
|
const archMount = mount(mod, 1, join(archivePath, '..'));
|
|
85
114
|
const baseMount = mount(mod, 2, baseDir);
|
|
86
115
|
mod.FS.chdir(baseMount);
|
|
@@ -90,7 +119,7 @@ export async function makeWasmSevenZip(): Promise<SevenZipPort> {
|
|
|
90
119
|
|
|
91
120
|
async listEntries(archivePath: string): Promise<string[]> {
|
|
92
121
|
const lines: string[] = [];
|
|
93
|
-
const mod = await initModule((text) => lines.push(text));
|
|
122
|
+
const mod = await initModule((text) => lines.push(text), wasmBinary);
|
|
94
123
|
const archMount = mount(mod, 1, join(archivePath, '..'));
|
|
95
124
|
const rc = mod.callMain(['l', `${archMount}/${basename(archivePath)}`]);
|
|
96
125
|
if (rc !== 0) throw new SevenZipError(`7z list failed (rc=${rc})`);
|
|
@@ -98,7 +127,7 @@ export async function makeWasmSevenZip(): Promise<SevenZipPort> {
|
|
|
98
127
|
},
|
|
99
128
|
|
|
100
129
|
async extractAll(archivePath: string, destDir: string): Promise<void> {
|
|
101
|
-
const mod = await initModule();
|
|
130
|
+
const mod = await initModule(undefined, wasmBinary);
|
|
102
131
|
const archMount = mount(mod, 1, join(archivePath, '..'));
|
|
103
132
|
mkdirSync(destDir, { recursive: true });
|
|
104
133
|
const destMount = mount(mod, 2, destDir);
|
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,12 +136,48 @@ 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));
|
|
@@ -106,32 +186,91 @@ export function finalizeChange(
|
|
|
106
186
|
}
|
|
107
187
|
const method = opts.method ?? 'squash';
|
|
108
188
|
const target = opts.into ?? binding.baseBranch ?? defaultBranch(git);
|
|
109
|
-
|
|
189
|
+
return mergeRenameCommit(git, io, id, binding.branch, target, method, opts.today, opts.noCommit);
|
|
190
|
+
}
|
|
110
191
|
|
|
111
|
-
|
|
112
|
-
|
|
192
|
+
/** Shared close-out: merge → archive rename → single archive(sdd) commit. */
|
|
193
|
+
function mergeRenameCommit(
|
|
194
|
+
git: GitLike,
|
|
195
|
+
io: FsIo,
|
|
196
|
+
id: string,
|
|
197
|
+
featureBranch: string,
|
|
198
|
+
target: string,
|
|
199
|
+
method: 'squash' | 'ff',
|
|
200
|
+
today?: string,
|
|
201
|
+
noCommit?: boolean,
|
|
202
|
+
): FinalizeResult {
|
|
203
|
+
const warnings: string[] = [];
|
|
113
204
|
git.run(['switch', target]);
|
|
114
205
|
const mergeArgs =
|
|
115
|
-
method === 'ff'
|
|
116
|
-
? ['merge', '--ff-only', binding.branch]
|
|
117
|
-
: ['merge', '--squash', binding.branch];
|
|
206
|
+
method === 'ff' ? ['merge', '--ff-only', featureBranch] : ['merge', '--squash', featureBranch];
|
|
118
207
|
if (git.runOpt(mergeArgs) === null) {
|
|
119
208
|
git.runOpt(['merge', '--abort']);
|
|
120
209
|
warnings.push(
|
|
121
|
-
`merge ${method} failed — resolve manually, e.g. \`git merge ${method === 'ff' ? '--ff-only' : '--squash'} ${
|
|
210
|
+
`merge ${method} failed — resolve manually, e.g. \`git merge ${method === 'ff' ? '--ff-only' : '--squash'} ${featureBranch}\``,
|
|
122
211
|
);
|
|
123
212
|
}
|
|
124
213
|
|
|
125
|
-
const date =
|
|
214
|
+
const date = today ?? new Date().toISOString().slice(0, 10);
|
|
126
215
|
const archiveDir = `${CHANGES_DIR}/archive/${date}-${id}`;
|
|
127
216
|
io.rename(`${CHANGES_DIR}/${id}`, archiveDir);
|
|
128
217
|
|
|
218
|
+
if (noCommit) return { target, archiveDir, warnings, commitSubject: '' };
|
|
129
219
|
git.run(['add', '-A']);
|
|
130
220
|
const commitSubject = `archive(sdd): ${id}`;
|
|
131
221
|
git.run(['commit', '-m', commitSubject]);
|
|
132
222
|
return { target, archiveDir, warnings, commitSubject };
|
|
133
223
|
}
|
|
134
224
|
|
|
225
|
+
export interface ArchiveChangeResult {
|
|
226
|
+
result: FinalizeResult;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** `change archive`: independent seal-off with task + strict git gates (r39/r40). */
|
|
230
|
+
export function archiveChange(
|
|
231
|
+
git: GitLike,
|
|
232
|
+
io: FsIo,
|
|
233
|
+
id: string,
|
|
234
|
+
opts: {
|
|
235
|
+
into?: string;
|
|
236
|
+
method?: 'squash' | 'ff';
|
|
237
|
+
force?: boolean;
|
|
238
|
+
minCompletionRatio?: number;
|
|
239
|
+
today?: string;
|
|
240
|
+
} = {},
|
|
241
|
+
): FinalizeResult {
|
|
242
|
+
const path = proposalPath(id);
|
|
243
|
+
const binding = readBinding(io.readText(path));
|
|
244
|
+
if (!opts.force && binding === null) {
|
|
245
|
+
throw new LifecycleError(`change \`${id}\` has no branch binding — run start/attach first`);
|
|
246
|
+
}
|
|
247
|
+
if (!opts.force) {
|
|
248
|
+
const tasksPath = `${CHANGES_DIR}/${id}/tasks.md`;
|
|
249
|
+
const gate = archiveTaskGate(
|
|
250
|
+
io.exists(tasksPath) ? io.readText(tasksPath) : null,
|
|
251
|
+
opts.minCompletionRatio,
|
|
252
|
+
);
|
|
253
|
+
if (gate.blocked) throw new LifecycleError(gate.reasons.join('\n'));
|
|
254
|
+
const current = currentBranch(git);
|
|
255
|
+
if (current === null || current === '')
|
|
256
|
+
throw new LifecycleError('detached HEAD — cannot archive');
|
|
257
|
+
if (current !== binding?.branch) {
|
|
258
|
+
throw new LifecycleError(
|
|
259
|
+
`archive must run on attached branch \`${binding?.branch}\` (current: \`${current}\`)`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (current === defaultBranch(git)) {
|
|
263
|
+
throw new LifecycleError('archive must not run on the default branch');
|
|
264
|
+
}
|
|
265
|
+
if (!isCleanTree(git)) throw new LifecycleError('working tree must be clean to archive');
|
|
266
|
+
} else if (binding === null) {
|
|
267
|
+
throw new LifecycleError(`change \`${id}\` has no branch binding — cannot merge`);
|
|
268
|
+
}
|
|
269
|
+
const method = opts.method ?? 'squash';
|
|
270
|
+
const target = opts.into ?? binding?.baseBranch ?? defaultBranch(git);
|
|
271
|
+
return mergeRenameCommit(git, io, id, binding?.branch as string, target, method, opts.today);
|
|
272
|
+
}
|
|
273
|
+
|
|
135
274
|
/** `change diff <id>`: full diff of the bound branch vs base. */
|
|
136
275
|
export function changeDiff(git: GitLike, io: FsIo, id: string): string {
|
|
137
276
|
const binding = readBinding(io.readText(proposalPath(id)));
|
|
@@ -139,3 +278,19 @@ export function changeDiff(git: GitLike, io: FsIo, id: string): string {
|
|
|
139
278
|
const base = binding.baseBranch ?? defaultBranch(git);
|
|
140
279
|
return git.run(['diff', `${base}...${binding.branch}`]);
|
|
141
280
|
}
|
|
281
|
+
|
|
282
|
+
export interface ChangeDiffInfo {
|
|
283
|
+
change: string;
|
|
284
|
+
branch: string;
|
|
285
|
+
base: string;
|
|
286
|
+
commitCount: number;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** `change diff --json` (r46): structured bound-branch summary. */
|
|
290
|
+
export function changeDiffInfo(git: GitLike, io: FsIo, id: string): ChangeDiffInfo {
|
|
291
|
+
const binding = readBinding(io.readText(proposalPath(id)));
|
|
292
|
+
if (binding === null) throw new LifecycleError(`change \`${id}\` has no branch binding`);
|
|
293
|
+
const count =
|
|
294
|
+
git.runOpt(['rev-list', '--count', `${binding.baseSha}...${binding.branch}`]) ?? '0';
|
|
295
|
+
return { change: id, branch: binding.branch, base: binding.baseSha, commitCount: Number(count) };
|
|
296
|
+
}
|
|
@@ -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,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
|
+
}
|