changeledger 0.3.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.
Files changed (44) hide show
  1. package/AGENTS.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +160 -0
  4. package/bin/changeledger.mjs +375 -0
  5. package/package.json +72 -0
  6. package/src/atomic-write.mjs +134 -0
  7. package/src/change.mjs +102 -0
  8. package/src/check.mjs +536 -0
  9. package/src/commands/agent.mjs +256 -0
  10. package/src/commands/check.mjs +48 -0
  11. package/src/commands/graduate.mjs +105 -0
  12. package/src/commands/init.mjs +43 -0
  13. package/src/commands/new.mjs +164 -0
  14. package/src/commands/register.mjs +28 -0
  15. package/src/commands/release.mjs +138 -0
  16. package/src/commands/view.mjs +52 -0
  17. package/src/config.mjs +76 -0
  18. package/src/contract.mjs +100 -0
  19. package/src/git.mjs +73 -0
  20. package/src/lifecycle.mjs +57 -0
  21. package/src/metrics.mjs +143 -0
  22. package/src/paths.mjs +12 -0
  23. package/src/registry.mjs +55 -0
  24. package/src/release.mjs +71 -0
  25. package/src/repo.mjs +122 -0
  26. package/src/slug.mjs +12 -0
  27. package/src/spec.mjs +12 -0
  28. package/src/viewer/domain.mjs +133 -0
  29. package/src/viewer/public/api.js +25 -0
  30. package/src/viewer/public/app-state.js +87 -0
  31. package/src/viewer/public/app.js +717 -0
  32. package/src/viewer/public/index.html +64 -0
  33. package/src/viewer/public/security.js +65 -0
  34. package/src/viewer/public/state.js +31 -0
  35. package/src/viewer/public/styles.css +1062 -0
  36. package/src/viewer/public/templates.js +9 -0
  37. package/src/viewer/public/view-parts.js +162 -0
  38. package/src/viewer/public/view-renderers.js +191 -0
  39. package/src/viewer/server/router.mjs +200 -0
  40. package/src/viewer/server/security.mjs +27 -0
  41. package/src/writer.mjs +151 -0
  42. package/src/yaml.mjs +75 -0
  43. package/templates/AGENTS.md +540 -0
  44. package/templates/config.yml +55 -0
package/src/writer.mjs ADDED
@@ -0,0 +1,151 @@
1
+ // Pure text transforms on a change file. They preserve the rest of the document
2
+ // and are the basis for the `changeledger status`/`log`/`task` mutation commands.
3
+
4
+ import { parseDocument } from 'yaml';
5
+
6
+ const FM = /^---\n([\s\S]*?)\n---\n?/;
7
+
8
+ export function setStatus(text, status) {
9
+ return mutateFrontmatter(text, (doc) => {
10
+ setRequired(doc, 'status', status);
11
+ });
12
+ }
13
+
14
+ // Sets, updates or removes the optional `owner:` frontmatter line. A falsy owner
15
+ // removes it. New lines are placed right after `depends_on`.
16
+ export function setOwner(text, owner) {
17
+ return mutateFrontmatter(text, (doc) => {
18
+ doc.delete('owner');
19
+ if (owner) {
20
+ requireKey(doc, 'depends_on');
21
+ doc.set('owner', owner);
22
+ moveKeyAfter(doc, 'owner', 'depends_on');
23
+ }
24
+ });
25
+ }
26
+
27
+ // Sets or removes the optional `archived: true` frontmatter line.
28
+ export function setArchived(text, archived) {
29
+ return mutateFrontmatter(text, (doc) => {
30
+ doc.delete('archived');
31
+ if (archived) {
32
+ requireKey(doc, 'depends_on');
33
+ doc.set('archived', true);
34
+ moveKeyAfter(doc, 'archived', 'depends_on');
35
+ }
36
+ });
37
+ }
38
+
39
+ // Sets or removes the optional `reviewed: true` frontmatter line. It marks the
40
+ // graduation question as resolved (graduated to a spec, or deliberately skipped).
41
+ export function setReviewed(text, reviewed) {
42
+ return mutateFrontmatter(text, (doc) => {
43
+ doc.delete('reviewed');
44
+ if (reviewed) {
45
+ requireKey(doc, 'depends_on');
46
+ doc.set('reviewed', true);
47
+ moveKeyAfter(doc, 'reviewed', 'depends_on');
48
+ }
49
+ });
50
+ }
51
+
52
+ // Refreshes a spec's `updated:` frontmatter line, leaving title, tags and body
53
+ // untouched. Used when graduating a change into an existing spec.
54
+ export function setSpecUpdated(text, iso) {
55
+ return mutateFrontmatter(text, (doc) => {
56
+ setRequired(doc, 'updated', iso);
57
+ });
58
+ }
59
+
60
+ function mutateFrontmatter(text, mutate) {
61
+ const m = text.match(FM);
62
+ if (!m) throw new Error('missing frontmatter');
63
+ const doc = parseDocument(m[1], { merge: false, uniqueKeys: true });
64
+ if (doc.errors.length) throw doc.errors[0];
65
+ if (!doc.contents || !Array.isArray(doc.contents.items)) {
66
+ throw new Error('frontmatter must be a YAML mapping');
67
+ }
68
+ mutate(doc);
69
+ const fm = doc.toString({ lineWidth: 0 });
70
+ return `---\n${fm.endsWith('\n') ? fm : `${fm}\n`}---\n${text.slice(m[0].length)}`;
71
+ }
72
+
73
+ function setRequired(doc, key, value) {
74
+ requireKey(doc, key);
75
+ doc.set(key, value);
76
+ }
77
+
78
+ function requireKey(doc, key) {
79
+ if (!doc.has(key)) throw new Error(`missing ${key} in frontmatter`);
80
+ }
81
+
82
+ function moveKeyAfter(doc, key, after) {
83
+ const items = doc.contents?.items;
84
+ if (!Array.isArray(items)) return;
85
+ const from = items.findIndex((item) => item.key?.value === key);
86
+ const to = items.findIndex((item) => item.key?.value === after);
87
+ if (from === -1 || to === -1 || from === to + 1) return;
88
+ const [item] = items.splice(from, 1);
89
+ const nextTo = items.findIndex((candidate) => candidate.key?.value === after);
90
+ items.splice(nextTo + 1, 0, item);
91
+ }
92
+
93
+ export function appendLog(text, iso, message) {
94
+ const lines = text.split('\n');
95
+ const start = lines.findIndex((l) => /^##\s+Log\s*$/.test(l));
96
+ // The Log is the lifecycle transition ledger, present in every change once its
97
+ // status moves. Some types (e.g. chore) don't scaffold it, so create it.
98
+ if (start === -1) {
99
+ const body = `${text.replace(/\s*$/, '')}\n\n## Log\n\n- **${iso}** — ${message}\n`;
100
+ return body;
101
+ }
102
+
103
+ let end = lines.length;
104
+ for (let j = start + 1; j < lines.length; j++) {
105
+ if (/^##\s+/.test(lines[j])) {
106
+ end = j;
107
+ break;
108
+ }
109
+ }
110
+ let at = end;
111
+ while (at > start + 1 && lines[at - 1].trim() === '') at--;
112
+
113
+ lines.splice(at, 0, `- **${iso}** — ${message}`);
114
+ return lines.join('\n');
115
+ }
116
+
117
+ // state: 'done' | 'blocked' | 'todo'. n is 1-based within the ## Plan checklist.
118
+ export function setTask(text, n, state, { iso, reason } = {}) {
119
+ const lines = text.split('\n');
120
+ const start = lines.findIndex((l) => /^##\s+Plan\s*$/.test(l));
121
+ if (start === -1) throw new Error('no ## Plan section');
122
+
123
+ let count = 0;
124
+ let target = -1;
125
+ for (let j = start + 1; j < lines.length; j++) {
126
+ if (/^##\s+/.test(lines[j])) break;
127
+ if (/^- \[( |x|!)\]/.test(lines[j].trim())) {
128
+ count++;
129
+ if (count === n) {
130
+ target = j;
131
+ break;
132
+ }
133
+ }
134
+ }
135
+ if (target === -1) throw new Error(`no task #${n} in ## Plan`);
136
+
137
+ const line = lines[target];
138
+ const dash = line.indexOf(' — ');
139
+ const head = dash === -1 ? line : line.slice(0, dash);
140
+
141
+ if (state === 'done') {
142
+ if (!iso) throw new Error('done task needs a timestamp');
143
+ lines[target] = `${head.replace(/- \[[ !]\]/, '- [x]')} — ${iso}`;
144
+ } else if (state === 'blocked') {
145
+ if (!reason) throw new Error('blocked task needs a reason');
146
+ lines[target] = `${head.replace(/- \[[ x]\]/, '- [!]')} — ${reason}`;
147
+ } else {
148
+ lines[target] = head.replace(/- \[[x!]\]/, '- [ ]');
149
+ }
150
+ return lines.join('\n');
151
+ }
package/src/yaml.mjs ADDED
@@ -0,0 +1,75 @@
1
+ import { parseDocument, stringify } from 'yaml';
2
+
3
+ // YAML is a broad format; ChangeLedger keeps this wrapper narrow so callers get
4
+ // stable domain behavior while syntax handling is delegated to a mature parser.
5
+
6
+ const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
7
+
8
+ export function parseYaml(text) {
9
+ const doc = parseDocument(text, {
10
+ merge: false,
11
+ uniqueKeys: true,
12
+ });
13
+ if (doc.errors.length) throw yamlError(doc.errors[0]);
14
+ const value = doc.toJS() ?? {};
15
+ if (Array.isArray(value) || !value || typeof value !== 'object') {
16
+ throw new Error('YAML document must be a mapping');
17
+ }
18
+ assertSafeKeys(value);
19
+ return value;
20
+ }
21
+
22
+ export function stringifyYaml(value) {
23
+ return stringify(value, { lineWidth: 0 });
24
+ }
25
+
26
+ function yamlError(error) {
27
+ if (/keys must be unique/i.test(error.message)) {
28
+ return new Error(`Duplicate key "${duplicateKey(error.message) ?? 'unknown'}" in YAML`);
29
+ }
30
+ return error;
31
+ }
32
+
33
+ function duplicateKey(message) {
34
+ const seen = new Set();
35
+ for (const line of message.split('\n')) {
36
+ const match = line.match(/^(\S[^:]*):/);
37
+ if (!match) continue;
38
+ if (seen.has(match[1])) return match[1];
39
+ seen.add(match[1]);
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function assertSafeKeys(value) {
45
+ if (!value || typeof value !== 'object') return;
46
+ if (Array.isArray(value)) {
47
+ value.forEach(assertSafeKeys);
48
+ return;
49
+ }
50
+ for (const [key, child] of Object.entries(value)) {
51
+ if (RESERVED_KEYS.has(key)) throw new Error(`Unsafe key "${key}" in YAML`);
52
+ assertSafeKeys(child);
53
+ }
54
+ }
55
+
56
+ export function serializeScalar(value) {
57
+ if (typeof value === 'boolean' || typeof value === 'number') return String(value);
58
+ const s = String(value ?? '');
59
+ return needsQuoting(s) ? quoteDouble(s) : s;
60
+ }
61
+
62
+ function needsQuoting(s) {
63
+ if (s === '') return true;
64
+ if (s !== s.trim()) return true;
65
+ try {
66
+ if (parseYaml(`k: ${s}`).k !== s) return true;
67
+ } catch {
68
+ return true;
69
+ }
70
+ return stringify({ k: s }).trimEnd() !== `k: ${s}`;
71
+ }
72
+
73
+ function quoteDouble(s) {
74
+ return stringify(s, { defaultStringType: 'QUOTE_DOUBLE', lineWidth: 0 }).trimEnd();
75
+ }