changeledger 0.9.0 → 0.10.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,72 @@
1
+ import { writeFileAtomic } from '../atomic-write.mjs';
2
+ import { computeFixes } from '../fix.mjs';
3
+ import { loadRepo } from '../repo.mjs';
4
+
5
+ // Repairs mechanical, unambiguous format defects (`changeledger fix [id] [--dry-run]`).
6
+ // Ambiguous defects are never touched — they are listed under "requires manual fix".
7
+ export function fix(args = [], cwd = process.cwd(), output = console) {
8
+ const dryRun = args.includes('--dry-run');
9
+ const id = args.find((a) => !a.startsWith('--'));
10
+
11
+ let repo;
12
+ try {
13
+ repo = loadRepo(cwd);
14
+ } catch (e) {
15
+ output.error(` error (repo): ${e.message}`);
16
+ return 1;
17
+ }
18
+
19
+ let targets = repo.changes;
20
+ if (id) {
21
+ targets = repo.changes.filter((c) => String(c.frontmatter?.id) === String(id));
22
+ if (!targets.length) {
23
+ output.error(` error no change with id "${id}"`);
24
+ return 1;
25
+ }
26
+ }
27
+
28
+ let anyChanged = false;
29
+ let anyManual = false;
30
+
31
+ for (const c of targets) {
32
+ const { text: fixedText, applied, manual, changed } = computeFixes(c.text);
33
+
34
+ if (manual.length) {
35
+ anyManual = true;
36
+ output.log(`requires manual fix — ${c.name}:`);
37
+ for (const m of manual) output.log(` - ${m}`);
38
+ }
39
+
40
+ if (!changed) {
41
+ output.log(id ? 'nothing to fix' : `${c.name}: nothing to fix`);
42
+ continue;
43
+ }
44
+
45
+ anyChanged = true;
46
+ if (dryRun) {
47
+ output.log(`--- ${c.name} (dry run)`);
48
+ for (const line of diffLines(c.text, fixedText)) output.log(line);
49
+ continue;
50
+ }
51
+
52
+ writeFileAtomic(c.file, fixedText);
53
+ output.log(`fixed — ${c.name}:`);
54
+ for (const a of applied) output.log(` - ${a}`);
55
+ }
56
+
57
+ if (!anyChanged && !anyManual) output.log('nothing to fix');
58
+ return 0;
59
+ }
60
+
61
+ function diffLines(before, after) {
62
+ const a = before.split('\n');
63
+ const b = after.split('\n');
64
+ const out = [];
65
+ const len = Math.max(a.length, b.length);
66
+ for (let i = 0; i < len; i++) {
67
+ if (a[i] === b[i]) continue;
68
+ if (a[i] !== undefined) out.push(`- ${a[i]}`);
69
+ if (b[i] !== undefined) out.push(`+ ${b[i]}`);
70
+ }
71
+ return out;
72
+ }
@@ -33,7 +33,15 @@ export function registerRepo(cwd = process.cwd(), output = console) {
33
33
 
34
34
  removeLegacyContract(changeledgerDir);
35
35
  removeLegacyGitignore(repoRoot);
36
- if (fs.existsSync(rootContract(repoRoot))) ensureReference(repoRoot);
36
+ if (fs.existsSync(rootContract(repoRoot))) {
37
+ for (const { name, status } of ensureReference(repoRoot)) {
38
+ if (status === 'updated') {
39
+ output.warn(
40
+ `warn ${name}: ChangeLedger bootstrap was outdated; updated to the current version`,
41
+ );
42
+ }
43
+ }
44
+ }
37
45
 
38
46
  register({ id: config.project_id, name, path: repoRoot });
39
47
  return { id: config.project_id, name, path: repoRoot };
@@ -0,0 +1,56 @@
1
+ // `changeledger search` — deterministic lexical discovery over changes
2
+ // (including archived) and specs. See change 20260711-103758.
3
+
4
+ import { loadRepo } from '../repo.mjs';
5
+ import { buildCorpus, searchDocuments } from '../search.mjs';
6
+
7
+ // Runs the search against the repo at `cwd` and returns ranked hits. Pure
8
+ // query, no mutation.
9
+ export function search(query, { limit, type, status } = {}, cwd = process.cwd()) {
10
+ const { changes, specs } = loadRepo(cwd);
11
+ const corpus = buildCorpus({ changes, specs });
12
+ return searchDocuments(corpus, query, { limit, type, status });
13
+ }
14
+
15
+ function formatLabel(hit) {
16
+ return hit.kind === 'spec' ? hit.ref : `${hit.ref} ${hit.status} ${hit.type}`;
17
+ }
18
+
19
+ // A non-numeric or <1 `--limit` used to degrade silently to "no matches"
20
+ // (see change 20260711-160443); fail fast with a clear error instead.
21
+ function parseLimit(limitStr) {
22
+ if (limitStr === undefined) return undefined;
23
+ const n = Number(limitStr);
24
+ if (!Number.isInteger(n) || n < 1) {
25
+ throw new Error(`--limit must be a whole number >= 1, got "${limitStr}"`);
26
+ }
27
+ return n;
28
+ }
29
+
30
+ // CLI entry point: prints text or `--json`, and `no matches` when nothing scores.
31
+ export function runSearch(queryParts, options = {}, cwd = process.cwd()) {
32
+ const query = queryParts.join(' ').trim();
33
+ const limit = parseLimit(options.limit);
34
+ const hits = search(query, { limit, type: options.type, status: options.status }, cwd);
35
+
36
+ if (options.json) {
37
+ console.log(
38
+ JSON.stringify(
39
+ hits.map(({ ref, title, score, snippet }) => ({ ref, title, score, snippet })),
40
+ null,
41
+ 2,
42
+ ),
43
+ );
44
+ return;
45
+ }
46
+
47
+ if (!hits.length) {
48
+ console.log('no matches');
49
+ return;
50
+ }
51
+
52
+ for (const hit of hits) {
53
+ console.log(`${formatLabel(hit)} — ${hit.title}`);
54
+ console.log(` ${hit.snippet}`);
55
+ }
56
+ }
@@ -4,7 +4,7 @@ import { parseDocument } from 'yaml';
4
4
  import { writeFileAtomic } from './atomic-write.mjs';
5
5
  import { templatesDir } from './paths.mjs';
6
6
 
7
- export const SUPPORTED_SCHEMA_VERSION = 1;
7
+ export const SUPPORTED_SCHEMA_VERSION = 2;
8
8
 
9
9
  const CANONICAL_STATUSES = [
10
10
  'draft',
@@ -59,13 +59,33 @@ export function buildMigration(originalText) {
59
59
 
60
60
  const changes = [];
61
61
 
62
- // schema_version: 1 remove any existing value, then prepend to appear first
63
- if (Object.hasOwn(config, 'schema_version')) {
64
- doc.delete('schema_version');
62
+ // schema_version — update in place when the key exists at version >= 1 (keeps
63
+ // its comment and position); otherwise remove any explicit 0 and prepend so
64
+ // the key appears first.
65
+ if (Object.hasOwn(config, 'schema_version') && current >= 1) {
66
+ doc.set('schema_version', SUPPORTED_SCHEMA_VERSION);
67
+ changes.push(`updated schema_version: ${current} → ${SUPPORTED_SCHEMA_VERSION}`);
68
+ } else {
69
+ if (Object.hasOwn(config, 'schema_version')) {
70
+ doc.delete('schema_version');
71
+ }
72
+ doc.contents.items.unshift(doc.createPair('schema_version', SUPPORTED_SCHEMA_VERSION));
73
+ changes.push(`added schema_version: ${SUPPORTED_SCHEMA_VERSION}`);
74
+ }
75
+
76
+ if (current < 1) {
77
+ migrateToV1(doc, config, changes);
65
78
  }
66
- doc.contents.items.unshift(doc.createPair('schema_version', 1));
67
- changes.push('added schema_version: 1');
79
+ migrateToV2(doc, config, changes);
80
+
81
+ // No line wrapping and no flow padding: keeps untouched flow sequences
82
+ // (statuses, stages) byte-identical to their common written form.
83
+ const yaml = doc.toString({ lineWidth: 0, flowCollectionPadding: false });
84
+ return { yaml, changes, fromVersion: current };
85
+ }
68
86
 
87
+ // 0 → 1: structural additions and managed-comment refresh.
88
+ function migrateToV1(doc, config, changes) {
69
89
  // tdd: true if absent
70
90
  if (!Object.hasOwn(config, 'tdd')) {
71
91
  doc.set('tdd', true);
@@ -125,8 +145,23 @@ export function buildMigration(originalText) {
125
145
  const templateComments = loadTemplateComments();
126
146
  const commentChanges = refreshManagedComments(doc, templateComments);
127
147
  changes.push(...commentChanges);
148
+ }
128
149
 
129
- return { yaml: doc.toString(), changes };
150
+ // 1 → 2: additive quick type. Existing custom `quick` definitions and impacts
151
+ // are never touched.
152
+ function migrateToV2(doc, config, changes) {
153
+ const configTypes = config.types ?? {};
154
+ if (!Object.hasOwn(configTypes, 'quick')) {
155
+ const stagesNode = doc.createNode(['request', 'log']);
156
+ stagesNode.flow = true;
157
+ doc.setIn(['types', 'quick', 'stages'], stagesNode);
158
+ changes.push('added types.quick with stages: [request, log]');
159
+ }
160
+ const currentImpacts = config.release?.impacts ?? {};
161
+ if (!Object.hasOwn(currentImpacts, 'quick')) {
162
+ doc.setIn(['release', 'impacts', 'quick'], 'patch');
163
+ changes.push('added release.impacts.quick: patch');
164
+ }
130
165
  }
131
166
 
132
167
  // Apply migration to a file (or dry-run). Returns summary string.
@@ -145,8 +180,8 @@ export function applyMigration(configFile, { dryRun = false } = {}) {
145
180
  }
146
181
 
147
182
  const header = dryRun
148
- ? `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION} (dry run)`
149
- : `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION}`;
183
+ ? `Config migration ${result.fromVersion} → ${SUPPORTED_SCHEMA_VERSION} (dry run)`
184
+ : `Config migration ${result.fromVersion} → ${SUPPORTED_SCHEMA_VERSION}`;
150
185
 
151
186
  const summary = [header, ...result.changes.map((c) => ` - ${c}`)].join('\n');
152
187
 
package/src/config.mjs CHANGED
@@ -69,6 +69,19 @@ function isInside(root, target) {
69
69
  return target === root || target.startsWith(root + path.sep);
70
70
  }
71
71
 
72
+ // Optional declared integration branch: change branches start from it and
73
+ // merge back into it. Absent means the caller keeps its current auto-detection
74
+ // (`defaultBaseBranch`); a present but malformed value fails fast instead of
75
+ // silently falling back.
76
+ export function integrationBranch(config) {
77
+ const value = config?.git?.integration_branch;
78
+ if (value === undefined || value === null) return undefined;
79
+ if (typeof value !== 'string' || value.trim() === '') {
80
+ throw new Error('config "git.integration_branch" must be a non-empty string');
81
+ }
82
+ return value.trim();
83
+ }
84
+
72
85
  // Single source of the specs directory: the configured `specs_dir` or the
73
86
  // default, always resolved through the containment guard. Shared by `loadRepo`
74
87
  // and `graduate` so a graduated spec lands where the repo will later read it.
package/src/contract.mjs CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { writeFileAtomic } from './atomic-write.mjs';
5
5
 
6
6
  const CONTRACT_FILES = ['AGENTS.md', 'CLAUDE.md'];
7
- const MARKER = '<!-- changeledger -->';
7
+ const LEGACY_MARKER = '<!-- changeledger -->';
8
8
  const LEGACY_ENTRY = '.changeledger/AGENTS.md';
9
9
  // Exact SHA-256 digests of every historical templates/AGENTS.md payload. A
10
10
  // regular file is removed only when it is byte-identical to a contract version
@@ -34,8 +34,16 @@ const LEGACY_CONTRACT_HASHES = new Set([
34
34
  '99a42380f3835e1f706b986b86e58e33f12d68de4a69d94eb50845e790e05f71',
35
35
  ]);
36
36
 
37
- export const REFERENCE = `${MARKER}
38
- > [!IMPORTANT]
37
+ // Bootstrap block format version. Independent of the package version: bump
38
+ // only when the delimited block's shape or required content changes in a way
39
+ // that must be detected and re-registered in consuming repos.
40
+ export const BOOTSTRAP_VERSION = 2;
41
+
42
+ const BEGIN_RE = /<!-- CHANGELEDGER BOOTSTRAP BEGIN v(\d+) -->/;
43
+ const END_MARKER = '<!-- CHANGELEDGER BOOTSTRAP END -->';
44
+ const beginMarker = (version) => `<!-- CHANGELEDGER BOOTSTRAP BEGIN v${version} -->`;
45
+
46
+ export const REFERENCE = `> [!IMPORTANT]
39
47
  > This repo uses **ChangeLedger**. Immediately after reading this file — before
40
48
  > planning, investigating, or acting — a normal agent must run \`changeledger context\` directly.
41
49
  > Only a delegated leaf whose prompt was emitted by \`changeledger agent-prompt <role>\`
@@ -51,8 +59,15 @@ export const REFERENCE = `${MARKER}
51
59
  >
52
60
  > Do not create or modify files without an authorized change; the core context
53
61
  > defines the workflow, the task contexts, and the narrow operational exception.
62
+ > After a compaction, verify a retained capture with \`changeledger context
63
+ > [mode] --have <rev>\` (the BEGIN line's \`rev:\`) instead of recapturing in
64
+ > full; a mismatch still returns the complete output.
54
65
  `;
55
66
 
67
+ function bootstrapBlock(version = BOOTSTRAP_VERSION) {
68
+ return `${beginMarker(version)}\n${REFERENCE}${END_MARKER}\n`;
69
+ }
70
+
56
71
  export const contractLink = (changeledgerDir) => path.join(changeledgerDir, 'AGENTS.md');
57
72
  export const rootContract = (repoRoot) => path.join(repoRoot, 'AGENTS.md');
58
73
 
@@ -64,28 +79,66 @@ function isPlainFile(file) {
64
79
  }
65
80
  }
66
81
 
67
- function replaceReference(text) {
68
- const start = text.indexOf(MARKER);
69
- if (start === -1) return `${text}${text.endsWith('\n') ? '' : '\n'}\n${REFERENCE}`;
82
+ function insertBlock(text) {
83
+ const sep = text.length === 0 || text.endsWith('\n') ? '' : '\n';
84
+ const gap = text.length === 0 ? '' : '\n';
85
+ return `${text}${sep}${gap}${bootstrapBlock()}`;
86
+ }
87
+
88
+ // Migrate the legacy `<!-- changeledger -->` marker and its contiguous
89
+ // blockquote to the delimited format.
90
+ function migrateLegacy(text, start) {
70
91
  const tail = text.slice(start).split('\n');
71
92
  let consumed = 1;
72
93
  while (consumed < tail.length && tail[consumed].startsWith('>')) consumed += 1;
73
94
  const before = text.slice(0, start);
74
95
  const after = tail.slice(consumed).join('\n').replace(/^\n+/, '');
75
- return `${before}${REFERENCE}${after ? `\n${after}` : ''}`;
96
+ return `${before}${bootstrapBlock()}${after ? `\n${after}` : ''}`;
97
+ }
98
+
99
+ // Replace only the interior of an existing BEGIN/END block, preserving
100
+ // everything outside the delimiters byte-for-byte.
101
+ function replaceDelimited(text, beginIndex, version) {
102
+ const endIndex = text.indexOf(END_MARKER, beginIndex);
103
+ if (endIndex === -1) {
104
+ throw new Error(
105
+ 'Malformed ChangeLedger bootstrap: found a BEGIN marker without a matching END marker',
106
+ );
107
+ }
108
+ const before = text.slice(0, beginIndex);
109
+ const after = text.slice(endIndex + END_MARKER.length).replace(/^\n/, '');
110
+ const newText = `${before}${bootstrapBlock()}${after}`;
111
+ const status =
112
+ version < BOOTSTRAP_VERSION ? 'updated' : newText === text ? 'unchanged' : 'replaced';
113
+ return { text: newText, status, fromVersion: version };
114
+ }
115
+
116
+ // Compute the delimited bootstrap block for `text`, inserting it, replacing
117
+ // it, or migrating the legacy marker as needed.
118
+ export function applyBootstrap(text) {
119
+ const beginMatch = BEGIN_RE.exec(text);
120
+ if (beginMatch) {
121
+ return replaceDelimited(text, beginMatch.index, Number(beginMatch[1]));
122
+ }
123
+ const legacyIndex = text.indexOf(LEGACY_MARKER);
124
+ if (legacyIndex !== -1) {
125
+ return { text: migrateLegacy(text, legacyIndex), status: 'migrated' };
126
+ }
127
+ return { text: insertBlock(text), status: 'inserted' };
76
128
  }
77
129
 
78
- // Add or replace the managed bootstrap block in project-owned agent files.
130
+ // Add, replace, or migrate the managed bootstrap block in project-owned agent
131
+ // files. Returns the files touched with the transition applied to each.
79
132
  export function ensureReference(repoRoot) {
80
133
  const touched = [];
81
134
  for (const name of CONTRACT_FILES) {
82
135
  const file = path.join(repoRoot, name);
83
136
  if (!isPlainFile(file)) continue;
84
137
  const text = fs.readFileSync(file, 'utf8');
85
- const updated = replaceReference(text);
86
- if (updated === text) continue;
138
+ const { text: updated, status, fromVersion } = applyBootstrap(text);
139
+ if (status === 'unchanged') continue;
87
140
  writeFileAtomic(file, updated);
88
- touched.push(name);
141
+ touched.push({ name, status, fromVersion });
89
142
  }
90
143
  return touched;
91
144
  }
@@ -138,9 +191,10 @@ export function checkContract(repoRoot) {
138
191
  const file = path.join(repoRoot, name);
139
192
  if (!isPlainFile(file)) continue;
140
193
  const text = fs.readFileSync(file, 'utf8');
141
- if (!text.includes(MARKER)) {
194
+ const { status } = applyBootstrap(text);
195
+ if (status === 'inserted') {
142
196
  errors.push(`${name} has no ChangeLedger reference — run \`changeledger register\``);
143
- } else if (!text.includes(REFERENCE.trim())) {
197
+ } else if (status !== 'unchanged') {
144
198
  errors.push(`${name} has an outdated ChangeLedger reference — run \`changeledger register\``);
145
199
  }
146
200
  }
package/src/fix.mjs ADDED
@@ -0,0 +1,127 @@
1
+ // Mechanical, unambiguous repairs for format defects `changeledger check` can
2
+ // only diagnose. Pure text-in/text-out — no IO. The `fix` command (and its
3
+ // `--dry-run`) do the reading/writing; `check` reuses `hasFixableDefects` to
4
+ // print a hint without duplicating the repair rules.
5
+ //
6
+ // Repairs (in order, per `## Plan` task line):
7
+ // 1. Checkbox marker variants `[ x ]` / `[X]` -> `[x]`.
8
+ // 2. A `(CRn) — verify: X` block reordered to `; verify: X (CRn)`.
9
+ // 3. A resolution suffix using a single hyphen instead of an em dash.
10
+ // 4. A near-ISO resolution timestamp normalized to strict ISO 8601 UTC.
11
+ //
12
+ // A task whose CR reference is not declared in `## Specification` is left
13
+ // completely untouched and reported under `manual` — the defect requires
14
+ // judgment (unknown criterion), not a mechanical rewrite.
15
+ import { parseChange } from './change.mjs';
16
+
17
+ const TASK_LINE = /^(\s*-\s)\[([^\]]*)\](\s+)(.*)$/;
18
+ const REORDERED_VERIFY = /^(.*?)\s*\(([^)]*\bCR\d+[^)]*)\)\s*—\s*verify:\s*(.+)$/;
19
+ const NEAR_ISO = /^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?(Z)?$/;
20
+
21
+ export function computeFixes(text) {
22
+ const criteria = new Set(parseChange(text).criteria ?? []);
23
+ const lines = text.split('\n');
24
+ const outLines = [];
25
+ const applied = [];
26
+ const manual = [];
27
+ let inPlan = false;
28
+
29
+ for (let i = 0; i < lines.length; i++) {
30
+ const rawLine = lines[i];
31
+ if (/^##\s+/.test(rawLine)) {
32
+ inPlan = /^##\s+Plan\s*$/.test(rawLine);
33
+ outLines.push(rawLine);
34
+ continue;
35
+ }
36
+ if (!inPlan) {
37
+ outLines.push(rawLine);
38
+ continue;
39
+ }
40
+ const result = fixTaskLine(rawLine, criteria, i + 1);
41
+ outLines.push(result.line);
42
+ applied.push(...result.applied);
43
+ manual.push(...result.manual);
44
+ }
45
+
46
+ return { text: outLines.join('\n'), applied, manual, changed: applied.length > 0 };
47
+ }
48
+
49
+ export function hasFixableDefects(text) {
50
+ if (typeof text !== 'string') return false;
51
+ try {
52
+ return computeFixes(text).changed;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function fixTaskLine(rawLine, declaredCR, lineNo) {
59
+ const m = rawLine.match(TASK_LINE);
60
+ if (!m) return { line: rawLine, applied: [], manual: [] };
61
+ const [, prefix, markerRaw, gap, restRaw] = m;
62
+
63
+ // A task referencing an undeclared criterion needs judgment, not a rewrite —
64
+ // leave the entire line untouched.
65
+ const referenced = restRaw.match(/CR\d+/g) ?? [];
66
+ const unknown = [...new Set(referenced.filter((cr) => !declaredCR.has(cr)))];
67
+ if (unknown.length) {
68
+ return {
69
+ line: rawLine,
70
+ applied: [],
71
+ manual: [`line ${lineNo}: references unknown criterion ${unknown.join(', ')}`],
72
+ };
73
+ }
74
+
75
+ const applied = [];
76
+ let rest = restRaw;
77
+
78
+ let marker = markerRaw;
79
+ if (marker.trim().toLowerCase() === 'x' && marker !== 'x') {
80
+ marker = 'x';
81
+ applied.push(`line ${lineNo}: checkbox marker normalized to [x]`);
82
+ }
83
+ const state = marker === 'x' ? 'done' : marker === '!' ? 'blocked' : 'todo';
84
+
85
+ const reorder = rest.match(REORDERED_VERIFY);
86
+ if (reorder) {
87
+ const [, target, crBlock, verify] = reorder;
88
+ rest = `${target.trim()}; verify: ${verify.trim()} (${crBlock.trim()})`;
89
+ applied.push(`line ${lineNo}: reordered verify suffix before (${crBlock.trim()})`);
90
+ }
91
+
92
+ // The resolution suffix is the LAST separator: a description may legitimately
93
+ // contain an em dash, so only a hyphen sitting after every em dash is a defect.
94
+ if (
95
+ (state === 'done' || state === 'blocked') &&
96
+ rest.lastIndexOf(' - ') > rest.lastIndexOf(' — ')
97
+ ) {
98
+ const hyphenIdx = rest.lastIndexOf(' - ');
99
+ if (hyphenIdx !== -1) {
100
+ rest = `${rest.slice(0, hyphenIdx)} — ${rest.slice(hyphenIdx + 3)}`;
101
+ applied.push(`line ${lineNo}: resolution suffix hyphen normalized to em dash`);
102
+ }
103
+ }
104
+
105
+ if (state === 'done') {
106
+ const dash = rest.lastIndexOf(' — ');
107
+ if (dash !== -1) {
108
+ const suffix = rest.slice(dash + 3);
109
+ const normalized = normalizeIsoTimestamp(suffix);
110
+ if (normalized && normalized !== suffix) {
111
+ rest = `${rest.slice(0, dash)} — ${normalized}`;
112
+ applied.push(`line ${lineNo}: resolution timestamp normalized to ISO 8601 UTC`);
113
+ }
114
+ }
115
+ }
116
+
117
+ if (!applied.length) return { line: rawLine, applied: [], manual: [] };
118
+ return { line: `${prefix}[${marker}]${gap}${rest}`, applied, manual: [] };
119
+ }
120
+
121
+ function normalizeIsoTimestamp(text) {
122
+ const m = text.trim().match(NEAR_ISO);
123
+ if (!m) return null;
124
+ const [, y, mo, d, h, mi, s] = m;
125
+ const pad = (v) => v.padStart(2, '0');
126
+ return `${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${mi}:${s}Z`;
127
+ }
package/src/framing.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { packageRoot } from './paths.mjs';
@@ -20,3 +21,10 @@ export function beginSentinel(kind, meta) {
20
21
  export function endSentinel(kind) {
21
22
  return `===== CHANGELEDGER ${kind} END — ${TRUNCATION_SUFFIX} =====`;
22
23
  }
24
+
25
+ // 12-hex-char content revision for a composed body. Callers hash the body
26
+ // only (never the BEGIN/END lines themselves) so the revision never
27
+ // references its own framing.
28
+ export function contentRev(body) {
29
+ return crypto.createHash('sha256').update(body).digest('hex').slice(0, 12);
30
+ }
package/src/git.mjs CHANGED
@@ -6,8 +6,56 @@ import { execFileSync } from 'node:child_process';
6
6
 
7
7
  const SEP = String.fromCharCode(31); // ASCII unit separator — safe field delimiter
8
8
 
9
- function defaultRun(args, cwd) {
10
- return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
9
+ // Repo-location env vars git itself exports while running a hook (e.g. this
10
+ // project's own pre-commit). Left inherited, a child `git` call would silently
11
+ // target the hook's repo/worktree instead of the given `cwd` — strip them so
12
+ // every invocation stays anchored on `cwd`.
13
+ const GIT_LOCATION_ENV_VARS = [
14
+ 'GIT_DIR',
15
+ 'GIT_WORK_TREE',
16
+ 'GIT_INDEX_FILE',
17
+ 'GIT_OBJECT_DIRECTORY',
18
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
19
+ 'GIT_COMMON_DIR',
20
+ 'GIT_CEILING_DIRECTORIES',
21
+ ];
22
+
23
+ function sanitizedEnv() {
24
+ const env = { ...process.env };
25
+ for (const key of GIT_LOCATION_ENV_VARS) delete env[key];
26
+ return env;
27
+ }
28
+
29
+ // Exported so other commands (e.g. `changeledger commit`) share the same
30
+ // GIT_* sanitization instead of re-implementing it.
31
+ export function defaultRun(args, cwd) {
32
+ return execFileSync('git', args, {
33
+ cwd,
34
+ env: sanitizedEnv(),
35
+ encoding: 'utf8',
36
+ stdio: ['ignore', 'pipe', 'ignore'],
37
+ });
38
+ }
39
+
40
+ // Run variant for mutating git commands (e.g. `commit`), where git's stderr is
41
+ // the only clue to a failure (failed hook, nothing staged, missing identity,
42
+ // lock). Pipes stderr and, on failure, throws an Error whose message includes
43
+ // the captured diagnostic. Query paths keep `defaultRun` and degrade silently.
44
+ export function mutatingRun(args, cwd) {
45
+ try {
46
+ return execFileSync('git', args, {
47
+ cwd,
48
+ env: sanitizedEnv(),
49
+ encoding: 'utf8',
50
+ stdio: ['ignore', 'pipe', 'pipe'],
51
+ });
52
+ } catch (e) {
53
+ const detail = [e.stderr, e.stdout]
54
+ .map((s) => (typeof s === 'string' ? s.trim() : ''))
55
+ .filter(Boolean)
56
+ .join('\n');
57
+ throw new Error(detail ? `${e.message}\n${detail}` : e.message, { cause: e });
58
+ }
11
59
  }
12
60
 
13
61
  // Local git identity (`git config user.name`), or '' if unavailable. Tolerant.
@@ -39,6 +87,75 @@ export function ownerHandle(cwd, run = defaultRun, ghRun = defaultGhRun) {
39
87
  return githubLogin(ghRun) || gitUser(cwd, run);
40
88
  }
41
89
 
90
+ // Detects the branch `changeledger check --commits` should diff against when
91
+ // no base is given: the remote's HEAD if configured, else a local `main` or
92
+ // `master`. Throws with actionable guidance if neither is resolvable.
93
+ export function defaultBaseBranch(repoRoot, run = defaultRun) {
94
+ try {
95
+ const out = run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], repoRoot);
96
+ const name = out.trim().replace(/^origin\//, '');
97
+ if (name) return name;
98
+ } catch {
99
+ // no configured remote HEAD — fall through to local candidates
100
+ }
101
+ for (const candidate of ['main', 'master']) {
102
+ try {
103
+ run(['rev-parse', '--verify', '--quiet', candidate], repoRoot);
104
+ return candidate;
105
+ } catch {
106
+ // candidate branch does not exist locally — try the next one
107
+ }
108
+ }
109
+ throw new Error(
110
+ 'Could not detect a default branch (no origin/HEAD, main, or master); pass one explicitly: changeledger check --commits <base>',
111
+ );
112
+ }
113
+
114
+ // Commits in `range` (e.g. `main..HEAD`): sha, subject and whether each is a
115
+ // merge (more than one parent) — the git metadata `check --commits` lints.
116
+ export function commitsInRange(repoRoot, range, run = defaultRun) {
117
+ let out;
118
+ try {
119
+ out = run(['log', range, `--pretty=format:%H${SEP}%P${SEP}%s`], repoRoot);
120
+ } catch (e) {
121
+ throw new Error(`git log failed for range "${range}": ${e.message}`);
122
+ }
123
+ return out
124
+ .split('\n')
125
+ .filter(Boolean)
126
+ .map((line) => {
127
+ const [sha, parents, subject] = line.split(SEP);
128
+ return {
129
+ sha,
130
+ subject,
131
+ isMerge: parents.trim().split(/\s+/).filter(Boolean).length > 1,
132
+ };
133
+ });
134
+ }
135
+
136
+ // A well-formed marker is one or more `[#id]` groups, each separated by a
137
+ // single space, terminating the subject — the canonical multi-id shape is
138
+ // separate brackets (`[#A] [#B]`), never a comma list in one bracket.
139
+ const MARKER_RE = /(\[#[^\]\s]+\])(\s\[#[^\]\s]+\])*$/;
140
+ export function hasCommitMarker(subject) {
141
+ return MARKER_RE.test(subject.trim());
142
+ }
143
+
144
+ // Lints `range`: every non-merge commit must carry a well-formed `[#id]`
145
+ // marker, except `chore(release)` prep commits. Returns only the violations
146
+ // (sha + subject); never throws for a clean range.
147
+ export function lintCommitRange(repoRoot, range, run = defaultRun) {
148
+ const commits = commitsInRange(repoRoot, range, run);
149
+ const violations = [];
150
+ for (const c of commits) {
151
+ if (c.isMerge) continue;
152
+ if (/^chore\(release\):/.test(c.subject)) continue;
153
+ if (!hasCommitMarker(c.subject))
154
+ violations.push({ sha: c.sha.slice(0, 7), subject: c.subject });
155
+ }
156
+ return violations;
157
+ }
158
+
42
159
  export function gitRefs(repoRoot, id, run = defaultRun) {
43
160
  const refs = { commits: [], branches: [] };
44
161
  if (!id) return refs;