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.
- package/AGENTS.md +5 -1
- package/README.md +5 -0
- package/bin/changeledger.mjs +127 -20
- package/package.json +1 -1
- package/src/check.mjs +12 -0
- package/src/commands/agent-context.mjs +2 -1
- package/src/commands/agent-prompt.mjs +1 -1
- package/src/commands/agent.mjs +3 -3
- package/src/commands/check.mjs +55 -0
- package/src/commands/commit.mjs +39 -0
- package/src/commands/context.mjs +57 -23
- package/src/commands/fix.mjs +72 -0
- package/src/commands/register.mjs +9 -1
- package/src/commands/search.mjs +56 -0
- package/src/config-migration.mjs +44 -9
- package/src/config.mjs +13 -0
- package/src/contract.mjs +67 -13
- package/src/fix.mjs +127 -0
- package/src/framing.mjs +8 -0
- package/src/git.mjs +119 -2
- package/src/metrics.mjs +42 -0
- package/src/search.mjs +162 -0
- package/src/viewer/domain.mjs +1 -1
- package/src/viewer/public/app.js +61 -11
- package/src/viewer/public/styles.css +76 -5
- package/src/viewer/public/view-renderers.js +160 -48
- package/src/viewer/server/router.mjs +40 -6
- package/templates/config.yml +7 -1
- package/templates/contract/agent-contexts/audit.md +22 -0
- package/templates/contract/agent-prompts/audit.md +37 -0
- package/templates/contract/agent-prompts/implementation.md +4 -0
- package/templates/contract/close.md +1 -1
- package/templates/contract/core.md +14 -4
- package/templates/contract/delegation.md +2 -1
- package/templates/contract/implement.md +14 -2
- package/templates/contract/spec.md +14 -1
|
@@ -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)))
|
|
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
|
+
}
|
package/src/config-migration.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
149
|
-
: `Config migration
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
68
|
-
const
|
|
69
|
-
|
|
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}${
|
|
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
|
|
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 =
|
|
86
|
-
if (
|
|
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
|
-
|
|
194
|
+
const { status } = applyBootstrap(text);
|
|
195
|
+
if (status === 'inserted') {
|
|
142
196
|
errors.push(`${name} has no ChangeLedger reference — run \`changeledger register\``);
|
|
143
|
-
} else if (
|
|
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
|
-
|
|
10
|
-
|
|
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;
|