changeledger 0.5.0 → 0.6.1

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 CHANGED
@@ -6,8 +6,10 @@ under `.changeledger/changes/`, persistent truth under `.changeledger/specs/`.
6
6
  <!-- changeledger -->
7
7
  > [!IMPORTANT]
8
8
  > This repo uses **ChangeLedger**. Before creating or modifying files, run
9
- > `changeledger context` (or `changeledger context <change-id>`) and follow its output.
10
- > If the command is unavailable, stop and restore/install ChangeLedger; do not proceed from memory.
9
+ > `changeledger context`, read its complete output, and follow it.
10
+ > If the output is truncated/incomplete, stop and restore complete context before
11
+ > proceeding. If the command is unavailable, stop and restore/install
12
+ > ChangeLedger; do not proceed from memory.
11
13
 
12
14
  The canonical ChangeLedger contract is split into task-focused fragments under
13
15
  [`templates/contract/`](templates/contract/). The deterministic
package/README.md CHANGED
@@ -139,8 +139,21 @@ changeledger context review # explicit task mode
139
139
 
140
140
  `init` places a small fail-closed bootstrap in the project-owned `AGENTS.md`;
141
141
  there is no linked or copied contract under `.changeledger/`. Run
142
- `changeledger register` after upgrading to refresh that bootstrap and migrate
143
- legacy repositories.
142
+ `changeledger register` after upgrading to refresh that bootstrap.
143
+
144
+ ### Upgrading an existing repo's configuration
145
+
146
+ Repos created before ChangeLedger 0.6 may have an older configuration schema.
147
+ Run this to inspect and apply available migrations:
148
+
149
+ ```sh
150
+ changeledger config migrate --dry-run # preview changes without writing
151
+ changeledger config migrate # apply atomically
152
+ changeledger check # confirm the repo is valid
153
+ ```
154
+
155
+ Migrations are safe to run more than once — if the config is already current,
156
+ the command reports so and exits without modifying any file.
144
157
 
145
158
  ## Compatibility and security
146
159
 
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
2
3
  import { Command } from 'commander';
3
4
  import {
4
5
  archive,
@@ -20,8 +21,12 @@ import { newChange } from '../src/commands/new.mjs';
20
21
  import { registerRepo } from '../src/commands/register.mjs';
21
22
  import { initReleaseHistory, recordRelease, releasePlan } from '../src/commands/release.mjs';
22
23
  import { view } from '../src/commands/view.mjs';
24
+ import { findChangeledgerDir } from '../src/config.mjs';
25
+ import { applyMigration, SUPPORTED_SCHEMA_VERSION } from '../src/config-migration.mjs';
23
26
  import { nowUtc } from '../src/paths.mjs';
24
27
 
28
+ const { version } = createRequire(import.meta.url)('../package.json');
29
+
25
30
  const USAGE = `ChangeLedger (changeledger)
26
31
 
27
32
  changeledger init set up .changeledger/ in the current repo (+ register it)
@@ -45,6 +50,7 @@ const USAGE = `ChangeLedger (changeledger)
45
50
  changeledger graduate <change-id> <spec-slug> --into graduate into an existing spec
46
51
  changeledger graduate <change-id> --skip [reason] mark graduation reviewed, no spec
47
52
  changeledger graduate --pending list done changes not yet reviewed
53
+ changeledger config migrate [--dry-run] migrate .changeledger/config.yml to schema ${SUPPORTED_SCHEMA_VERSION}
48
54
  changeledger release init <version> initialize release history at X.Y.Z
49
55
  changeledger release plan [--json] calculate the next portable SemVer release
50
56
  changeledger release record <version> record the calculated release manifest`;
@@ -65,6 +71,7 @@ function action(fn) {
65
71
  program
66
72
  .name('changeledger')
67
73
  .description('ChangeLedger (changeledger)')
74
+ .version(version, '-v, --version', 'output the installed version (-V also accepted)')
68
75
  .helpOption('-h, --help', 'display help for command')
69
76
  .addHelpText('after', `\n${USAGE}`);
70
77
 
@@ -326,6 +333,24 @@ program
326
333
  }),
327
334
  );
328
335
 
336
+ const configCommand = program
337
+ .command('config')
338
+ .description('inspect and manage the repo configuration');
339
+
340
+ configCommand
341
+ .command('migrate')
342
+ .description('migrate .changeledger/config.yml to the current schema')
343
+ .option('--dry-run', 'show the migration plan and candidate YAML without writing')
344
+ .action(
345
+ action((options) => {
346
+ const changeledgerDir = findChangeledgerDir();
347
+ if (!changeledgerDir) throw new Error('Not a ChangeLedger repo.');
348
+ const configFile = `${changeledgerDir}/config.yml`;
349
+ const result = applyMigration(configFile, { dryRun: options.dryRun ?? false });
350
+ console.log(result);
351
+ }),
352
+ );
353
+
329
354
  const releaseCommand = program
330
355
  .command('release')
331
356
  .description('plan and record portable SemVer releases');
@@ -376,8 +401,10 @@ releaseCommand
376
401
  }),
377
402
  );
378
403
 
379
- if (process.argv.length <= 2) {
404
+ // Normalize -V to --version so both short aliases work identically.
405
+ const argv = process.argv.map((a) => (a === '-V' ? '--version' : a));
406
+ if (argv.length <= 2) {
380
407
  console.log(USAGE);
381
408
  } else {
382
- program.parse();
409
+ program.parse(argv);
383
410
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changeledger",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Turn conversations into buildable changes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,5 @@
1
1
  import { checkRepo } from '../check.mjs';
2
+ import { getSchemaVersion, SUPPORTED_SCHEMA_VERSION } from '../config-migration.mjs';
2
3
  import { checkContract } from '../contract.mjs';
3
4
  import { loadRepo } from '../repo.mjs';
4
5
 
@@ -22,6 +23,15 @@ export function check(args = [], cwd = process.cwd(), output = console) {
22
23
 
23
24
  const { errors, warnings } = checkRepo(repo, { id });
24
25
 
26
+ // Schema version detection — warn without mutating.
27
+ const schemaVersion = getSchemaVersion(repo.config);
28
+ if (!id && schemaVersion < SUPPORTED_SCHEMA_VERSION) {
29
+ warnings.push({
30
+ file: '.changeledger/config.yml',
31
+ message: `config schema ${schemaVersion} is outdated; run \`changeledger config migrate --dry-run\``,
32
+ });
33
+ }
34
+
25
35
  // Discovery validation needs the filesystem (root contract bootstrap), so it
26
36
  // lives here, not in the pure validator. Repo-wide only.
27
37
  if (!id) {
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { findChangeledgerDir, loadConfig } from '../config.mjs';
4
+ import { getSchemaVersion, SUPPORTED_SCHEMA_VERSION } from '../config-migration.mjs';
4
5
  import {
5
6
  ensureReference,
6
7
  removeLegacyContract,
@@ -11,7 +12,7 @@ import { register } from '../registry.mjs';
11
12
 
12
13
  // Refreshes the repo bootstrap and registry path. Also migrates the per-machine
13
14
  // contract artifact left by legacy versions.
14
- export function registerRepo(cwd = process.cwd()) {
15
+ export function registerRepo(cwd = process.cwd(), output = console) {
15
16
  const changeledgerDir = findChangeledgerDir(cwd);
16
17
  if (!changeledgerDir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
17
18
 
@@ -20,6 +21,13 @@ export function registerRepo(cwd = process.cwd()) {
20
21
  throw new Error('config.yml has no project_id. Run `changeledger init` to create one.');
21
22
  }
22
23
 
24
+ const schemaVersion = getSchemaVersion(config);
25
+ if (schemaVersion < SUPPORTED_SCHEMA_VERSION) {
26
+ output.warn(
27
+ `warn .changeledger/config.yml: config schema ${schemaVersion} is outdated; run \`changeledger config migrate --dry-run\``,
28
+ );
29
+ }
30
+
23
31
  const repoRoot = path.dirname(changeledgerDir);
24
32
  const name = config.project_name || path.basename(repoRoot);
25
33
 
@@ -6,8 +6,12 @@ import { createRequestListener, staticFile } from '../viewer/server/router.mjs';
6
6
  import { hostnameOf, isAuthorizedWrite, isLocalHost } from '../viewer/server/security.mjs';
7
7
 
8
8
  export {
9
+ applyConfigMigration,
9
10
  changeStatus,
11
+ patchProjectConfig,
12
+ previewConfigMigration,
10
13
  readProjectConfig,
14
+ readProjectConfigStructured,
11
15
  repairProjectPath,
12
16
  resolveProjects,
13
17
  saveProjectConfig,
@@ -0,0 +1,213 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseDocument } from 'yaml';
4
+ import { writeFileAtomic } from './atomic-write.mjs';
5
+ import { templatesDir } from './paths.mjs';
6
+
7
+ export const SUPPORTED_SCHEMA_VERSION = 1;
8
+
9
+ const CANONICAL_STATUSES = [
10
+ 'draft',
11
+ 'approved',
12
+ 'in-progress',
13
+ 'in-review',
14
+ 'in-validation',
15
+ 'blocked',
16
+ 'done',
17
+ 'discarded',
18
+ ];
19
+
20
+ const BUILTIN_TYPES = ['feature', 'bug', 'refactor'];
21
+
22
+ const BUILTIN_IMPACTS = {
23
+ feature: 'minor',
24
+ bug: 'patch',
25
+ audit: 'none',
26
+ refactor: 'none',
27
+ chore: 'none',
28
+ };
29
+
30
+ export function getSchemaVersion(config) {
31
+ const v = config.schema_version;
32
+ return typeof v === 'number' ? v : 0;
33
+ }
34
+
35
+ // Returns null when no migration needed; throws on invalid/future schema.
36
+ // Otherwise returns { yaml: string, changes: string[] }.
37
+ export function buildMigration(originalText) {
38
+ let doc;
39
+ try {
40
+ doc = parseDocument(originalText, { merge: false });
41
+ } catch (e) {
42
+ throw new Error(`Invalid YAML: ${e.message}`);
43
+ }
44
+ if (doc.errors.length) {
45
+ throw new Error(`Invalid YAML: ${doc.errors[0].message}`);
46
+ }
47
+
48
+ const config = doc.toJS() ?? {};
49
+ const current = getSchemaVersion(config);
50
+
51
+ if (current > SUPPORTED_SCHEMA_VERSION) {
52
+ throw new Error(
53
+ `config schema ${current} is newer than supported schema ${SUPPORTED_SCHEMA_VERSION}`,
54
+ );
55
+ }
56
+ if (current === SUPPORTED_SCHEMA_VERSION) {
57
+ return null;
58
+ }
59
+
60
+ const changes = [];
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');
65
+ }
66
+ doc.contents.items.unshift(doc.createPair('schema_version', 1));
67
+ changes.push('added schema_version: 1');
68
+
69
+ // tdd: true if absent
70
+ if (!Object.hasOwn(config, 'tdd')) {
71
+ doc.set('tdd', true);
72
+ changes.push('added tdd: true');
73
+ }
74
+
75
+ // specs_dir if absent
76
+ if (!Object.hasOwn(config, 'specs_dir')) {
77
+ doc.set('specs_dir', '.changeledger/specs');
78
+ changes.push('added specs_dir: .changeledger/specs');
79
+ }
80
+
81
+ // Canonical statuses — insert missing ones in canonical order
82
+ const statusesNode = doc.get('statuses', true);
83
+ if (statusesNode) {
84
+ const current_statuses = statusesNode.items.map((n) => String(n.value));
85
+ for (const status of CANONICAL_STATUSES) {
86
+ if (current_statuses.includes(status)) continue;
87
+ const insertBefore = findInsertBefore(current_statuses, CANONICAL_STATUSES, status);
88
+ const newNode = doc.createNode(status);
89
+ if (insertBefore === -1) {
90
+ statusesNode.items.push(newNode);
91
+ current_statuses.push(status);
92
+ } else {
93
+ statusesNode.items.splice(insertBefore, 0, newNode);
94
+ current_statuses.splice(insertBefore, 0, status);
95
+ }
96
+ changes.push(`added status: ${status}`);
97
+ }
98
+ }
99
+
100
+ // review_required: true for built-in types where key is absent
101
+ const configTypes = config.types ?? {};
102
+ for (const typeName of BUILTIN_TYPES) {
103
+ if (!Object.hasOwn(configTypes, typeName)) continue;
104
+ if (Object.hasOwn(configTypes[typeName], 'review_required')) continue;
105
+ doc.setIn(['types', typeName, 'review_required'], true);
106
+ changes.push(`added types.${typeName}.review_required: true`);
107
+ }
108
+
109
+ // release.impacts defaults for built-in types that exist in config.types
110
+ const currentImpacts = config.release?.impacts ?? {};
111
+ for (const [type, impact] of Object.entries(BUILTIN_IMPACTS)) {
112
+ if (!Object.hasOwn(configTypes, type)) continue;
113
+ if (Object.hasOwn(currentImpacts, type)) continue;
114
+ doc.setIn(['release', 'impacts', type], impact);
115
+ changes.push(`added release.impacts.${type}: ${impact}`);
116
+ }
117
+
118
+ // Remove legacy id_digits
119
+ if (Object.hasOwn(config, 'id_digits')) {
120
+ doc.delete('id_digits');
121
+ changes.push('removed legacy id_digits');
122
+ }
123
+
124
+ // Refresh managed comments from the current template, preserving custom comments.
125
+ const templateComments = loadTemplateComments();
126
+ const commentChanges = refreshManagedComments(doc, templateComments);
127
+ changes.push(...commentChanges);
128
+
129
+ return { yaml: doc.toString(), changes };
130
+ }
131
+
132
+ // Apply migration to a file (or dry-run). Returns summary string.
133
+ export function applyMigration(configFile, { dryRun = false } = {}) {
134
+ let original;
135
+ try {
136
+ original = fs.readFileSync(configFile, 'utf8');
137
+ } catch (e) {
138
+ throw new Error(`Cannot read config: ${e.message}`);
139
+ }
140
+
141
+ const result = buildMigration(original);
142
+
143
+ if (!result) {
144
+ return `Config is already at schema ${SUPPORTED_SCHEMA_VERSION}. No changes needed.`;
145
+ }
146
+
147
+ const header = dryRun
148
+ ? `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION} (dry run)`
149
+ : `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION}`;
150
+
151
+ const summary = [header, ...result.changes.map((c) => ` - ${c}`)].join('\n');
152
+
153
+ if (!dryRun) {
154
+ writeFileAtomic(configFile, result.yaml);
155
+ return summary;
156
+ }
157
+
158
+ return `${summary}\n\n--- candidate YAML ---\n${result.yaml}`;
159
+ }
160
+
161
+ // Find the index in currentList where `status` should be inserted, based on
162
+ // canonical order. Returns -1 to append at end.
163
+ function findInsertBefore(currentList, canonicalOrder, status) {
164
+ const canonIdx = canonicalOrder.indexOf(status);
165
+ for (let i = canonIdx + 1; i < canonicalOrder.length; i++) {
166
+ const pos = currentList.indexOf(canonicalOrder[i]);
167
+ if (pos !== -1) return pos;
168
+ }
169
+ return -1;
170
+ }
171
+
172
+ // Reads the current template and returns a map of top-level key → commentBefore string.
173
+ // Returns an empty map if the template is unreadable (comment refresh is optional).
174
+ function loadTemplateComments() {
175
+ try {
176
+ const templateText = fs.readFileSync(path.join(templatesDir, 'config.yml'), 'utf8');
177
+ const templateDoc = parseDocument(templateText, { merge: false });
178
+ const comments = new Map();
179
+ for (const pair of templateDoc.contents.items) {
180
+ const key = pair.key?.value;
181
+ const comment = pair.key?.commentBefore;
182
+ if (key && comment !== undefined) {
183
+ comments.set(key, comment);
184
+ }
185
+ }
186
+ return comments;
187
+ } catch {
188
+ return new Map();
189
+ }
190
+ }
191
+
192
+ // Replace managed comments (those defined in the template) on existing keys,
193
+ // preserving comments on custom/unknown keys.
194
+ // doc.set() creates keys as plain strings (not Scalar nodes), so commentBefore
195
+ // cannot be set on them directly. Convert string keys to Scalar nodes first.
196
+ function refreshManagedComments(doc, templateComments) {
197
+ const changes = [];
198
+ for (const pair of doc.contents.items) {
199
+ // Normalise string-primitive keys to Scalar nodes so commentBefore is writable.
200
+ if (typeof pair.key === 'string') {
201
+ pair.key = doc.createNode(pair.key);
202
+ }
203
+ const key = pair.key?.value;
204
+ if (!key) continue;
205
+ if (!templateComments.has(key)) continue; // custom key — preserve its comment
206
+ const templateComment = templateComments.get(key);
207
+ const currentComment = pair.key?.commentBefore;
208
+ if (currentComment === templateComment) continue; // already matches
209
+ pair.key.commentBefore = templateComment;
210
+ changes.push(`refreshed comment for ${key}`);
211
+ }
212
+ return changes;
213
+ }
package/src/contract.mjs CHANGED
@@ -37,8 +37,10 @@ const LEGACY_CONTRACT_HASHES = new Set([
37
37
  export const REFERENCE = `${MARKER}
38
38
  > [!IMPORTANT]
39
39
  > This repo uses **ChangeLedger**. Before creating or modifying files, run
40
- > \`changeledger context\` (or \`changeledger context <change-id>\`) and follow its output.
41
- > If the command is unavailable, stop and restore/install ChangeLedger; do not proceed from memory.
40
+ > \`changeledger context\`, read its complete output, and follow it.
41
+ > If the output is truncated/incomplete, stop and restore complete context before
42
+ > proceeding. If the command is unavailable, stop and restore/install
43
+ > ChangeLedger; do not proceed from memory.
42
44
  `;
43
45
 
44
46
  export const contractLink = (changeledgerDir) => path.join(changeledgerDir, 'AGENTS.md');