changeledger 0.9.0 → 0.11.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 +44 -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 +67 -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 +137 -2
- package/src/metrics.mjs +42 -0
- package/src/search.mjs +162 -0
- package/src/viewer/domain.mjs +13 -1
- package/src/viewer/public/app.js +76 -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 +11 -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 +16 -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 = 3;
|
|
8
8
|
|
|
9
9
|
const CANONICAL_STATUSES = [
|
|
10
10
|
'draft',
|
|
@@ -59,13 +59,34 @@ 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
|
+
if (current < 2) migrateToV2(doc, config, changes);
|
|
80
|
+
if (current < 3) migrateToV3(doc, config, changes);
|
|
81
|
+
|
|
82
|
+
// No line wrapping and no flow padding: keeps untouched flow sequences
|
|
83
|
+
// (statuses, stages) byte-identical to their common written form.
|
|
84
|
+
const yaml = doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
|
85
|
+
return { yaml, changes, fromVersion: current };
|
|
86
|
+
}
|
|
68
87
|
|
|
88
|
+
// 0 → 1: structural additions and managed-comment refresh.
|
|
89
|
+
function migrateToV1(doc, config, changes) {
|
|
69
90
|
// tdd: true if absent
|
|
70
91
|
if (!Object.hasOwn(config, 'tdd')) {
|
|
71
92
|
doc.set('tdd', true);
|
|
@@ -125,8 +146,45 @@ export function buildMigration(originalText) {
|
|
|
125
146
|
const templateComments = loadTemplateComments();
|
|
126
147
|
const commentChanges = refreshManagedComments(doc, templateComments);
|
|
127
148
|
changes.push(...commentChanges);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 1 → 2: additive quick type. Existing custom `quick` definitions and impacts
|
|
152
|
+
// are never touched.
|
|
153
|
+
function migrateToV2(doc, config, changes) {
|
|
154
|
+
const configTypes = config.types ?? {};
|
|
155
|
+
if (!Object.hasOwn(configTypes, 'quick')) {
|
|
156
|
+
const stagesNode = doc.createNode(['request', 'log']);
|
|
157
|
+
stagesNode.flow = true;
|
|
158
|
+
doc.setIn(['types', 'quick', 'stages'], stagesNode);
|
|
159
|
+
changes.push('added types.quick with stages: [request, log]');
|
|
160
|
+
}
|
|
161
|
+
const currentImpacts = config.release?.impacts ?? {};
|
|
162
|
+
if (!Object.hasOwn(currentImpacts, 'quick')) {
|
|
163
|
+
doc.setIn(['release', 'impacts', 'quick'], 'patch');
|
|
164
|
+
changes.push('added release.impacts.quick: patch');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 2 → 3: expose Git integration without inventing a repository-specific branch.
|
|
169
|
+
// Existing git settings and comments remain byte-for-byte owned by the source doc.
|
|
170
|
+
function migrateToV3(doc, config, changes) {
|
|
171
|
+
if (!Object.hasOwn(config, 'git')) {
|
|
172
|
+
setBlankGitSection(doc);
|
|
173
|
+
changes.push('added git section');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
128
176
|
|
|
129
|
-
|
|
177
|
+
function setBlankGitSection(doc) {
|
|
178
|
+
const gitNode = parseDocument('git:\n integration_branch:\n').get('git', true);
|
|
179
|
+
doc.set('git', gitNode);
|
|
180
|
+
const gitPair = doc.contents.items.find(
|
|
181
|
+
(pair) => pair.key?.value === 'git' || pair.key === 'git',
|
|
182
|
+
);
|
|
183
|
+
if (!gitPair) return;
|
|
184
|
+
if (typeof gitPair.key === 'string') gitPair.key = doc.createNode(gitPair.key);
|
|
185
|
+
gitPair.key.spaceBefore = true;
|
|
186
|
+
gitPair.key.commentBefore =
|
|
187
|
+
' Git integration: change branches start from and merge into this branch';
|
|
130
188
|
}
|
|
131
189
|
|
|
132
190
|
// Apply migration to a file (or dry-run). Returns summary string.
|
|
@@ -145,8 +203,8 @@ export function applyMigration(configFile, { dryRun = false } = {}) {
|
|
|
145
203
|
}
|
|
146
204
|
|
|
147
205
|
const header = dryRun
|
|
148
|
-
? `Config migration
|
|
149
|
-
: `Config migration
|
|
206
|
+
? `Config migration ${result.fromVersion} → ${SUPPORTED_SCHEMA_VERSION} (dry run)`
|
|
207
|
+
: `Config migration ${result.fromVersion} → ${SUPPORTED_SCHEMA_VERSION}`;
|
|
150
208
|
|
|
151
209
|
const summary = [header, ...result.changes.map((c) => ` - ${c}`)].join('\n');
|
|
152
210
|
|
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
|
+
}
|