archgen-skill 0.0.5 → 0.0.6

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 (36) hide show
  1. package/README.md +12 -6
  2. package/bin/archgen.mjs +22 -5
  3. package/lib/block.js +1 -1
  4. package/lib/bootstrap.js +103 -0
  5. package/lib/doctor.js +45 -2
  6. package/lib/init.js +32 -3
  7. package/lib/install.js +20 -1
  8. package/lib/mcp-server.js +159 -0
  9. package/lib/migrations/001-stamp-provenance.mjs +3 -3
  10. package/lib/spawn-npm.js +54 -0
  11. package/lib/store.js +75 -5
  12. package/lib/uninstall-project.js +16 -3
  13. package/lib/version.js +11 -3
  14. package/package.json +3 -3
  15. package/vendor/skills/archgen/SKILL.md +363 -213
  16. package/vendor/skills/archgen/archgen.config.json +1 -1
  17. package/vendor/skills/archgen/references/code-standards.md +2 -2
  18. package/vendor/skills/archgen/assets/mcp-template.json +0 -42
  19. package/vendor/skills/archgen/assets/skill-registry.json +0 -24
  20. package/vendor/skills/archgen/references/artifact-templates.md +0 -406
  21. package/vendor/skills/archgen/references/interview.md +0 -275
  22. package/vendor/skills/archgen/references/mcp-and-skills.md +0 -117
  23. package/vendor/skills/archgen/references/orchestration.md +0 -292
  24. package/vendor/skills/archgen/references/platforms.md +0 -138
  25. package/vendor/skills/archgen/scripts/doc-index.mjs +0 -457
  26. package/vendor/skills/archgen/scripts/impact.mjs +0 -77
  27. package/vendor/skills/archgen/scripts/lib/graph.mjs +0 -222
  28. package/vendor/skills/archgen/scripts/lib/plan-coverage.mjs +0 -32
  29. package/vendor/skills/archgen/scripts/lib/scope.mjs +0 -65
  30. package/vendor/skills/archgen/scripts/lib/yaml.mjs +0 -492
  31. package/vendor/skills/archgen/scripts/next-tasks.mjs +0 -55
  32. package/vendor/skills/archgen/scripts/plan-graph.mjs +0 -329
  33. package/vendor/skills/archgen/scripts/set-status.mjs +0 -82
  34. package/vendor/skills/archgen/scripts/update-agents.mjs +0 -213
  35. package/vendor/skills/archgen/scripts/validate.mjs +0 -87
  36. package/vendor/skills/archgen/scripts/verify-plan.mjs +0 -70
@@ -1,492 +0,0 @@
1
- // yaml.mjs — zero-dependency YAML subset parser/writer for archgen contracts.
2
- // WHY hand-rolled: skill scripts must run on bare Node ≥18 with no npm install
3
- // (portability across harness sandboxes); full YAML libs are overkill for our
4
- // two documented shapes (tasks.yaml, architecture.yaml).
5
- //
6
- // Supported subset (anything else is REJECTED, never guessed):
7
- // - nested mappings (2-space indentation levels)
8
- // - sequences of scalars (block "- x" and flow "[a, b]")
9
- // - sequences of mappings (block "- key: value")
10
- // - scalars: plain, 'single-quoted', "double-quoted", numbers, booleans, null/~
11
- // - full-line AND end-of-line comments, preserved positionally
12
- // Unsupported BY DESIGN (throws with file:line): block scalars (| >), anchors,
13
- // tags, multi-document streams, flow mappings, tabs.
14
- //
15
- // Comment preservation contract (used by set-status.mjs so edits never destroy
16
- // reviewer notes): parse() returns { data, comments }; stringify(data, comments)
17
- // re-emits every comment verbatim, in original order, at its anchored position.
18
-
19
- /**
20
- * @typedef {{path: Array<string|number>, inline: boolean, text: string}} YamlComment
21
- * `path` anchors the comment to the mapping key (or sequence item index) it
22
- * sits above; `inline:true` means it trailed a value on the same line.
23
- * `path:null` = dangling comment emitted at end of document.
24
- */
25
-
26
- const INDENT_UNIT = 2;
27
-
28
- /** Parse YAML-subset text into data plus its comment ledger.
29
- * @param {string} text
30
- * @param {{filename?: string}} [opts]
31
- * @returns {{data: any, comments: YamlComment[]}}
32
- */
33
- export function parseYaml(text, opts = {}) {
34
- const filename = opts.filename ?? '<input>';
35
- // Normalize CRLF: split on '\n' leaves a stray '\r' at the end of every line
36
- // in Windows-authored files; stripping it here keeps scalar values and
37
- // comment texts free of carriage returns so stringify can never double them
38
- // up when re-emitting with the file's dominant EOL (see stringifyYaml eol).
39
- const lines = text.split('\n').map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
40
- // Strip a single trailing newline artifact so the last real line is processed.
41
- if (lines.length && lines[lines.length - 1] === '') lines.pop();
42
-
43
- /** @type {YamlComment[]} */
44
- const comments = [];
45
- /** Pending full-line comments waiting to anchor to the next key/item. */
46
- let pendingComments = [];
47
-
48
- const ctx = { filename, comments, pendingComments };
49
-
50
- // Pre-scan: tabs are illegal for indentation in our subset — fail fast with location.
51
- lines.forEach((raw, i) => {
52
- const m = raw.match(/^[\t ]*/)[0];
53
- if (m.includes('\t')) {
54
- throw new YamlError(`${filename}:${i + 1}: tab characters are not allowed in indentation`);
55
- }
56
- });
57
-
58
- // Drop blank/comment-only knowledge from the recursive descent input by
59
- // filtering here but recording positions first (keeps parser core simple).
60
- const pos = { i: 0 };
61
- const data = parseBlock(lines, pos, 0, ctx, /*root*/ true, []);
62
- // Guard: nothing structural may survive past the root block (e.g. a sequence
63
- // followed by a mapping at the same indent) — silent truncation would corrupt
64
- // contracts, so refuse loudly instead.
65
- for (let i = pos.i; i < lines.length; i++) {
66
- const c = classify(lines[i]);
67
- if (c.type === 'content') fail(ctx, i + 1, `unexpected content outside the document block: ${c.bare.slice(0, 30)}`);
68
- }
69
-
70
- // Any comments left pending anchor to end-of-document.
71
- for (const c of ctx.pendingComments) comments.push({ path: null, inline: false, text: c });
72
- return { data, comments };
73
- }
74
-
75
- class YamlError extends Error {}
76
-
77
- function fail(ctx, lineNo, msg) {
78
- throw new YamlError(`${ctx.filename}:${lineNo}: ${msg}`);
79
- }
80
-
81
- /** Classification of one physical line. */
82
- function classify(raw) {
83
- const trimmed = raw.trimStart();
84
- const indent = raw.length - trimmed.length;
85
- if (trimmed === '' ) return { type: 'blank', indent };
86
- if (trimmed.startsWith('#')) return { type: 'comment', indent, text: trimmed };
87
- // WHY `bare`: all structural checks (dash items, key regex) must see the
88
- // de-indented text; `indent` alone carries the column information.
89
- return { type: 'content', indent, text: trimmed, bare: trimmed };
90
- }
91
-
92
- /** Recursive block parser: consumes lines at >= indent belonging to this block. */
93
- function parseBlock(lines, pos, indent, ctx, root = false, basePath = []) {
94
- // Decide map vs sequence by first content line at exactly this indent.
95
- const peek = peekContent(lines, pos, indent);
96
- if (!peek) return null;
97
- if (peek.line.bare.startsWith('- ') || peek.line.bare === '-') return parseSequence(lines, pos, indent, ctx, basePath);
98
- return parseMapping(lines, pos, indent, ctx, basePath);
99
- }
100
-
101
- function peekContent(lines, pos, indent) {
102
- let i = pos.i;
103
- while (i < lines.length) {
104
- const c = classify(lines[i]);
105
- if (c.type === 'blank') { i++; continue; }
106
- if (c.type === 'comment') { i++; continue; } // handled by caller via pendingComments
107
- if (c.indent < indent) return null;
108
- if (c.indent > indent) fail({ filename: 'internal' }, i + 1, `unexpected deeper indentation (got ${c.indent}, expected ${indent})`);
109
- return { idx: i, line: c };
110
- }
111
- return null;
112
- }
113
-
114
- function parseMapping(lines, pos, indent, ctx, basePath = []) {
115
- /** @type {Record<string, any>} */
116
- const out = {};
117
- while (pos.i < lines.length) {
118
- const c = classify(lines[pos.i]);
119
- if (c.type === 'blank') { pos.i++; continue; }
120
- if (c.type === 'comment') { ctx.pendingComments.push(c.text); pos.i++; continue; }
121
- if (c.indent < indent) break;
122
- if (c.indent > indent) fail(ctx, pos.i + 1, 'inconsistent indentation inside mapping');
123
-
124
- const m = matchKey(c.text);
125
- if (!m) fail(ctx, pos.i + 1, `expected 'key:' mapping entry, got: ${c.bare.slice(0, 40)}`);
126
- assertKeyWellFormed(ctx, pos.i + 1, m);
127
- // Duplicate keys are a corruption class (real YAML parsers reject them):
128
- // silent last-wins hides data from every downstream check.
129
- if (Object.prototype.hasOwnProperty.call(out, m.key)) {
130
- fail(ctx, pos.i + 1, `duplicate key '${m.key}' in mapping`);
131
- }
132
- const keyPath = [...basePath, m.key];
133
- const key = consumePendingAndParseKey(ctx, c, pos.i + 1, keyPath);
134
- pos.i++;
135
-
136
- // Value may be inline, a nested block, or a sequence at SAME indent (common YAML style).
137
- const inlineVal = splitKeyValue(c.text, ctx, pos.i);
138
- if (inlineVal.hasValue) {
139
- out[key.name] = parseScalarOrFlow(inlineVal.value, ctx, pos.i);
140
- key.inlineComment(keyPath);
141
- continue;
142
- }
143
- // Look ahead: nested block (deeper indent) or sequence at same indent.
144
- const next = nextContent(lines, pos.i);
145
- if (!next || next.indent < indent) { out[key.name] = null; key.inlineComment(keyPath); continue; }
146
- if (next.indent > indent) {
147
- out[key.name] = parseChild(lines, pos, next.indent, ctx, keyPath);
148
- continue;
149
- }
150
- // Same indent: only legal child here is a sequence ("- ...").
151
- if (next.bare.startsWith('- ')) {
152
- out[key.name] = parseSequence(lines, pos, indent, ctx, keyPath);
153
- } else {
154
- out[key.name] = null;
155
- key.inlineComment(keyPath);
156
- }
157
- }
158
- return out;
159
- }
160
-
161
- function parseChild(lines, pos, childIndent, ctx, basePath = []) {
162
- // Skip blanks/comments so a leading comment block never masquerades as the
163
- // child's first structural line (classify() of a comment has no `bare`).
164
- let c = null;
165
- while (pos.i < lines.length) {
166
- const l = classify(lines[pos.i]);
167
- if (l.type === 'blank') { pos.i++; continue; }
168
- if (l.type === 'comment') {
169
- // Pre-item comments must survive: park them for the sequence to anchor.
170
- ctx.pendingComments.push(l.text); pos.i++; continue;
171
- }
172
- c = l; break;
173
- }
174
- if (!c) return null;
175
- if (c.bare.startsWith('- ') || c.bare === '-') return parseSequence(lines, pos, childIndent, ctx, basePath);
176
- return parseMapping(lines, pos, childIndent, ctx, basePath);
177
- }
178
-
179
- function parseSequence(lines, pos, indent, ctx, basePath = []) {
180
- /** @type {any[]} */
181
- const out = [];
182
- while (pos.i < lines.length) {
183
- const c = classify(lines[pos.i]);
184
- if (c.type === 'blank') { pos.i++; continue; }
185
- if (c.type === 'comment') { ctx.pendingComments.push(c.text); pos.i++; continue; }
186
- if (c.indent !== indent || !(c.bare.startsWith('- ') || c.bare === '-')) break;
187
-
188
- const itemText = c.bare.slice(2).trim();
189
- if (itemText === '-' || itemText.startsWith('- ')) {
190
- fail(ctx, pos.i + 1, 'nested sequence items are not supported by archgen\u0027s YAML subset');
191
- }
192
- // Positional anchor: comments above this item bind to its exact location.
193
- const itemPath = [...basePath, out.length];
194
- flushPendingInto(ctx.comments, ctx.pendingComments, itemPath, false);
195
-
196
- if (itemText === '') { // nested block under bare "-"
197
- pos.i++;
198
- const next = nextContent(lines, pos.i);
199
- if (!next || next.indent <= indent) { out.push(null); continue; }
200
- out.push(parseChild(lines, pos, next.indent, ctx, itemPath));
201
- continue;
202
- }
203
- const isFlowMap = itemText.startsWith('{');
204
- const isMapItem = !isFlowMap && (/^[^:#]+:(\s|$)/.test(itemText) || /^[A-Za-z_][\w.-]*:\s/.test(itemText));
205
- if (isFlowMap) {
206
- // Compact flow-map item: parse whole item as a flat mapping value.
207
- out.push(parseScalarOrFlow(itemText, ctx, pos.i + 1));
208
- pos.i++;
209
- continue;
210
- }
211
- if (isMapItem) {
212
- // Sequence of mappings: rewrite this line as a virtual mapping start.
213
- const virtualLine = ' '.repeat(indent + INDENT_UNIT) + itemText;
214
- const sub = [virtualLine];
215
- pos.i++;
216
- // Absorb following lines that belong to this item (indent > indent, not a new "- ").
217
- while (pos.i < lines.length) {
218
- const n = classify(lines[pos.i]);
219
- if (n.type === 'blank' || n.type === 'comment') { sub.push(lines[pos.i]); pos.i++; continue; }
220
- // Absorb ANY deeper line, dashes included: a '-' deeper than the item
221
- // base belongs to THIS item (nested list under one of its keys). Sibling
222
- // items sit exactly at `indent`, so the indent test alone is sufficient.
223
- if (n.indent > indent) { sub.push(lines[pos.i]); pos.i++; continue; }
224
- break;
225
- }
226
- const subCtx = { filename: ctx.filename, comments: ctx.comments, pendingComments: [] };
227
- const subPos = { i: 0 };
228
- // Sub-mapping inherits the item's positional path so inner keys anchor
229
- // as [.., itemIndex, keyName] — reviewer notes can never migrate between
230
- // sibling items that share key names (the set-status corruption class).
231
- const obj = parseMapping(sub, subPos, indent + INDENT_UNIT, subCtx, itemPath);
232
- for (const pc of subCtx.pendingComments) ctx.comments.push({ path: null, inline: false, text: pc });
233
- out.push(obj);
234
- continue;
235
- }
236
- // Plain scalar item (may include inline comment).
237
- const { value, inlineComment } = splitInlineComment(itemText);
238
- out.push(parseScalarOrFlow(value, ctx, pos.i + 1));
239
- if (inlineComment) ctx.comments.push({ path: itemPath, inline: true, text: inlineComment });
240
- pos.i++;
241
- }
242
- return out;
243
- }
244
-
245
- /** Split "key: value # comment" honoring quotes; returns parts. */
246
- function splitKeyValue(text, ctx, lineNo) {
247
- const m = matchKey(text);
248
- if (!m) fail(ctx, lineNo, `expected 'key:' mapping entry, got: ${text.slice(0, 40)}`);
249
- const rest = text.slice(m.end).trim();
250
- const { value, inlineComment } = splitInlineComment(rest);
251
- return { hasValue: value.length > 0, value, inlineComment, key: m.key };
252
- }
253
-
254
- const KEY_RE = /^("([^"\\]|\\.)*"|'([^'\\]|\\.)*'|[^:#]+?):(?:\s|$)/;
255
- function matchKey(text) {
256
- const m = KEY_RE.exec(text);
257
- if (!m) return null;
258
- return { key: unquote(m[1]), raw: m[1], end: m[0].length };
259
- }
260
-
261
- /** Reject malformed keys early: unterminated quotes and brace/bracket starts are
262
- * typos that would otherwise silently become literal key names. */
263
- function assertKeyWellFormed(ctx, lineNo, m) {
264
- const r = m.raw;
265
- if (r.startsWith('"') && !(r.length >= 2 && r.endsWith('"'))) fail(ctx, lineNo, `unterminated double-quoted key: ${r}`);
266
- if (r.startsWith("'") && !(r.length >= 2 && r.endsWith("'"))) fail(ctx, lineNo, `unterminated single-quoted key: ${r}`);
267
- if ('{['.includes(r[0])) fail(ctx, lineNo, `flow collections cannot be keys in archgen's YAML subset: ${r}`);
268
- }
269
-
270
- function splitInlineComment(s) {
271
- // Find '#' outside quotes.
272
- let inS = false, inD = false;
273
- for (let i = 0; i < s.length; i++) {
274
- const ch = s[i];
275
- if (ch === "'" && !inD) inS = !inS;
276
- else if (ch === '"' && !inS) inD = !inD;
277
- else if (ch === '#' && !inS && !inD && (i === 0 || s[i - 1] === ' ')) {
278
- return { value: s.slice(0, i).trim(), inlineComment: s.slice(i).trim() };
279
- }
280
- }
281
- return { value: s.trim(), inlineComment: null };
282
- }
283
-
284
- function parseScalarOrFlow(v, ctx, lineNo) {
285
- if (v === '') return null;
286
- if (v.startsWith('[')) {
287
- if (!v.endsWith(']')) fail(ctx, lineNo, `unterminated flow sequence: ${v}`);
288
- const inner = v.slice(1, -1).trim();
289
- if (inner === '') return [];
290
- return splitFlow(inner).map((p) => parseScalarOrFlow(p, ctx, lineNo));
291
- }
292
- if ((v.startsWith('"') && !(v.length >= 2 && v.endsWith('"'))) ||
293
- (v.startsWith("'") && !(v.length >= 2 && v.endsWith("'")))) {
294
- fail(ctx, lineNo, `unterminated quoted string: ${v.slice(0, 30)}`);
295
- }
296
- if (v.startsWith('{')) {
297
- // Flat flow mappings are supported ({k: v, ...}) because compact task
298
- // entries read far better; NESTED flow values inside them are not.
299
- if (!v.endsWith('}')) fail(ctx, lineNo, `unterminated flow mapping: ${v}`);
300
- const inner = v.slice(1, -1).trim();
301
- if (inner === '') return {};
302
- const obj = {};
303
- for (const part of splitFlow(inner)) {
304
- const m = matchKey(part);
305
- if (!m) fail(ctx, lineNo, `flow mapping entry must be 'key: value', got: ${part}`);
306
- assertKeyWellFormed(ctx, lineNo, m);
307
- if (Object.prototype.hasOwnProperty.call(obj, m.key)) {
308
- fail(ctx, lineNo, `duplicate key '${m.key}' in flow mapping`);
309
- }
310
- const val = part.slice(m.end).trim();
311
- if (val.startsWith('{')) fail(ctx, lineNo, `nested flow mappings are not supported: ${val}`);
312
- obj[m.key] = parseScalarOrFlow(val, ctx, lineNo);
313
- }
314
- return obj;
315
- }
316
- if (!/^["']/.test(v) && /:\s/.test(v)) {
317
- fail(ctx, lineNo, `plain values cannot contain ': ' (quote the value if intended): ${v.slice(0, 30)}`);
318
- }
319
- if (v.startsWith('|') || v.startsWith('>')) fail(ctx, lineNo, `block scalars are not supported by archgen's YAML subset (use quoted or single-line values): ${v[0]}`);
320
- if (v.startsWith('&') || v.startsWith('*') || v.startsWith('!')) fail(ctx, lineNo, `anchors/tags are not supported by archgen's YAML subset: ${v[0]}`);
321
- return parseScalar(v);
322
- }
323
-
324
- function splitFlow(inner) {
325
- const parts = []; let cur = ''; let inS = false, inD = false, depth = 0;
326
- for (const ch of inner) {
327
- if (ch === "'" && !inD) inS = !inS;
328
- else if (ch === '"' && !inS) inD = !inD;
329
- else if (ch === '[' && !inS && !inD) depth++;
330
- else if (ch === ']' && !inS && !inD) depth--;
331
- if (ch === ',' && !inS && !inD && depth === 0) { parts.push(cur.trim()); cur = ''; continue; }
332
- cur += ch;
333
- }
334
- if (cur.trim()) parts.push(cur.trim());
335
- return parts;
336
- }
337
-
338
- function parseScalar(v) {
339
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) return unquote(v);
340
- if (v === 'null' || v === '~' || v === '') return null;
341
- if (v === 'true') return true;
342
- if (v === 'false') return false;
343
- if (/^-?\d+$/.test(v)) return parseInt(v, 10);
344
- if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v);
345
- return v; // plain string
346
- }
347
-
348
- function unquote(v) {
349
- if (v.startsWith('"')) return v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
350
- if (v.startsWith("'")) return v.slice(1, -1).replace(/''/g, "'");
351
- return v;
352
- }
353
-
354
- function quoteIfNeeded(v) {
355
- if (v === '') return '""';
356
- if (/^[#&*!|>%@`{}[\],]|^\s|\s$|: |\s#|^[-?:]\s|^".*"$/ .test(v) || /[:#]/.test(v) && !/^["']/.test(v)) {
357
- return JSON.stringify(v);
358
- }
359
- if (/^(true|false|null|~|-?\d+(\.\d+)?)$/.test(v)) return JSON.stringify(v);
360
- return v;
361
- }
362
-
363
- function nextContent(lines, from) {
364
- for (let i = from; i < lines.length; i++) {
365
- const c = classify(lines[i]);
366
- if (c.type === 'blank') continue;
367
- if (c.type === 'comment') continue;
368
- return { indent: c.indent, text: c.bare, bare: c.bare, idx: i };
369
- }
370
- return null;
371
- }
372
-
373
- function consumePendingAndParseKey(ctx, line, lineNo, keyPath) {
374
- const m = matchKey(line.text);
375
- if (!m) fail(ctx, lineNo, `expected 'key:' mapping entry, got: ${line.text.slice(0, 40)}`);
376
- assertKeyWellFormed(ctx, lineNo, m);
377
- // Full-line comments sitting directly above this key anchor to its FULL
378
- // positional path NOW; waiting longer would misattach them to a deeper child.
379
- for (const t of ctx.pendingComments.splice(0)) {
380
- ctx.comments.push({ path: keyPath, inline: false, text: t });
381
- }
382
- return {
383
- name: m.key,
384
- inlineComment(kp) {
385
- const { inlineComment } = splitInlineComment(line.text.slice(line.text.indexOf(':') + 1).trim());
386
- if (inlineComment) ctx.comments.push({ path: kp, inline: true, text: inlineComment });
387
- },
388
- };
389
- }
390
-
391
- function flushPendingInto(comments, pending, path, _inline) {
392
- for (const t of pending.splice(0)) comments.push({ path, inline: false, text: t });
393
- }
394
-
395
- /**
396
- * Serialize data back to the YAML subset, re-emitting comments positionally.
397
- * @param {any} data
398
- * @param {YamlComment[]} [comments]
399
- * @param {{eol?: string}} [opts] `eol` joins lines with the given line ending
400
- * (default '\n'); callers that detected a CRLF source pass '\r\n' so the
401
- * rewrite preserves the file's dominant convention byte-for-byte.
402
- */
403
- export function stringifyYaml(data, comments = [], opts = {}) {
404
- const eol = opts.eol ?? '\n';
405
- const out = [];
406
- const used = new Set();
407
- emitValue(out, data, 0, comments, used, []);
408
- // Dangling comments (path null) land at EOF in original order.
409
- for (let i = 0; i < comments.length; i++) {
410
- if (!used.has(i) && comments[i].path === null) out.push(comments[i].text);
411
- }
412
- return out.join(eol) + eol;
413
- }
414
-
415
- function preComments(out, comments, used, path) {
416
- comments.forEach((c, i) => {
417
- if (used.has(i) || c.inline || c.path === null) return;
418
- if (samePath(c.path, path)) { out.push(c.text); used.add(i); }
419
- });
420
- }
421
-
422
- function inlineCommentFor(out, comments, used, path) {
423
- comments.forEach((c, i) => {
424
- if (used.has(i) || !c.inline) return;
425
- if (samePath(c.path, path)) { out[out.length - 1] += ' ' + c.text; used.add(i); }
426
- });
427
- }
428
-
429
- function samePath(a, b) {
430
- if (!a || !b || a.length !== b.length) return false;
431
- for (let i = 0; i < a.length; i++) if (String(a[i]) !== String(b[i])) return false;
432
- return true;
433
- }
434
-
435
- function emitValue(out, v, indent, comments, used, path) {
436
- const pad = ' '.repeat(indent);
437
- if (Array.isArray(v)) {
438
- if (v.length === 0) { out.push(pad + '[]'); return; }
439
- v.forEach((item, idx) => {
440
- const itemPath = [...path, idx];
441
- // Pre-item comments keep their original indentation relative to the dash.
442
- comments.forEach((c, i) => {
443
- if (!used.has(i) && !c.inline && c.path && samePath(c.path, itemPath)) {
444
- out.push(pad + c.text); used.add(i);
445
- }
446
- });
447
- if (item !== null && typeof item === 'object') {
448
- const entries = Object.entries(item);
449
- if (entries.length === 0) { out.push(pad + '- {}'); return; }
450
- const [k0, v0] = entries[0];
451
- const k0path = [...itemPath, k0];
452
- preComments(out, comments, used, k0path);
453
- out.push(pad + '- ' + k0 + ': ' + encodeScalar(v0));
454
- inlineCommentFor(out, comments, used, k0path);
455
- emitEntries(out, entries.slice(1), indent + INDENT_UNIT, comments, used, itemPath);
456
- } else {
457
- out.push(pad + '- ' + encodeScalar(item));
458
- inlineCommentFor(out, comments, used, itemPath);
459
- }
460
- });
461
- return;
462
- }
463
- if (v !== null && typeof v === 'object') {
464
- emitEntries(out, Object.entries(v), indent, comments, used, path);
465
- return;
466
- }
467
- out.push(pad + encodeScalar(v));
468
- }
469
-
470
- function emitEntries(out, entries, indent, comments, used, basePath) {
471
- const pad = ' '.repeat(indent);
472
- for (const [k, val] of entries) {
473
- const kpath = [...basePath, k];
474
- preComments(out, comments, used, kpath);
475
- if (val !== null && typeof val === 'object') {
476
- if (Object.keys(val).length === 0 && !Array.isArray(val)) { out.push(pad + k + ': {}'); inlineCommentFor(out, comments, used, kpath); continue; }
477
- if (Array.isArray(val) && val.length === 0) { out.push(pad + k + ': []'); inlineCommentFor(out, comments, used, kpath); continue; }
478
- out.push(pad + k + ':');
479
- inlineCommentFor(out, comments, used, kpath);
480
- emitValue(out, val, indent + INDENT_UNIT, comments, used, kpath);
481
- } else {
482
- out.push(pad + k + ': ' + encodeScalar(val));
483
- inlineCommentFor(out, comments, used, kpath);
484
- }
485
- }
486
- }
487
-
488
- function encodeScalar(v) {
489
- if (v === null) return 'null';
490
- if (typeof v === 'boolean' || typeof v === 'number') return String(v);
491
- return quoteIfNeeded(String(v));
492
- }
@@ -1,55 +0,0 @@
1
- #!/usr/bin/env node
2
- // next-tasks.mjs — topological wave resolver for tasks.yaml.
3
- // Usage: node next-tasks.mjs <tasks.yaml>
4
- // stdout: JSON {waves:[[{id,title,status,file_ownership}]], blockedByFailure:[ids]}
5
- // Exit codes: 0 ok · 2 cycle · 3 ownership conflict in a wave · 4 invalid file
6
- import { readFileSync } from 'node:fs';
7
- import { parseYaml } from './lib/yaml.mjs';
8
- import { buildGraph, findCycle, computeWaves, findOwnershipConflict, GraphError } from './lib/graph.mjs';
9
-
10
- const [, , file] = process.argv;
11
- if (!file) { console.error('usage: next-tasks.mjs <tasks.yaml>'); process.exit(4); }
12
-
13
- let data;
14
- try {
15
- ({ data } = parseYaml(readFileSync(file, 'utf8'), { filename: file }));
16
- } catch (e) {
17
- console.error(String(e.message ?? e));
18
- process.exit(4);
19
- }
20
- const tasks = data?.tasks;
21
- if (!Array.isArray(tasks)) { console.error(`${file}: missing 'tasks:' sequence`); process.exit(4); }
22
-
23
- try {
24
- const { byId, prerequisites } = buildGraph(tasks);
25
-
26
- const cycle = findCycle(prerequisites);
27
- if (cycle.length) {
28
- console.error(`dependency cycle detected: ${cycle.join(' -> ')}`);
29
- process.exit(2);
30
- }
31
-
32
- const { waves, blockedByFailure } = computeWaves(byId, prerequisites);
33
-
34
- for (const wave of waves) {
35
- const conflict = findOwnershipConflict(wave);
36
- if (conflict) {
37
- console.error(`file_ownership conflict in same wave: glob '${conflict.glob}' claimed by '${conflict.a}' and '${conflict.b}'`);
38
- process.exit(3);
39
- }
40
- }
41
-
42
- const out = {
43
- waves: waves.map((w) => w.map((t) => ({
44
- id: t.id,
45
- title: t.title,
46
- status: t.status,
47
- file_ownership: t.file_ownership,
48
- }))),
49
- blockedByFailure,
50
- };
51
- console.log(JSON.stringify(out, null, 2));
52
- } catch (e) {
53
- if (e instanceof GraphError) { console.error(e.message); process.exit(4); }
54
- throw e;
55
- }