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 +4 -2
- package/README.md +15 -2
- package/bin/changeledger.mjs +29 -2
- package/package.json +1 -1
- package/src/commands/check.mjs +10 -0
- package/src/commands/register.mjs +9 -1
- package/src/commands/view.mjs +4 -0
- package/src/config-migration.mjs +213 -0
- package/src/contract.mjs +4 -2
- package/src/viewer/domain.mjs +280 -0
- package/src/viewer/public/api.js +28 -0
- package/src/viewer/public/app.js +529 -29
- package/src/viewer/public/index.html +2 -0
- package/src/viewer/public/styles.css +283 -0
- package/src/viewer/server/router.mjs +40 -14
- package/templates/config.yml +4 -0
- package/templates/contract/core.md +16 -0
- package/templates/contract/release.md +10 -0
package/src/viewer/domain.mjs
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { parseDocument } from 'yaml';
|
|
4
5
|
import { mutateFileAtomic } from '../atomic-write.mjs';
|
|
5
6
|
import { checkRepo } from '../check.mjs';
|
|
6
7
|
import { status as applyStatusCmd, validation as applyValidation } from '../commands/agent.mjs';
|
|
7
8
|
import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
|
|
9
|
+
import {
|
|
10
|
+
buildMigration,
|
|
11
|
+
getSchemaVersion,
|
|
12
|
+
SUPPORTED_SCHEMA_VERSION,
|
|
13
|
+
} from '../config-migration.mjs';
|
|
8
14
|
import { computeMetrics } from '../metrics.mjs';
|
|
9
15
|
import { nowUtc } from '../paths.mjs';
|
|
10
16
|
import { listProjects, remove, update } from '../registry.mjs';
|
|
@@ -209,12 +215,21 @@ export function saveProjectConfig(projects, payload, { mutateConfig = mutateFile
|
|
|
209
215
|
if (revision(before) !== payload.revision) {
|
|
210
216
|
throw new Error('configuration changed on disk; reload before saving');
|
|
211
217
|
}
|
|
218
|
+
const currentSchema = getSchemaVersion(parseYaml(before));
|
|
219
|
+
if (currentSchema > SUPPORTED_SCHEMA_VERSION) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`config schema ${currentSchema} is newer than supported schema ${SUPPORTED_SCHEMA_VERSION}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
212
224
|
return payload.content;
|
|
213
225
|
});
|
|
214
226
|
} catch (error) {
|
|
215
227
|
if (error.message === 'configuration changed on disk; reload before saving') {
|
|
216
228
|
return { code: 409, body: { error: error.message } };
|
|
217
229
|
}
|
|
230
|
+
if (/^config schema \d+ is newer than supported schema \d+$/.test(error.message)) {
|
|
231
|
+
return { code: 400, body: { error: error.message } };
|
|
232
|
+
}
|
|
218
233
|
return { code: 400, body: { error: 'unable to save project configuration' } };
|
|
219
234
|
}
|
|
220
235
|
return {
|
|
@@ -264,3 +279,268 @@ export function unregisterProject(projects, payload, { localOnly = false } = {})
|
|
|
264
279
|
}
|
|
265
280
|
return { code: 200, body: { ok: true } };
|
|
266
281
|
}
|
|
282
|
+
|
|
283
|
+
// Returns config content + schema metadata without mutating anything.
|
|
284
|
+
export function readProjectConfigStructured(projects, id) {
|
|
285
|
+
const found = projectFor(projects, id);
|
|
286
|
+
if (!found.project) return found;
|
|
287
|
+
const file = path.join(found.project.path, '.changeledger', 'config.yml');
|
|
288
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
289
|
+
const config = parseYaml(content);
|
|
290
|
+
const schemaVersion = getSchemaVersion(config);
|
|
291
|
+
return {
|
|
292
|
+
code: 200,
|
|
293
|
+
body: {
|
|
294
|
+
content,
|
|
295
|
+
revision: revision(content),
|
|
296
|
+
schemaVersion,
|
|
297
|
+
supported: SUPPORTED_SCHEMA_VERSION,
|
|
298
|
+
config,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Applies a semantic patch (allowlisted fields only) to the YAML AST, preserving
|
|
304
|
+
// comments, unknown keys and fields the form does not represent.
|
|
305
|
+
export function patchProjectConfig(projects, payload, { mutateConfig = mutateFileAtomic } = {}) {
|
|
306
|
+
const found = projectFor(projects, payload.project);
|
|
307
|
+
if (!found.project) return found;
|
|
308
|
+
if (!payload.patch || typeof payload.patch !== 'object' || Array.isArray(payload.patch)) {
|
|
309
|
+
return { code: 400, body: { error: 'patch must be an object' } };
|
|
310
|
+
}
|
|
311
|
+
if (typeof payload.revision !== 'string') {
|
|
312
|
+
return { code: 400, body: { error: 'revision is required' } };
|
|
313
|
+
}
|
|
314
|
+
// Explicitly reject attempts to change identity fields via patch.
|
|
315
|
+
if ('project_id' in payload.patch) {
|
|
316
|
+
return { code: 400, body: { error: 'project_id cannot be changed from the viewer' } };
|
|
317
|
+
}
|
|
318
|
+
if ('schema_version' in payload.patch) {
|
|
319
|
+
return { code: 400, body: { error: 'schema_version cannot be changed via patch' } };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const file = path.join(found.project.path, '.changeledger', 'config.yml');
|
|
323
|
+
|
|
324
|
+
let result;
|
|
325
|
+
try {
|
|
326
|
+
mutateConfig(file, (before) => {
|
|
327
|
+
if (revision(before) !== payload.revision) {
|
|
328
|
+
throw new Error('configuration changed on disk; reload before saving');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const doc = parseDocument(before, { merge: false });
|
|
332
|
+
const config = doc.toJS() ?? {};
|
|
333
|
+
|
|
334
|
+
// Fail closed for future schema
|
|
335
|
+
const schemaVersion = getSchemaVersion(config);
|
|
336
|
+
if (schemaVersion > SUPPORTED_SCHEMA_VERSION) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`config schema ${schemaVersion} is newer than supported schema ${SUPPORTED_SCHEMA_VERSION}`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
applyPatch(doc, payload.patch, config);
|
|
343
|
+
|
|
344
|
+
const patched = doc.toString();
|
|
345
|
+
const candidate = parseYaml(patched);
|
|
346
|
+
|
|
347
|
+
// Identity guard
|
|
348
|
+
if (String(candidate.project_id ?? '') !== String(found.project.id)) {
|
|
349
|
+
throw new Error('project_id cannot be changed from the viewer');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Structural validation
|
|
353
|
+
const repo = loadRepo(found.project.path);
|
|
354
|
+
resolveRepoPath(repo.repoRoot, candidate.changes_dir, 'changes_dir');
|
|
355
|
+
resolveSpecsDir(repo.repoRoot, candidate);
|
|
356
|
+
const candidateRepo = loadRepoWithConfig(repo.repoRoot, repo.changeledgerDir, candidate);
|
|
357
|
+
const { errors } = checkRepo(candidateRepo);
|
|
358
|
+
if (errors.length) throw new Error(errors[0].message);
|
|
359
|
+
|
|
360
|
+
result = { content: patched, rev: revision(patched) };
|
|
361
|
+
return patched;
|
|
362
|
+
});
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (error.message === 'configuration changed on disk; reload before saving') {
|
|
365
|
+
return { code: 409, body: { error: error.message } };
|
|
366
|
+
}
|
|
367
|
+
return { code: 400, body: { error: error.message } };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { code: 200, body: { ok: true, revision: result.rev } };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Preview the migration without writing. Returns summary + candidate YAML.
|
|
374
|
+
export function previewConfigMigration(projects, id, rev) {
|
|
375
|
+
const found = projectFor(projects, id);
|
|
376
|
+
if (!found.project) return found;
|
|
377
|
+
const file = path.join(found.project.path, '.changeledger', 'config.yml');
|
|
378
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
379
|
+
if (rev && revision(content) !== rev) {
|
|
380
|
+
return { code: 409, body: { error: 'configuration changed on disk; reload before saving' } };
|
|
381
|
+
}
|
|
382
|
+
let migrationResult;
|
|
383
|
+
try {
|
|
384
|
+
migrationResult = buildMigration(content);
|
|
385
|
+
} catch (e) {
|
|
386
|
+
return { code: 400, body: { error: e.message } };
|
|
387
|
+
}
|
|
388
|
+
if (!migrationResult) {
|
|
389
|
+
return {
|
|
390
|
+
code: 200,
|
|
391
|
+
body: {
|
|
392
|
+
already_current: true,
|
|
393
|
+
message: `Config is already at schema ${SUPPORTED_SCHEMA_VERSION}`,
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
code: 200,
|
|
399
|
+
body: {
|
|
400
|
+
summary: `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION} (dry run)`,
|
|
401
|
+
changes: migrationResult.changes,
|
|
402
|
+
yaml: migrationResult.yaml,
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Apply the migration atomically. Uses the same engine as `changeledger config migrate`.
|
|
408
|
+
// Revision check and write are inside mutateFileAtomic to avoid TOCTOU races.
|
|
409
|
+
export function applyConfigMigration(projects, payload, { mutateConfig = mutateFileAtomic } = {}) {
|
|
410
|
+
const found = projectFor(projects, payload.project);
|
|
411
|
+
if (!found.project) return found;
|
|
412
|
+
if (typeof payload.revision !== 'string') {
|
|
413
|
+
return { code: 400, body: { error: 'revision is required' } };
|
|
414
|
+
}
|
|
415
|
+
const file = path.join(found.project.path, '.changeledger', 'config.yml');
|
|
416
|
+
|
|
417
|
+
let result;
|
|
418
|
+
try {
|
|
419
|
+
mutateConfig(file, (before) => {
|
|
420
|
+
if (revision(before) !== payload.revision) {
|
|
421
|
+
throw new Error('configuration changed on disk; reload before saving');
|
|
422
|
+
}
|
|
423
|
+
let migrationResult;
|
|
424
|
+
try {
|
|
425
|
+
migrationResult = buildMigration(before);
|
|
426
|
+
} catch (e) {
|
|
427
|
+
throw new Error(e.message);
|
|
428
|
+
}
|
|
429
|
+
if (!migrationResult) {
|
|
430
|
+
result = { already_current: true, rev: payload.revision };
|
|
431
|
+
return undefined; // no write needed
|
|
432
|
+
}
|
|
433
|
+
result = { ok: true, rev: revision(migrationResult.yaml) };
|
|
434
|
+
return migrationResult.yaml;
|
|
435
|
+
});
|
|
436
|
+
} catch (error) {
|
|
437
|
+
if (error.message === 'configuration changed on disk; reload before saving') {
|
|
438
|
+
return { code: 409, body: { error: error.message } };
|
|
439
|
+
}
|
|
440
|
+
return { code: 400, body: { error: error.message } };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (result.already_current) {
|
|
444
|
+
return { code: 200, body: { already_current: true, revision: result.rev } };
|
|
445
|
+
}
|
|
446
|
+
return { code: 200, body: { ok: true, revision: result.rev } };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Allowlisted fields the form patch may update.
|
|
450
|
+
const PATCH_ALLOWED = new Set([
|
|
451
|
+
'project_name',
|
|
452
|
+
'language',
|
|
453
|
+
'tdd',
|
|
454
|
+
'changes_dir',
|
|
455
|
+
'specs_dir',
|
|
456
|
+
'statuses',
|
|
457
|
+
'stages',
|
|
458
|
+
'readiness',
|
|
459
|
+
'types',
|
|
460
|
+
'release',
|
|
461
|
+
]);
|
|
462
|
+
|
|
463
|
+
const CANONICAL_STATUSES_REQUIRED = new Set([
|
|
464
|
+
'draft',
|
|
465
|
+
'approved',
|
|
466
|
+
'in-progress',
|
|
467
|
+
'in-review',
|
|
468
|
+
'in-validation',
|
|
469
|
+
'blocked',
|
|
470
|
+
'done',
|
|
471
|
+
'discarded',
|
|
472
|
+
]);
|
|
473
|
+
|
|
474
|
+
const CANONICAL_STAGES_REQUIRED = new Set([
|
|
475
|
+
'request',
|
|
476
|
+
'investigation',
|
|
477
|
+
'proposal',
|
|
478
|
+
'specification',
|
|
479
|
+
'plan',
|
|
480
|
+
'log',
|
|
481
|
+
]);
|
|
482
|
+
|
|
483
|
+
function applyPatch(doc, patch, currentConfig) {
|
|
484
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
485
|
+
if (!PATCH_ALLOWED.has(key)) continue;
|
|
486
|
+
|
|
487
|
+
if (key === 'types') {
|
|
488
|
+
applyTypesPatch(doc, value, currentConfig.types ?? {});
|
|
489
|
+
} else if (key === 'release') {
|
|
490
|
+
applyReleasePatch(doc, value);
|
|
491
|
+
} else if (key === 'readiness') {
|
|
492
|
+
applyReadinessPatch(doc, value);
|
|
493
|
+
} else if (key === 'statuses') {
|
|
494
|
+
applyRequiredListPatch(doc, 'statuses', value, CANONICAL_STATUSES_REQUIRED);
|
|
495
|
+
} else if (key === 'stages') {
|
|
496
|
+
applyRequiredListPatch(doc, 'stages', value, CANONICAL_STAGES_REQUIRED);
|
|
497
|
+
} else {
|
|
498
|
+
doc.set(key, value);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function applyRequiredListPatch(doc, key, proposed, required) {
|
|
504
|
+
if (!Array.isArray(proposed) || proposed.some((value) => typeof value !== 'string')) {
|
|
505
|
+
throw new Error(`${key} must be a list of strings`);
|
|
506
|
+
}
|
|
507
|
+
const duplicate = proposed.find((value, index) => proposed.indexOf(value) !== index);
|
|
508
|
+
if (duplicate) throw new Error(`${key} contains duplicate value "${duplicate}"`);
|
|
509
|
+
const missing = [...required].find((value) => !proposed.includes(value));
|
|
510
|
+
if (missing) throw new Error(`${key} cannot remove required value "${missing}"`);
|
|
511
|
+
doc.set(key, proposed);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function applyTypesPatch(doc, typesPatch, currentTypes) {
|
|
515
|
+
for (const [typeName, typeDef] of Object.entries(typesPatch)) {
|
|
516
|
+
if (!Object.hasOwn(currentTypes, typeName)) continue; // don't add new types
|
|
517
|
+
if (!typeDef || typeof typeDef !== 'object') continue;
|
|
518
|
+
if (Array.isArray(typeDef.stages)) {
|
|
519
|
+
doc.setIn(['types', typeName, 'stages'], typeDef.stages);
|
|
520
|
+
}
|
|
521
|
+
if (typeof typeDef.review_required === 'boolean') {
|
|
522
|
+
doc.setIn(['types', typeName, 'review_required'], typeDef.review_required);
|
|
523
|
+
} else if (typeDef.review_required === null) {
|
|
524
|
+
doc.deleteIn(['types', typeName, 'review_required']);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function applyReleasePatch(doc, releasePatch) {
|
|
530
|
+
if (releasePatch.impacts && typeof releasePatch.impacts === 'object') {
|
|
531
|
+
for (const [type, impact] of Object.entries(releasePatch.impacts)) {
|
|
532
|
+
if (impact === null) doc.deleteIn(['release', 'impacts', type]);
|
|
533
|
+
else doc.setIn(['release', 'impacts', type], impact);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function applyReadinessPatch(doc, readinessPatch) {
|
|
539
|
+
if (!readinessPatch || typeof readinessPatch !== 'object') return;
|
|
540
|
+
if (Array.isArray(readinessPatch.target_patterns)) {
|
|
541
|
+
doc.setIn(['readiness', 'target_patterns'], readinessPatch.target_patterns);
|
|
542
|
+
}
|
|
543
|
+
if (Array.isArray(readinessPatch.verification_patterns)) {
|
|
544
|
+
doc.setIn(['readiness', 'verification_patterns'], readinessPatch.verification_patterns);
|
|
545
|
+
}
|
|
546
|
+
}
|
package/src/viewer/public/api.js
CHANGED
|
@@ -41,9 +41,37 @@ const postProject = (route, body) =>
|
|
|
41
41
|
body: JSON.stringify(body),
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
const jsonOrThrow = async (response) => {
|
|
45
|
+
const body = await response.json();
|
|
46
|
+
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
|
47
|
+
return body;
|
|
48
|
+
};
|
|
49
|
+
|
|
44
50
|
export const postProjectConfig = (project, content, revision) =>
|
|
45
51
|
postProject('/api/project-config', { project, content, revision });
|
|
46
52
|
|
|
53
|
+
export const getProjectConfigStructured = (project) =>
|
|
54
|
+
fetch(`/api/project-config-structured?project=${encodeURIComponent(project)}`).then(async (r) => {
|
|
55
|
+
const body = await r.json();
|
|
56
|
+
if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
|
|
57
|
+
return body;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const patchProjectConfigApi = (project, patch, revision) =>
|
|
61
|
+
postProject('/api/project-config-patch', { project, patch, revision });
|
|
62
|
+
|
|
63
|
+
export const getConfigMigrationPreview = (project, revision) =>
|
|
64
|
+
fetch(
|
|
65
|
+
`/api/project-config-migrate-preview?project=${encodeURIComponent(project)}&revision=${encodeURIComponent(revision ?? '')}`,
|
|
66
|
+
).then(async (r) => {
|
|
67
|
+
const body = await r.json();
|
|
68
|
+
if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
|
|
69
|
+
return body;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const postConfigMigrationApply = (project, revision) =>
|
|
73
|
+
postProject('/api/project-config-migrate-apply', { project, revision }).then(jsonOrThrow);
|
|
74
|
+
|
|
47
75
|
export const postProjectPath = (project, path) =>
|
|
48
76
|
postProject('/api/project-path', { project, path });
|
|
49
77
|
|