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.
- package/AGENTS.md +25 -0
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/bin/changeledger.mjs +375 -0
- package/package.json +72 -0
- package/src/atomic-write.mjs +134 -0
- package/src/change.mjs +102 -0
- package/src/check.mjs +536 -0
- package/src/commands/agent.mjs +256 -0
- package/src/commands/check.mjs +48 -0
- package/src/commands/graduate.mjs +105 -0
- package/src/commands/init.mjs +43 -0
- package/src/commands/new.mjs +164 -0
- package/src/commands/register.mjs +28 -0
- package/src/commands/release.mjs +138 -0
- package/src/commands/view.mjs +52 -0
- package/src/config.mjs +76 -0
- package/src/contract.mjs +100 -0
- package/src/git.mjs +73 -0
- package/src/lifecycle.mjs +57 -0
- package/src/metrics.mjs +143 -0
- package/src/paths.mjs +12 -0
- package/src/registry.mjs +55 -0
- package/src/release.mjs +71 -0
- package/src/repo.mjs +122 -0
- package/src/slug.mjs +12 -0
- package/src/spec.mjs +12 -0
- package/src/viewer/domain.mjs +133 -0
- package/src/viewer/public/api.js +25 -0
- package/src/viewer/public/app-state.js +87 -0
- package/src/viewer/public/app.js +717 -0
- package/src/viewer/public/index.html +64 -0
- package/src/viewer/public/security.js +65 -0
- package/src/viewer/public/state.js +31 -0
- package/src/viewer/public/styles.css +1062 -0
- package/src/viewer/public/templates.js +9 -0
- package/src/viewer/public/view-parts.js +162 -0
- package/src/viewer/public/view-renderers.js +191 -0
- package/src/viewer/server/router.mjs +200 -0
- package/src/viewer/server/security.mjs +27 -0
- package/src/writer.mjs +151 -0
- package/src/yaml.mjs +75 -0
- package/templates/AGENTS.md +540 -0
- package/templates/config.yml +55 -0
package/src/check.mjs
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
// Pure validator: takes a loaded repo ({ config, changes }) and returns
|
|
2
|
+
// { errors, warnings }. No IO — the `changeledger check` command does the IO and printing.
|
|
3
|
+
|
|
4
|
+
import { compareVersions, parseVersion, RELEASE_IMPACTS } from './release.mjs';
|
|
5
|
+
|
|
6
|
+
const REQUIRED = ['id', 'title', 'type', 'status', 'created', 'depends_on'];
|
|
7
|
+
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
8
|
+
const ID_FORM = /^\d{8}-\d{6}$/;
|
|
9
|
+
|
|
10
|
+
export function checkRepo({ config, changes, specs = [], releases = [] }, opts = {}) {
|
|
11
|
+
const errors = [];
|
|
12
|
+
const warnings = [];
|
|
13
|
+
const err = (c, message) => errors.push({ file: c?.name ?? '(repo)', message });
|
|
14
|
+
const warn = (c, message) => warnings.push({ file: c?.name ?? '(repo)', message });
|
|
15
|
+
|
|
16
|
+
checkConfig(config, (c, message) => err(c ?? { name: '.changeledger/config.yml' }, message));
|
|
17
|
+
|
|
18
|
+
const statuses = config.statuses ?? [];
|
|
19
|
+
const types = config.types ?? {};
|
|
20
|
+
const canonical = config.stages ?? [];
|
|
21
|
+
|
|
22
|
+
// Scope: a single change (fast, post-write check) or the whole repo.
|
|
23
|
+
let targets = changes;
|
|
24
|
+
if (opts.id) {
|
|
25
|
+
targets = changes.filter((c) => String(c.frontmatter?.id) === String(opts.id));
|
|
26
|
+
if (!targets.length) err(null, `no change with id "${opts.id}"`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const c of targets) {
|
|
30
|
+
const fm = c.frontmatter ?? {};
|
|
31
|
+
|
|
32
|
+
checkConflictMarkers(c, err);
|
|
33
|
+
|
|
34
|
+
for (const k of REQUIRED) if (!(k in fm)) err(c, `missing frontmatter "${k}"`);
|
|
35
|
+
if (fm.created && !ISO_UTC.test(fm.created)) err(c, `created not ISO 8601 UTC: ${fm.created}`);
|
|
36
|
+
if (fm.id && !ID_FORM.test(String(fm.id))) err(c, `id not in YYYYMMDD-HHMMSS form: ${fm.id}`);
|
|
37
|
+
if (fm.id && c.name && !c.name.startsWith(`${fm.id}-`))
|
|
38
|
+
err(c, `filename does not match id "${fm.id}"`);
|
|
39
|
+
if (fm.type && !types[fm.type]) err(c, `unknown type "${fm.type}"`);
|
|
40
|
+
if (fm.status && !statuses.includes(fm.status)) err(c, `unknown status "${fm.status}"`);
|
|
41
|
+
if ('depends_on' in fm && !Array.isArray(fm.depends_on)) err(c, 'depends_on must be a list');
|
|
42
|
+
if ('archived' in fm && typeof fm.archived !== 'boolean') err(c, 'archived must be a boolean');
|
|
43
|
+
if ('reviewed' in fm && typeof fm.reviewed !== 'boolean') err(c, 'reviewed must be a boolean');
|
|
44
|
+
if ('release_impact' in fm && !RELEASE_IMPACTS.includes(fm.release_impact)) {
|
|
45
|
+
err(c, `release_impact "${fm.release_impact}" must be one of: ${RELEASE_IMPACTS.join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const present = (c.stages ?? []).map((s) => s.key);
|
|
49
|
+
for (const s of c.stages ?? []) {
|
|
50
|
+
const k = s.key;
|
|
51
|
+
if (!canonical.includes(k)) err(c, `unknown stage "## ${k}"`);
|
|
52
|
+
else if (s.heading && s.heading !== canonicalHeading(k)) {
|
|
53
|
+
err(c, `stage heading must be canonical: expected "## ${canonicalHeading(k)}"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const seenStages = new Set();
|
|
58
|
+
for (const k of present) {
|
|
59
|
+
if (seenStages.has(k)) err(c, `duplicate stage "## ${k}"`);
|
|
60
|
+
else seenStages.add(k);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const known = present.filter((k) => canonical.includes(k));
|
|
64
|
+
const ordered = [...known].sort((a, b) => canonical.indexOf(a) - canonical.indexOf(b));
|
|
65
|
+
if (known.join(',') !== ordered.join(',')) err(c, 'stages are out of canonical order');
|
|
66
|
+
|
|
67
|
+
const active = types[fm.type]?.stages;
|
|
68
|
+
if (active) {
|
|
69
|
+
for (const k of active)
|
|
70
|
+
if (!present.includes(k)) err(c, `missing active stage "## ${k}" for type ${fm.type}`);
|
|
71
|
+
// `log` is the lifecycle ledger — allowed on any type once status moves.
|
|
72
|
+
for (const k of known)
|
|
73
|
+
if (k !== 'log' && !active.includes(k))
|
|
74
|
+
err(c, `stage "## ${k}" is not active for type ${fm.type}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tasks = c.tasks ?? [];
|
|
78
|
+
checkTasks(c, tasks, err);
|
|
79
|
+
checkCriteria(c, c.criteria ?? [], err);
|
|
80
|
+
|
|
81
|
+
if (fm.status === 'done' && tasks.some((t) => t.state !== 'done')) {
|
|
82
|
+
const pending = tasks.filter((t) => t.state !== 'done').length;
|
|
83
|
+
warn(c, `status is "done" but ${pending} task(s) are not done`);
|
|
84
|
+
}
|
|
85
|
+
if (fm.status === 'blocked' && tasks.length && !tasks.some((t) => t.state === 'blocked')) {
|
|
86
|
+
warn(c, 'status is "blocked" but no task is marked [!]');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
checkCoverage(c, fm, active, config, warn, err);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Aggregate checks only make sense over the whole repo.
|
|
93
|
+
if (opts.id) return { errors, warnings };
|
|
94
|
+
|
|
95
|
+
const seen = new Map();
|
|
96
|
+
for (const c of changes) {
|
|
97
|
+
const id = c.frontmatter?.id;
|
|
98
|
+
if (!id) continue;
|
|
99
|
+
if (seen.has(id)) err(c, `duplicate id "${id}" (also in ${seen.get(id)})`);
|
|
100
|
+
else seen.set(id, c.name);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A dep containing ':' is a cross-project reference (`<project>:<changeId>`).
|
|
104
|
+
// It points at another repo, so the pure checker neither validates it nor
|
|
105
|
+
// includes it in the local cycle graph.
|
|
106
|
+
const isExternal = (d) => String(d).includes(':');
|
|
107
|
+
const ids = new Set([...seen.keys()]);
|
|
108
|
+
const graph = new Map();
|
|
109
|
+
for (const c of changes) {
|
|
110
|
+
const id = c.frontmatter?.id;
|
|
111
|
+
const deps = c.frontmatter?.depends_on ?? [];
|
|
112
|
+
for (const d of deps) {
|
|
113
|
+
if (!isExternal(d) && !ids.has(d)) err(c, `depends_on references missing change "${d}"`);
|
|
114
|
+
}
|
|
115
|
+
if (id)
|
|
116
|
+
graph.set(
|
|
117
|
+
id,
|
|
118
|
+
deps.filter((d) => !isExternal(d) && ids.has(d)),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const cycle = findCycle(graph);
|
|
123
|
+
if (cycle) err(null, `dependency cycle: ${cycle.join(' → ')}`);
|
|
124
|
+
|
|
125
|
+
checkSpecs(changes, specs, ids, err, warn);
|
|
126
|
+
checkReleases(releases, new Map(changes.map((c) => [String(c.frontmatter?.id), c])), err);
|
|
127
|
+
|
|
128
|
+
return { errors, warnings };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function checkReleases(releases, changesById, err) {
|
|
132
|
+
const seenVersions = new Set();
|
|
133
|
+
const releasedChanges = new Map();
|
|
134
|
+
const baselines = [];
|
|
135
|
+
|
|
136
|
+
for (const release of releases) {
|
|
137
|
+
const version = release.version;
|
|
138
|
+
try {
|
|
139
|
+
parseVersion(version);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
err(release, error.message);
|
|
142
|
+
}
|
|
143
|
+
if (version && release.name !== `${version}.yml`) {
|
|
144
|
+
err(release, `filename must match version "${version}.yml"`);
|
|
145
|
+
}
|
|
146
|
+
if (seenVersions.has(version)) err(release, `duplicate release version "${version}"`);
|
|
147
|
+
else seenVersions.add(version);
|
|
148
|
+
if (!ISO_UTC.test(release.created ?? '')) {
|
|
149
|
+
err(release, `created not ISO 8601 UTC: ${release.created}`);
|
|
150
|
+
}
|
|
151
|
+
if (!Array.isArray(release.changes)) {
|
|
152
|
+
err(release, 'changes must be a list');
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if ('baseline' in release && typeof release.baseline !== 'boolean') {
|
|
156
|
+
err(release, 'baseline must be a boolean');
|
|
157
|
+
}
|
|
158
|
+
if (release.baseline === true) baselines.push(release);
|
|
159
|
+
const local = new Set();
|
|
160
|
+
for (const rawId of release.changes) {
|
|
161
|
+
const id = String(rawId);
|
|
162
|
+
const change = changesById.get(id);
|
|
163
|
+
if (!change) err(release, `references missing change "${id}"`);
|
|
164
|
+
else if (change.frontmatter?.status !== 'done') {
|
|
165
|
+
err(release, `references change "${id}" whose status is not done`);
|
|
166
|
+
}
|
|
167
|
+
if (local.has(id)) err(release, `contains duplicate change "${id}"`);
|
|
168
|
+
else local.add(id);
|
|
169
|
+
if (releasedChanges.has(id)) {
|
|
170
|
+
err(release, `change "${id}" already appears in ${releasedChanges.get(id)}`);
|
|
171
|
+
} else {
|
|
172
|
+
releasedChanges.set(id, release.name);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (baselines.length > 1) {
|
|
177
|
+
for (const release of baselines.slice(1))
|
|
178
|
+
err(release, 'release history has multiple baselines');
|
|
179
|
+
}
|
|
180
|
+
if (baselines.length === 1) {
|
|
181
|
+
const baseline = baselines[0];
|
|
182
|
+
for (const release of releases) {
|
|
183
|
+
try {
|
|
184
|
+
if (compareVersions(release.version, baseline.version) < 0) {
|
|
185
|
+
err(baseline, `baseline ${baseline.version} is not the earliest release`);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// Invalid versions are already reported above.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function canonicalHeading(key) {
|
|
196
|
+
return key.charAt(0).toUpperCase() + key.slice(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function checkTasks(c, tasks, err) {
|
|
200
|
+
for (const t of tasks) {
|
|
201
|
+
if (t.state === 'done' && !ISO_UTC.test(t.resolvedAt ?? '')) {
|
|
202
|
+
err(c, 'done task is missing an ISO 8601 UTC resolution timestamp');
|
|
203
|
+
}
|
|
204
|
+
if (t.state === 'blocked' && !String(t.reason ?? '').trim()) {
|
|
205
|
+
err(c, 'blocked task is missing a reason');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function checkCriteria(c, criteria, err) {
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
for (const cr of criteria) {
|
|
213
|
+
if (seen.has(cr)) err(c, `duplicate criterion "${cr}"`);
|
|
214
|
+
else seen.add(cr);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Validates the spec layer and its links to changes. `graduate` records two
|
|
219
|
+
// markers: in the change Log `graduado a spec \`<file>\``, and in the spec body
|
|
220
|
+
// `Graduado del change <id>`.
|
|
221
|
+
function checkSpecs(changes, specs, changeIds, err, warn) {
|
|
222
|
+
const specNames = new Set(specs.map((s) => s.name));
|
|
223
|
+
|
|
224
|
+
// change → spec links (from each change's Log graduation marker).
|
|
225
|
+
const incoming = new Set(); // spec names a change graduated to
|
|
226
|
+
const activityBySpec = new Map(); // spec name → latest linked-change activity
|
|
227
|
+
for (const c of changes) {
|
|
228
|
+
for (const m of graduationMarkers(c)) {
|
|
229
|
+
const ts = m[1].trim();
|
|
230
|
+
const specName = m[2].trim();
|
|
231
|
+
if (!specNames.has(specName)) {
|
|
232
|
+
err(c, `graduated to a missing spec "${specName}"`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
incoming.add(specName);
|
|
236
|
+
const prev = activityBySpec.get(specName);
|
|
237
|
+
if (ts && (!prev || ts > prev)) activityBySpec.set(specName, ts);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const s of specs) {
|
|
242
|
+
const fm = s.frontmatter ?? {};
|
|
243
|
+
if (fm.updated && !ISO_UTC.test(fm.updated)) err(s, `updated not ISO 8601 UTC: ${fm.updated}`);
|
|
244
|
+
|
|
245
|
+
// spec → change backlinks.
|
|
246
|
+
let hasValidBacklink = false;
|
|
247
|
+
for (const m of String(s.body ?? '').matchAll(/Graduado del change\s+(\d{8}-\d{6})/gi)) {
|
|
248
|
+
if (changeIds.has(m[1])) hasValidBacklink = true;
|
|
249
|
+
else err(s, `references a missing change "${m[1]}"`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!incoming.has(s.name) && !hasValidBacklink) {
|
|
253
|
+
warn(s, 'orphan spec (no change graduated it)');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const activity = activityBySpec.get(s.name);
|
|
257
|
+
if (fm.updated && ISO_UTC.test(fm.updated) && activity && activity > fm.updated) {
|
|
258
|
+
warn(s, `updated (${fm.updated}) is older than linked change activity (${activity})`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function logBody(change) {
|
|
264
|
+
return String((change.stages ?? []).find((s) => s.key === 'log')?.body ?? '');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function* graduationMarkers(change) {
|
|
268
|
+
for (const line of logBody(change).split('\n')) {
|
|
269
|
+
const m = line.match(
|
|
270
|
+
/\*\*(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\*\*\s*—\s*graduado a spec `([^`]+)`/i,
|
|
271
|
+
);
|
|
272
|
+
if (m) yield m;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Git merge conflict markers: exactly 7 of <, = or > at the start of a line.
|
|
277
|
+
// `<` and `>` never appear in normal markdown; `=` could be a setext H1
|
|
278
|
+
// underline, but ChangeLedger uses ATX headings, so this is safe in practice.
|
|
279
|
+
const CONFLICT = /^(<{7}|={7}|>{7})(\s|$)/;
|
|
280
|
+
|
|
281
|
+
function checkConflictMarkers(c, err) {
|
|
282
|
+
if (typeof c.text !== 'string') return;
|
|
283
|
+
const lines = c.text.split('\n');
|
|
284
|
+
for (let i = 0; i < lines.length; i++) {
|
|
285
|
+
if (CONFLICT.test(lines[i]))
|
|
286
|
+
err(c, `merge conflict marker "${lines[i].slice(0, 7)}" at line ${i + 1}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Definition-of-Ready coverage: when `tdd` is on (default), a change being built
|
|
291
|
+
// (approved/in-progress) whose type activates `## Specification` must map
|
|
292
|
+
// criteria ↔ Plan tasks both ways. Warnings only — it nudges, never blocks.
|
|
293
|
+
// It checks coverage, not whether a criterion is "test-grade" (not parseable).
|
|
294
|
+
function checkCoverage(c, fm, active, config, warn, err = () => {}) {
|
|
295
|
+
if (config?.tdd === false) return;
|
|
296
|
+
if (!active?.includes('specification')) return;
|
|
297
|
+
if (!['draft', 'approved', 'in-progress'].includes(fm.status)) return;
|
|
298
|
+
const report = fm.status === 'draft' ? warn : err;
|
|
299
|
+
|
|
300
|
+
const declared = c.criteria ?? [];
|
|
301
|
+
const declaredSet = new Set(declared);
|
|
302
|
+
const tasks = c.tasks ?? [];
|
|
303
|
+
const referenced = new Set(tasks.flatMap((t) => t.criteria ?? []));
|
|
304
|
+
|
|
305
|
+
for (const cr of c.criterionBlocks ?? []) {
|
|
306
|
+
const steps = new Set(cr.steps ?? []);
|
|
307
|
+
if (!steps.has('Given') || !steps.has('When') || !steps.has('Then')) {
|
|
308
|
+
report(c, `${cr.id} is not test-grade: missing Given/When/Then`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const t of tasks) {
|
|
313
|
+
if (!t.criteria?.length) continue;
|
|
314
|
+
for (const cr of t.criteria) {
|
|
315
|
+
if (!declaredSet.has(cr)) report(c, `Plan task references unknown criterion "${cr}"`);
|
|
316
|
+
}
|
|
317
|
+
if (!namesTargetAndVerification(t.text, config)) {
|
|
318
|
+
const hint = readinessHint(config);
|
|
319
|
+
const misplaced = misplacedVerificationSuffix(t, config);
|
|
320
|
+
for (const cr of t.criteria) {
|
|
321
|
+
if (misplaced) {
|
|
322
|
+
report(
|
|
323
|
+
c,
|
|
324
|
+
`Plan task for ${cr} puts verification in the reserved suffix; move "${misplaced}" before the final (CRn) block because "— ..." is reserved for done timestamps and blocked reasons`,
|
|
325
|
+
);
|
|
326
|
+
} else {
|
|
327
|
+
report(c, `Plan task for ${cr} must name target and verification (${hint})`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const cr of declared)
|
|
334
|
+
if (!referenced.has(cr)) warn(c, `${cr} is not covered by any Plan task`);
|
|
335
|
+
|
|
336
|
+
for (const t of tasks)
|
|
337
|
+
if (!t.criteria?.length && !isSupportTask(t.text)) {
|
|
338
|
+
const label = t.text.length > 50 ? `${t.text.slice(0, 50)}…` : t.text;
|
|
339
|
+
warn(c, `Plan task "${label}" references no criterion`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// A task ending with `(support)` is intentionally operational (running tests,
|
|
344
|
+
// reading docs, scaffolding) and is exempt from the "references no criterion"
|
|
345
|
+
// warning. Readiness checks (target + verification patterns) already skip
|
|
346
|
+
// tasks with no criteria, so no additional exclusion is needed there.
|
|
347
|
+
function isSupportTask(text) {
|
|
348
|
+
return /\(support\)\s*$/.test(text);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function namesTargetAndVerification(text, config) {
|
|
352
|
+
const readiness = readinessConfig(config);
|
|
353
|
+
return (
|
|
354
|
+
matchesAnyReadinessPattern(text, readiness.target_patterns) &&
|
|
355
|
+
matchesAnyReadinessPattern(text, readiness.verification_patterns)
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function misplacedVerificationSuffix(task, config) {
|
|
360
|
+
if (task.state !== 'todo' || !task.suffix) return null;
|
|
361
|
+
const readiness = readinessConfig(config);
|
|
362
|
+
return readiness.verification_patterns.find((pattern) =>
|
|
363
|
+
readinessPatternMatches(task.suffix, pattern),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function readinessConfig(config) {
|
|
368
|
+
return {
|
|
369
|
+
target_patterns: config?.readiness?.target_patterns ?? ['src/**'],
|
|
370
|
+
verification_patterns: config?.readiness?.verification_patterns ?? ['test/**'],
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function readinessHint(config) {
|
|
375
|
+
const readiness = readinessConfig(config);
|
|
376
|
+
const source = config?.readiness ? 'configured readiness' : 'default readiness';
|
|
377
|
+
return `${source}: target_patterns=${formatPatterns(readiness.target_patterns)}, verification_patterns=${formatPatterns(readiness.verification_patterns)}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function formatPatterns(patterns) {
|
|
381
|
+
return `[${patterns.map((p) => JSON.stringify(p)).join(', ')}]`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function matchesAnyReadinessPattern(text, patterns) {
|
|
385
|
+
return patterns.some((pattern) => readinessPatternMatches(text, pattern));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function readinessPatternMatches(text, pattern) {
|
|
389
|
+
if (/[*?]/.test(pattern)) return readinessGlob(pattern).test(text);
|
|
390
|
+
return text.includes(pattern);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function readinessGlob(pattern) {
|
|
394
|
+
let out = '';
|
|
395
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
396
|
+
const ch = pattern[i];
|
|
397
|
+
if (ch === '*') {
|
|
398
|
+
if (pattern[i + 1] === '*') {
|
|
399
|
+
out += '.*';
|
|
400
|
+
i++;
|
|
401
|
+
} else {
|
|
402
|
+
out += '[^\\s`)]+';
|
|
403
|
+
}
|
|
404
|
+
} else if (ch === '?') {
|
|
405
|
+
out += '[^\\s`)]';
|
|
406
|
+
} else {
|
|
407
|
+
out += escapeRegExp(ch);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return new RegExp(out);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function escapeRegExp(text) {
|
|
414
|
+
return text.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function checkConfig(config, err) {
|
|
418
|
+
const c = config ?? {};
|
|
419
|
+
for (const k of ['changes_dir', 'statuses', 'stages', 'types']) {
|
|
420
|
+
if (!(k in c)) err(null, `config missing "${k}"`);
|
|
421
|
+
}
|
|
422
|
+
if (
|
|
423
|
+
Array.isArray(c.statuses) &&
|
|
424
|
+
c.statuses.includes('done') &&
|
|
425
|
+
!c.statuses.includes('in-validation')
|
|
426
|
+
) {
|
|
427
|
+
err(null, 'config statuses must include "in-validation" before "done"');
|
|
428
|
+
}
|
|
429
|
+
if (
|
|
430
|
+
Array.isArray(c.statuses) &&
|
|
431
|
+
c.statuses.includes('in-validation') &&
|
|
432
|
+
c.statuses.includes('done') &&
|
|
433
|
+
c.statuses.indexOf('in-validation') > c.statuses.indexOf('done')
|
|
434
|
+
) {
|
|
435
|
+
err(null, 'config status "in-validation" must appear before "done"');
|
|
436
|
+
}
|
|
437
|
+
// changes_dir/specs_dir must stay inside the repo. The pure checker catches
|
|
438
|
+
// shape escapes (absolute paths, `..` traversal); the symlink case is enforced
|
|
439
|
+
// at command time by resolveRepoPath, which needs IO.
|
|
440
|
+
for (const k of ['changes_dir', 'specs_dir']) {
|
|
441
|
+
const v = c[k];
|
|
442
|
+
if (v === undefined) continue;
|
|
443
|
+
if (typeof v !== 'string' || v === '') err(null, `config "${k}" must be a relative path`);
|
|
444
|
+
else if (isPathEscape(v)) err(null, `config "${k}" escapes the repo root: ${v}`);
|
|
445
|
+
}
|
|
446
|
+
if ('statuses' in c && !Array.isArray(c.statuses)) err(null, 'config "statuses" must be a list');
|
|
447
|
+
if ('stages' in c && !Array.isArray(c.stages)) err(null, 'config "stages" must be a list');
|
|
448
|
+
if ('readiness' in c) checkReadinessConfig(c.readiness, err);
|
|
449
|
+
if ('release' in c) checkReleaseConfig(c.release, c.types ?? {}, err);
|
|
450
|
+
const canonical = Array.isArray(c.stages) ? c.stages : [];
|
|
451
|
+
for (const [type, def] of Object.entries(c.types ?? {})) {
|
|
452
|
+
for (const s of def?.stages ?? []) {
|
|
453
|
+
if (!canonical.includes(s))
|
|
454
|
+
err(null, `config type "${type}" references unknown stage "${s}"`);
|
|
455
|
+
}
|
|
456
|
+
if (def && 'review_required' in def && typeof def.review_required !== 'boolean')
|
|
457
|
+
err(null, `config type "${type}": review_required must be a boolean`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function checkReleaseConfig(release, types, err) {
|
|
462
|
+
if (!release || typeof release !== 'object' || Array.isArray(release)) {
|
|
463
|
+
err(null, 'config "release" must be a mapping');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (!release.impacts || typeof release.impacts !== 'object' || Array.isArray(release.impacts)) {
|
|
467
|
+
err(null, 'config "release.impacts" must be a mapping');
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
for (const [type, impact] of Object.entries(release.impacts)) {
|
|
471
|
+
if (!(type in types)) err(null, `config release impact references unknown type "${type}"`);
|
|
472
|
+
if (!RELEASE_IMPACTS.includes(impact)) {
|
|
473
|
+
err(
|
|
474
|
+
null,
|
|
475
|
+
`config release impact "${impact}" for "${type}" must be one of: ${RELEASE_IMPACTS.join(', ')}`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function checkReadinessConfig(readiness, err) {
|
|
482
|
+
if (!readiness || typeof readiness !== 'object' || Array.isArray(readiness)) {
|
|
483
|
+
err(null, 'config "readiness" must be a mapping');
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
for (const key of ['target_patterns', 'verification_patterns']) {
|
|
487
|
+
if (!(key in readiness)) continue;
|
|
488
|
+
if (!Array.isArray(readiness[key])) {
|
|
489
|
+
err(null, `config "readiness.${key}" must be a list`);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
for (const pattern of readiness[key]) {
|
|
493
|
+
if (typeof pattern !== 'string' || pattern.trim() === '') {
|
|
494
|
+
err(null, `config "readiness.${key}" entries must be non-empty strings`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// IO-free escape detection for configured paths: absolute (POSIX or Windows) or
|
|
501
|
+
// containing a `..` segment. Symlink escapes need IO and are caught at runtime.
|
|
502
|
+
function isPathEscape(p) {
|
|
503
|
+
if (/^([a-zA-Z]:[\\/]|[\\/])/.test(p)) return true;
|
|
504
|
+
return p.split(/[\\/]/).some((seg) => seg === '..');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function findCycle(graph) {
|
|
508
|
+
const WHITE = 0,
|
|
509
|
+
GRAY = 1,
|
|
510
|
+
BLACK = 2;
|
|
511
|
+
const color = new Map([...graph.keys()].map((k) => [k, WHITE]));
|
|
512
|
+
const stack = [];
|
|
513
|
+
|
|
514
|
+
const visit = (node) => {
|
|
515
|
+
color.set(node, GRAY);
|
|
516
|
+
stack.push(node);
|
|
517
|
+
for (const next of graph.get(node) ?? []) {
|
|
518
|
+
if (color.get(next) === GRAY) return [...stack.slice(stack.indexOf(next)), next];
|
|
519
|
+
if (color.get(next) === WHITE) {
|
|
520
|
+
const found = visit(next);
|
|
521
|
+
if (found) return found;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
stack.pop();
|
|
525
|
+
color.set(node, BLACK);
|
|
526
|
+
return null;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
for (const node of graph.keys()) {
|
|
530
|
+
if (color.get(node) === WHITE) {
|
|
531
|
+
const found = visit(node);
|
|
532
|
+
if (found) return found;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|