dreamteamer 0.17.0 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamteamer",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "A workspace compiler for coding agents — schema-validated records as plain files over git, compiled into every harness",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Gilad Khen <giladkhen@gmail.com>",
@@ -48,7 +48,8 @@
48
48
  "ajv-formats": "^3.0.1",
49
49
  "express": "^5.2.1",
50
50
  "fractional-indexing": "^4.0.0",
51
- "js-yaml": "^4.1.0"
51
+ "js-yaml": "^4.1.0",
52
+ "yaml": "2.8.1"
52
53
  },
53
54
  "dreamteamer": {
54
55
  "title": "System"
package/src/schema-ops.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { execFileSync } from 'node:child_process';
10
- import { load, dump } from './yaml.js';
10
+ import { load, dump, writeSource, commentCount } from './yaml.js';
11
11
  import { compile, kindDir, titleCase } from './compile.js';
12
12
  import { readManifest, runtimeKindDir } from './runtime.js';
13
13
  import { normalizeNamespaces, namespaceOf, baseNameOf, qualify, defaultStoragePath, singular } from './namespace.js';
@@ -21,7 +21,30 @@ import { Store, bodyField, serialize, atomicWrite } from './store.js';
21
21
 
22
22
  // ---- the gate -------------------------------------------------------------------
23
23
 
24
- function writeGated(ws, store, files, subject, mutate, after) {
24
+ /**
25
+ * A SOURCE WRITE MAY NOT SILENTLY LOSE A COMMENT — the invariant that would have caught the
26
+ * re-serialization bug on the first rename instead of the twenty-seventh.
27
+ *
28
+ * A module source is where this project writes down WHY a collection exists, and every gate it had
29
+ * was blind to losing that: the schema is unchanged, so `compile` and `check` both stay green while
30
+ * the reasoning is deleted. Counting comment lines is crude on purpose — it is structural, it costs
31
+ * one pass over bytes already in hand, and it fails the op rather than reporting it afterwards.
32
+ *
33
+ * ⚠ THE OPT-OUT IS REAL AND NARROW. `remove-field` takes the comment ABOVE the field with the field,
34
+ * which is the correct outcome and a decrease; so does deleting a file. Those ops say so explicitly
35
+ * (`commentsMayDecrease`) rather than being exempted by a heuristic that would also excuse a bug.
36
+ */
37
+ function assertCommentsKept(ws, snapshots) {
38
+ for (const { f, prev } of snapshots) {
39
+ if (prev === null || !fs.existsSync(f)) continue;
40
+ const before = commentCount(prev.toString('utf8'));
41
+ const after = commentCount(fs.readFileSync(f, 'utf8'));
42
+ if (after >= before) continue;
43
+ throw new Error(`${path.relative(ws.root, f)} would lose ${before - after} comment line(s) — a source write may not delete a module's own reasoning. Nothing was changed.`);
44
+ }
45
+ }
46
+
47
+ function writeGated(ws, store, files, subject, mutate, after, { commentsMayDecrease = false } = {}) {
25
48
  // same guarantees as record writes (docs-audit catch): the STORE's cross-process lock
26
49
  // serializes schema ops too, and a failed git commit rolls the source back — a schema
27
50
  // op fails closed exactly like a record mutation.
@@ -34,6 +57,14 @@ function writeGated(ws, store, files, subject, mutate, after) {
34
57
  }
35
58
  };
36
59
  mutate();
60
+ if (!commentsMayDecrease) {
61
+ try {
62
+ assertCommentsKept(ws, snapshots);
63
+ } catch (e) {
64
+ restore();
65
+ throw e;
66
+ }
67
+ }
37
68
  try {
38
69
  compile(ws); // dry-run that doubles as the materialization — throws CompileError on bad sources
39
70
  } catch (e) {
@@ -126,50 +157,6 @@ function descriptorSourceDir(ws, name) {
126
157
  return { dir: kindDir(moduleRoot, 'collections'), sources };
127
158
  }
128
159
 
129
- /**
130
- * Set one scalar in a YAML document TEXTUALLY, so comments and key order survive.
131
- *
132
- * This exists because `load` → mutate → `dump` is lossy in the one way that matters here: it drops
133
- * every comment. That is fine for a generated artifact and wrong for a module SOURCE, which is where
134
- * this project writes down why a collection exists. Only `renameCollection` uses it, and only for the
135
- * three scalars a rename changes; anything more ambitious belongs in a real round-trip YAML library,
136
- * not in a regex.
137
- *
138
- * Handles both spellings the descriptors actually use — a top-level key, a nested block mapping, and
139
- * the inline `storage: { path: x, suffix: y }` flow form. Callers MUST re-parse and assert, because a
140
- * shape not covered here fails by changing nothing rather than by throwing.
141
- */
142
- function setScalar(text, keyPath, value) {
143
- const [head, child] = keyPath;
144
- if (!child) return text.replace(new RegExp(`^${head}:.*$`, 'm'), `${head}: ${value}`);
145
-
146
- // inline flow mapping: `storage: { path: data/x, suffix: y }`
147
- const flow = new RegExp(`^${head}:\\s*\\{([^}]*)\\}\\s*$`, 'm').exec(text);
148
- if (flow) {
149
- let body = flow[1];
150
- body = new RegExp(`\\b${child}:\\s*[^,}]+`).test(body)
151
- ? body.replace(new RegExp(`(\\b${child}:\\s*)[^,}]+`), `$1${value}`)
152
- : `${body.trimEnd()}, ${child}: ${value}`;
153
- return text.slice(0, flow.index) + `${head}: {${body}}` + text.slice(flow.index + flow[0].length);
154
- }
155
-
156
- // block mapping: `storage:\n path: data/x`
157
- const block = new RegExp(`^${head}:\\n(?:[ \\t]+.*\\n)*?[ \\t]+${child}:.*$`, 'm').exec(text);
158
- if (block) {
159
- return text.slice(0, block.index)
160
- + block[0].replace(new RegExp(`([ \\t]+${child}:).*$`, 'm'), `$1 ${value}`)
161
- + text.slice(block.index + block[0].length);
162
- }
163
-
164
- // the key is absent under an existing block — insert it directly after the parent
165
- const parent = new RegExp(`^${head}:\\s*$`, 'm').exec(text);
166
- if (parent) {
167
- const at = parent.index + parent[0].length + 1;
168
- return text.slice(0, at) + ` ${child}: ${value}\n` + text.slice(at);
169
- }
170
- return text;
171
- }
172
-
173
160
  // ---- ops ------------------------------------------------------------------------
174
161
 
175
162
  export function createCollection(ws, store, { name, template, namespace }) {
@@ -354,7 +341,7 @@ export function renameCollection(ws, store, oldName, newName) {
354
341
  const touched = new Set();
355
342
  let rewrites = 0;
356
343
  try {
357
- // 1. the descriptor source, at its new path — EDITED TEXTUALLY, never re-dumped.
344
+ // 1. the descriptor source, at its new path — ROUND-TRIPPED, never re-dumped.
358
345
  //
359
346
  // ⚠ `fs.writeFileSync(dest, dump(doc))` destroyed every comment in the descriptor, and a
360
347
  // descriptor's comments are where this project keeps its reasoning: 194 lines across 24
@@ -362,19 +349,21 @@ export function renameCollection(ws, store, oldName, newName) {
362
349
  // collection and which failure mode it guards against. The record survived; the thinking
363
350
  // did not, and nothing said so.
364
351
  //
365
- // A rename changes exactly three scalars. Rewriting those three in place keeps the
366
- // comments, the key order and the author's formatting and the parse afterwards proves
367
- // the edit landed rather than trusting the regex.
368
- const edited = setScalar(setScalar(setScalar(srcBytes.toString('utf8'),
369
- ['name'], newName),
370
- ['storage', 'path'], newPath),
371
- ['storage', 'suffix'], newSuffix);
352
+ // A rename changes exactly three scalars, and `writeSource` rewrites exactly those three
353
+ // every other byte is restored from the source it was parsed out of. The parse afterwards
354
+ // still proves the edit landed rather than trusting the writer, and the comment count is
355
+ // asserted here because a rename does not go through `writeGated`'s invariant.
356
+ const beforeText = srcBytes.toString('utf8');
357
+ doc.name = newName;
358
+ doc.storage = { ...doc.storage, path: newPath, suffix: newSuffix };
359
+ const edited = writeSource(beforeText, doc);
372
360
  const parsed = load(edited);
373
361
  if (parsed?.name !== newName || parsed?.storage?.path !== newPath || parsed?.storage?.suffix !== newSuffix) {
374
362
  throw new Error(`could not rewrite ${path.relative(ws.root, src)} in place — name/storage.path/storage.suffix did not take. nothing was changed.`);
375
363
  }
376
- doc.name = newName;
377
- doc.storage = { ...doc.storage, path: newPath, suffix: newSuffix };
364
+ if (commentCount(edited) < commentCount(beforeText)) {
365
+ throw new Error(`renaming "${oldName}" would lose ${commentCount(beforeText) - commentCount(edited)} comment line(s) from ${path.relative(ws.root, src)} nothing was changed.`);
366
+ }
378
367
  fs.mkdirSync(path.dirname(dest), { recursive: true });
379
368
  fs.writeFileSync(dest, edited);
380
369
  if (dest !== src) fs.rmSync(src);
@@ -431,28 +420,25 @@ export function renameCollection(ws, store, oldName, newName) {
431
420
  // 4. bare `x-reference: <oldName>` in every descriptor SOURCE. Not a `<collection>/<id>`
432
421
  // ref, so step 2 cannot see it — and leaving it makes compile fail on an unknown target.
433
422
  //
434
- // ⚠ TEXTUAL, for the same reason step 1 is. This used to `load` → mutate → `dump`, which
423
+ // ⚠ ROUND-TRIPPED, for the same reason step 1 is. This used to `load` → mutate → `dump`, which
435
424
  // meant that ANY descriptor needing a retarget lost every comment in it — including the
436
425
  // renamed one itself when it self-references, which is how step 1's careful preservation
437
426
  // was undone one step later. 17 of the 24 descriptors stripped in the migration that
438
427
  // found this were stripped HERE, not there.
439
428
  //
440
- // `retargetRefs` still decides WHETHER a file is affected it walks the parsed schema and
441
- // knows about nested properties and `items` — but the write is a line edit, and the parse
442
- // afterwards proves it landed.
429
+ // `retargetRefs` decides whether a file is affected AND performs the edit on the parsed value
430
+ // it walks nested properties and `items` — and `writeSource` puts that value back over the
431
+ // original bytes. The line editor this replaced had to know THREE spellings by hand (block
432
+ // scalar, inline flow, and a flow or block LIST) and fell through unchanged on three more it
433
+ // documented as out of scope; a value-level edit knows all of them because it never sees
434
+ // syntax. The parse afterwards still proves it landed.
443
435
  for (const f of descriptorSources(ws, store)) {
444
436
  const before = fs.readFileSync(f, 'utf8');
445
437
  const probe = load(before);
446
438
  if (!probe || !retargetRefs(probe.schema, oldName, newName)) continue;
447
- // the boundary must cover THREE spellings: the block form (`x-reference: accounts` to
448
- // end of line), the inline flow form (`{ type: string, x-reference: accounts }`, where
449
- // the value ends at `,` or `}`), and a LIST — flow (`x-reference: [a, accounts, b]`) or
450
- // block (`x-reference:` + `- accounts` items). Anchoring on `$` alone silently matched
451
- // nothing in the flow form — and the assert below turned that silence into a refusal,
452
- // which is how it was found.
453
- const after = retargetRefText(before, oldName, newName);
439
+ const after = writeSource(before, probe);
454
440
  const reparsed = load(after);
455
- if (!reparsed || retargetRefs(reparsed.schema, oldName, newName)) {
441
+ if (!reparsed || retargetRefs(reparsed.schema, oldName, newName) || commentCount(after) < commentCount(before)) {
456
442
  throw new Error(`could not retarget x-reference "${oldName}" in ${path.relative(ws.root, f)} without reformatting it — nothing was changed.`);
457
443
  }
458
444
  if (!refFiles.has(f)) refFiles.set(f, Buffer.from(before));
@@ -547,73 +533,6 @@ function retargetRefs(schema, oldName, newName) {
547
533
  return changed;
548
534
  }
549
535
 
550
- /**
551
- * The TEXTUAL x-reference retarget — a line edit, never load→dump, so comments survive (see the
552
- * step-4 comment in renameCollection for the 17-descriptor lesson).
553
- *
554
- * Handles three spellings: scalar (`x-reference: old`), flow list (`x-reference: [a, old, b]`),
555
- * and block sequence (`x-reference:` + `- old` items tracked by indent under the key):
556
- *
557
- * ```
558
- * x-reference:
559
- * - doctors
560
- * - nurses
561
- * ```
562
- *
563
- * Two YAML styles are deliberately NOT rewritten and fall through unchanged to the caller's
564
- * reparse-assert (which throws if the return does not compile), failing closed rather than
565
- * half-written:
566
- *
567
- * - **Same-indent block sequence**: YAML allows `- items` at the PARENT's own indent
568
- * (`x-reference:` and `- doctors` at the same indent level). The indent state machine
569
- * requires strictly deeper dashes (`item[1].length > listIndent`), so this spelling is
570
- * never entered and goes reparse-asserted instead.
571
- * - **Multi-line flow list**: a flow list split across lines — `[` on one line, `]` on
572
- * another. The flow regex requires both brackets and the body on the same line, so this
573
- * spelling is not matched and goes reparse-asserted instead.
574
- * - **A blank or comment line BETWEEN block-sequence items**: the item regex requires a
575
- * leading `-`, so a blank line or a `#`-comment line inside the list resets `listIndent`
576
- * early. Every item after the gap is then read as ordinary text rather than a list member,
577
- * and (having no `x-reference:` key on its own line) is left untouched.
578
- *
579
- * All three fail closed on purpose: the engine's own `dump()` always emits deeper-indented,
580
- * gap-free sequences and single-line flow lists, so only a hand-authored descriptor can reach
581
- * these styles. Anything trickier than the three handled spellings falls through unchanged —
582
- * the caller reparses and REFUSES rather than guessing.
583
- */
584
- function retargetRefText(text, oldName, newName) {
585
- const esc = oldName.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
586
- const quoted = newName.includes('/') ? `'${newName}'` : newName;
587
- const retag = (part) => {
588
- const m = part.match(/^(\s*)(['"]?)(.*?)\2(\s*)$/);
589
- return m && m[3] === oldName ? `${m[1]}${quoted}${m[4]}` : part;
590
- };
591
- const lines = text.split('\n');
592
- let listIndent = -1; // >= 0 while inside a block-sequence x-reference list
593
- for (let i = 0; i < lines.length; i++) {
594
- const line = lines[i];
595
- if (listIndent >= 0) {
596
- const item = line.match(/^(\s*)-\s*(['"]?)(.*?)\2\s*(#.*)?$/);
597
- if (item && item[1].length > listIndent) {
598
- if (item[3] === oldName) {
599
- lines[i] = line.replace(new RegExp(`(-\\s*)(['"]?)${esc}\\2`), (_m, lead) => `${lead}${quoted}`);
600
- }
601
- continue;
602
- }
603
- listIndent = -1;
604
- }
605
- const key = line.match(/^(\s*)x-reference:\s*(.*)$/);
606
- if (key && (key[2] === '' || key[2].startsWith('#'))) {
607
- listIndent = key[1].length;
608
- continue;
609
- }
610
- lines[i] = line
611
- .replace(new RegExp(`(x-reference:\\s*)(['"]?)${esc}\\2(?=\\s*(?:[,}]|#|$))`, 'gm'), (_m, lead) => `${lead}${quoted}`)
612
- .replace(/(x-reference:\s*\[)([^\]]*)(\])/g, (_m, open, body, close) => open + body.split(',').map(retag).join(',') + close);
613
- }
614
- return lines.join('\n');
615
- }
616
-
617
536
  /** Remove now-empty parents up to (not including) the data root — a moved collection leaves its
618
537
  * namespace folder behind otherwise. */
619
538
  function pruneEmpty(dir, stopAt) {
@@ -1011,7 +930,7 @@ export function removeField(ws, store, collection, fieldName) {
1011
930
  if (!doc.list_fields.length) delete doc.list_fields;
1012
931
  }
1013
932
  if (doc.sort_field === fieldName) delete doc.sort_field;
1014
- fs.writeFileSync(dest, writeDescriptor(previousText, doc));
933
+ fs.writeFileSync(dest, writeSource(previousText, doc));
1015
934
  }, () => {
1016
935
  // ONE Store for both sweeps — the runtime as the gate compile just left it.
1017
936
  const after = new Store(ws);
@@ -1025,7 +944,11 @@ export function removeField(ws, store, collection, fieldName) {
1025
944
  dropped: mirrors.dropped,
1026
945
  cleared: own.records,
1027
946
  };
1028
- });
947
+ // ⚠ THE ONE OP THAT MAY LOSE A COMMENT, and the reason the invariant takes an opt-out rather
948
+ // than a heuristic: the comment above a field explains THAT field, so removing the field takes
949
+ // it, and that is the outcome the operator asked for. Every other source write is still held to
950
+ // the count.
951
+ }, { commentsMayDecrease: true });
1029
952
  return { collection, removed: fieldName, dropped: out.dropped, cleared: out.cleared, staleViews };
1030
953
  }
1031
954
 
@@ -1092,7 +1015,7 @@ function upsertField(ws, store, collection, fieldName, prop, required, verb) {
1092
1015
  const dest = path.join(workspaceSystemDir(ws, 'collections'), `${collection}.collection.yaml`);
1093
1016
  let doc;
1094
1017
  // The BYTES, not just the parse: `dump` cannot round-trip a comment, and a collection descriptor is
1095
- // where a module writes down why the collection exists (see reattachComments).
1018
+ // where a module writes down why the collection exists (see writeSource).
1096
1019
  let previousText = null;
1097
1020
  if (fs.existsSync(dest)) {
1098
1021
  previousText = fs.readFileSync(dest, 'utf8');
@@ -1127,7 +1050,7 @@ function upsertField(ws, store, collection, fieldName, prop, required, verb) {
1127
1050
  if (required === true) doc.schema.required = [...new Set([...(doc.schema.required ?? []), fieldName])];
1128
1051
  if (required === false && Array.isArray(doc.schema.required)) doc.schema.required = doc.schema.required.filter((r) => r !== fieldName);
1129
1052
  fs.mkdirSync(path.dirname(dest), { recursive: true });
1130
- fs.writeFileSync(dest, writeDescriptor(previousText, doc));
1053
+ fs.writeFileSync(dest, writeSource(previousText, doc));
1131
1054
  }, () => dropOrphanedMirrors(new Store(ws), was)).dropped;
1132
1055
  // the prop as WRITTEN — callers report the relation off this, never off the one they passed:
1133
1056
  // both this function and updateField reassign it, so a caller's own copy can be a stale object.
@@ -1157,78 +1080,6 @@ function uiViewSourceFile(ws, id) {
1157
1080
  return { file: path.join(ws.root, shipped), shipped };
1158
1081
  }
1159
1082
 
1160
- /**
1161
- * Re-attach a rewritten YAML source's COMMENTS — the part `dump` cannot round-trip.
1162
- *
1163
- * js-yaml drops every comment on `load` → `dump`. That is fine for a generated artifact and wrong
1164
- * for a module SOURCE, which is where this project writes down why something exists (`setScalar`
1165
- * above exists for the same reason). A real round-trip needs a different YAML library and core is
1166
- * not taking one on for this, so this does the narrow thing that is actually safe: a comment block
1167
- * sitting directly above a TOP-LEVEL key is carried back above that same key, if the key survived.
1168
- * The file header comes along for free — it is the block above the first key.
1169
- *
1170
- * ⚠ Deliberately top-level only. A comment above a NESTED key cannot be re-placed without knowing
1171
- * where that key ended up, and a misplaced comment is worse than an absent one: it would attach an
1172
- * explanation to something it does not explain. Those are still lost. Measured against
1173
- * `modules/family/ui-views/health-labs-abnormal.ui-view.yaml`, whose two blocks — the file header
1174
- * and the ⚠ above `filter:` — are both top-level and both survive.
1175
- */
1176
- function reattachComments(oldText, newText) {
1177
- const TOP_KEY = /^([A-Za-z_][\w-]*):/;
1178
- const blocks = new Map(); // surviving key -> the comment lines that sat above it
1179
- let pending = [];
1180
- for (const line of oldText.split('\n')) {
1181
- if (line.startsWith('#') || line.trim() === '') { pending.push(line); continue; }
1182
- const key = TOP_KEY.exec(line)?.[1];
1183
- if (key && pending.some((l) => l.startsWith('#'))) {
1184
- while (pending.length && pending[pending.length - 1].trim() === '') pending.pop();
1185
- blocks.set(key, pending);
1186
- }
1187
- pending = [];
1188
- }
1189
- if (!blocks.size) return newText;
1190
-
1191
- const out = [];
1192
- for (const line of newText.split('\n')) {
1193
- const block = blocks.get(TOP_KEY.exec(line)?.[1]);
1194
- if (block) out.push(...block);
1195
- out.push(line);
1196
- }
1197
- return out.join('\n');
1198
- }
1199
-
1200
- /**
1201
- * Serialize a collection descriptor back to its source, keeping what `dump` cannot.
1202
- *
1203
- * ⚠ THIS IS A PARTIAL FIX, AND THE LIMITS ARE THE REASON IT IS WORTH HAVING ANYWAY. A descriptor is
1204
- * hand-written, and it is where a module records WHY a collection exists — so `add-field` rewriting
1205
- * it through `load` → mutate → `dump` deleted every comment in the file. Measured on a four-comment
1206
- * descriptor: one `dt schema add-field` took it to zero. The consequence was not cosmetic: the
1207
- * schema verbs were unusable on any commented descriptor, so real relations got authored by hand in
1208
- * a text editor instead, which is the CLI losing to an editor for a job it owns.
1209
- *
1210
- * `reattachComments` recovers a comment block sitting above a TOP-LEVEL key, the file header
1211
- * included — 25 of the 27 comment lines across this engine's own descriptors. What is still lost,
1212
- * stated here rather than discovered later:
1213
- *
1214
- * - A comment above a NESTED key (inside `schema.properties.<field>`, the natural place to explain
1215
- * one field). Re-placing it needs to know where that key ended up, and a misplaced comment is
1216
- * worse than an absent one — it attaches an explanation to something it does not explain.
1217
- * - STYLE. `dump` emits its own defaults, so an inline `storage: { suffix: thing }` comes back as a
1218
- * block mapping and a hand-folded scalar comes back on one line. The diff is the whole file
1219
- * however small the edit.
1220
- * - The RECORD half of the same bug, which is a different call site entirely (`store.serialize`)
1221
- * and untouched: `dt set` on one field rewraps every folded scalar and expands every flow
1222
- * sequence in the frontmatter.
1223
- *
1224
- * All three need a YAML library that keeps a document's syntax tree; js-yaml has no such API, and
1225
- * the change fans out through every reader and writer in the record layer. `setScalar` above states
1226
- * the same conclusion from the other end.
1227
- */
1228
- function writeDescriptor(previousText, doc) {
1229
- return previousText === null ? dump(doc) : reattachComments(previousText, dump(doc));
1230
- }
1231
-
1232
1083
  // saved views (M3): a studio-saved view IS a ui-view record — but ui-views are
1233
1084
  // system-stored (sources + compile), so the write goes through the same gate as any
1234
1085
  // other schema op. the studio "save view" button lands here.
@@ -1240,10 +1091,14 @@ export function saveUiView(ws, store, { id, view }) {
1240
1091
  const existed = fs.existsSync(dest);
1241
1092
  // A module source is where this project writes down WHY a view exists; `dump` cannot keep that.
1242
1093
  const previous = existed ? fs.readFileSync(dest, 'utf8') : null;
1094
+ // ⚠ opted OUT of the comment invariant, on the same rule `remove-field` is: this write REPLACES
1095
+ // the view, so a key the caller omits is deliberately gone (see the `filter:` case) and the comment
1096
+ // explaining that key goes with it. Every key that SURVIVES keeps its comments, which is what the
1097
+ // round-trip buys and what the old `dump` could not do.
1243
1098
  writeGated(ws, store, [dest], `dreamteamer: ui-views ${existed ? 'update' : 'add'} ${id}`, () => {
1244
1099
  fs.mkdirSync(path.dirname(dest), { recursive: true });
1245
- fs.writeFileSync(dest, previous === null ? dump(view) : reattachComments(previous, dump(view)));
1246
- });
1100
+ fs.writeFileSync(dest, writeSource(previous, view));
1101
+ }, undefined, { commentsMayDecrease: true });
1247
1102
  return { id, file: dest, updated: existed };
1248
1103
  }
1249
1104
 
package/src/yaml.js CHANGED
@@ -1,6 +1,190 @@
1
1
  // contract rule: YAML is parsed with the CORE schema — unquoted dates stay strings,
2
2
  // never timestamp objects. ALL dreamteamer tooling loads YAML through here.
3
3
  import yaml from 'js-yaml';
4
+ import { parseDocument, isMap, isSeq, isScalar } from 'yaml';
4
5
 
5
6
  export const load = (text) => yaml.load(text, { schema: yaml.CORE_SCHEMA });
6
7
  export const dump = (obj, opts = {}) => yaml.dump(obj, { lineWidth: 120, ...opts });
8
+
9
+ // ---- writing a source a HUMAN wrote ---------------------------------------------
10
+ //
11
+ // `dump` is for GENERATED output — the compiled runtime, a record's front matter, a harness file —
12
+ // where nothing was hand-formatted and byte-stability is the only thing that matters. A module
13
+ // SOURCE is the opposite case: it is hand-written, and it is where a module records WHY a collection
14
+ // exists. `load` → mutate → `dump` destroys all of that, because a dump re-derives the whole file
15
+ // from the parsed value: every comment gone, every flow form (`templates: [a]`, `storage: { … }`)
16
+ // expanded to block, every hand-folded scalar re-wrapped at the writer's own width. One `add-field`
17
+ // on a commented descriptor took it to zero comments, and one namespacing migration lost 194 comment
18
+ // lines across 24 descriptors — headers stating what belongs in a collection and which failure mode
19
+ // it guards against. Nothing warned; the schema was unchanged, so every gate stayed green.
20
+ //
21
+ // Three hand-rolled textual workarounds were written against this before it was fixed properly (a
22
+ // `setScalar` regex, an `x-reference` line editor, and a `reattachComments` pass that could only
23
+ // carry TOP-LEVEL blocks). All three are retired by `writeSource`.
24
+ //
25
+ // ⚠ THE DOCUMENT API ALONE IS NOT ENOUGH, and that was measured rather than assumed. `yaml` keeps
26
+ // comments and key order across parse → mutate → stringify, but it re-derives STYLE from its options
27
+ // rather than from the source, so a plain round-trip still reformats: `[a, b]` gains or loses its
28
+ // padding depending on one global flag the file's own author used both ways, and a block-folded
29
+ // scalar is re-folded at `lineWidth` — hand-wrapping the library cannot see and cannot reproduce.
30
+ // Straight through the Document API, 27 of 92 hand-written sources round-tripped byte-identically.
31
+ //
32
+ // So the stringify is followed by a pass that puts the ORIGINAL BYTES back wherever nothing changed:
33
+ // walk the old and new documents together, and for every node — or every key/value pair — whose
34
+ // value is deep-equal on both sides, replace the newly-emitted span with the source it was parsed
35
+ // from. Unchanged means byte-identical BY CONSTRUCTION, so the diff can only ever be the mutation.
36
+ // With the pass, 92 of 92. `test/unit/yaml-source.test.js` is the reproduction.
37
+
38
+ // `lineWidth: 0` never re-folds — an unchanged scalar is restored verbatim below, and a changed one
39
+ // must not drag its neighbours onto new lines.
40
+ //
41
+ // ⚠ `flowCollectionPadding` is ONE flag for two conventions that genuinely differ. Counted over a
42
+ // real workspace's 92 hand-written sources: flow SEQUENCES are unpadded 183 times out of 183
43
+ // (`list_fields: [name, status]`), flow MAPPINGS are padded 118 times out of 120
44
+ // (`storage: { path: x }`). Only a CHANGED collection is re-emitted at all — everything else is
45
+ // restored byte-for-byte — and what these ops change is the sequences: `list_fields`, `required`,
46
+ // `enum` and `templates`. So the sequence convention wins, and a flow mapping loses its padding only
47
+ // on the line an `x-reference` retarget was already rewriting.
48
+ const SOURCE_OPTS = { lineWidth: 0, flowCollectionPadding: false };
49
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
50
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
51
+ const keyOf = (pair) => String(pair.key?.value ?? pair.key);
52
+
53
+ /**
54
+ * Apply a plain JS value onto a parsed document, touching only the nodes that differ.
55
+ *
56
+ * Key ORDER comes from the value, which is what gives `add-field` its insert-before-body placement
57
+ * for free; an existing key keeps its node — and therefore its comments, its quoting and its flow
58
+ * form — whenever its value is unchanged. A node that IS replaced inherits the comments that sat on
59
+ * the position, because those belong to the key, not to the value that happened to be there.
60
+ */
61
+ function merge(doc, node, value) {
62
+ if (isMap(node) && isObj(value)) {
63
+ node.items = Object.entries(value).map(([k, v]) => {
64
+ const pair = node.items.find((p) => keyOf(p) === k);
65
+ if (!pair) return doc.createPair(k, v);
66
+ pair.value = merge(doc, pair.value, v);
67
+ return pair;
68
+ });
69
+ return node;
70
+ }
71
+ // ⚠ A SEQUENCE IS MATCHED BY VALUE FIRST, NEVER BY INDEX ALONE. A comment in a list belongs to the
72
+ // ITEM it sits above, and index-matching hands it to whatever slides into that slot: dropping
73
+ // `name` from `required: [name, vendor_code]` moved "name is required because…" on top of
74
+ // `vendor_code` — an explanation attached to something it does not explain, which is worse than
75
+ // losing it. So an unchanged item keeps its own node wherever it moved to; only what is left over
76
+ // falls to the leftover nodes IN ORDER, which is what lets an in-place edit (an `x-reference`
77
+ // retarget rewriting one entry) keep the comment that was always about that position.
78
+ if (isSeq(node) && Array.isArray(value)) {
79
+ const old = [...node.items];
80
+ const out = new Array(value.length);
81
+ value.forEach((v, i) => {
82
+ const j = old.findIndex((it) => it !== undefined && same(it?.toJSON?.() ?? null, v ?? null));
83
+ if (j !== -1) { out[i] = old[j]; old[j] = undefined; }
84
+ });
85
+ let k = 0;
86
+ value.forEach((v, i) => {
87
+ if (out[i] !== undefined) return;
88
+ while (k < old.length && old[k] === undefined) k++;
89
+ out[i] = k < old.length ? merge(doc, old[k++], v) : doc.createNode(v);
90
+ });
91
+ node.items = out;
92
+ return node;
93
+ }
94
+ if (isScalar(node) && !isObj(value) && !Array.isArray(value) && node.value === value) return node;
95
+ const fresh = doc.createNode(value);
96
+ for (const k of ['comment', 'commentBefore', 'spaceBefore']) if (node?.[k] !== undefined) fresh[k] = node[k];
97
+ return fresh;
98
+ }
99
+
100
+ const lineEnd = (text, from) => { const nl = text.indexOf('\n', from); return nl === -1 ? text.length : nl; };
101
+ const commentLines = (node) => (node?.comment == null ? 0 : node.comment.split('\n').length);
102
+
103
+ /**
104
+ * Where a node's source really ends. `range[1]` stops at the VALUE, so a trailing `# comment` sits
105
+ * outside it and its alignment is lost with it.
106
+ *
107
+ * ⚠ Counts COMMENT lines, not total lines, and takes the count from the ORIGINAL side. The two sides
108
+ * spell one comment differently: a source writes `icon: star # why` on one line, while the library
109
+ * re-emits a MULTI-line comment on lines of its own — so the same comment spans two lines in the
110
+ * source and three in the output. A following line is only ever consumed when it is nothing but a
111
+ * comment, so a wrong count can shorten the span but can never swallow content.
112
+ */
113
+ function endOf(text, from, lines) {
114
+ if (lines <= 0) return from;
115
+ let end = lineEnd(text, from);
116
+ let got = text.slice(from, end).includes('#') ? 1 : 0;
117
+ while (got < lines && end < text.length) {
118
+ const next = lineEnd(text, end + 1);
119
+ if (!/^\s*#/.test(text.slice(end + 1, next))) break;
120
+ end = next;
121
+ got++;
122
+ }
123
+ return end;
124
+ }
125
+
126
+ const nodeSpan = (text, n, lines) => [n.range[0], endOf(text, n.range[1], lines)];
127
+ /** A pair's whole source — the key, the value, and any comment sitting between or after them. */
128
+ const pairSpan = (text, p, lines) => [p.key.range[0], endOf(text, (p.value?.range ? p.value : p.key).range[1], lines)];
129
+
130
+ /** Emit the document, restoring the original bytes of everything that did not change. */
131
+ function emit(doc, originalText) {
132
+ const out = doc.toString(SOURCE_OPTS);
133
+ if (originalText == null) return out;
134
+ const oldRoot = parseDocument(originalText, { schema: 'core' }).contents;
135
+ const newRoot = parseDocument(out, { schema: 'core' }).contents;
136
+ const kind = (x) => (isMap(x) ? 'map' : isSeq(x) ? 'seq' : isScalar(x) ? 'scalar' : null);
137
+ const edits = [];
138
+
139
+ const visit = (o, n) => {
140
+ if (!o?.range || !n?.range || kind(o) === null || kind(o) !== kind(n)) return;
141
+ if (same(o.toJSON(), n.toJSON())) {
142
+ const lines = commentLines(o);
143
+ edits.push([...nodeSpan(out, n, lines), originalText.slice(...nodeSpan(originalText, o, lines))]);
144
+ return;
145
+ }
146
+ // A changed collection is still mostly unchanged — descend, so one edited field restores every
147
+ // sibling verbatim instead of re-emitting the whole block around it.
148
+ if (isMap(o) && isMap(n)) {
149
+ for (const np of n.items) {
150
+ const op = o.items.find((p) => keyOf(p) === keyOf(np));
151
+ if (!op) continue;
152
+ if (same(op.value?.toJSON?.() ?? null, np.value?.toJSON?.() ?? null)) {
153
+ const lines = commentLines(op.value);
154
+ edits.push([...pairSpan(out, np, lines), originalText.slice(...pairSpan(originalText, op, lines))]);
155
+ } else visit(op.value, np.value);
156
+ }
157
+ } else if (isSeq(o) && isSeq(n)) {
158
+ for (let i = 0; i < Math.min(o.items.length, n.items.length); i++) visit(o.items[i], n.items[i]);
159
+ }
160
+ };
161
+ visit(oldRoot, newRoot);
162
+
163
+ // right-to-left, so an earlier splice cannot shift a later one's offsets. Descending into a node
164
+ // only happens when it was NOT spliced, so no two spans overlap.
165
+ let res = out;
166
+ for (const [s, e, src] of edits.sort((a, b) => b[0] - a[0])) res = res.slice(0, s) + src + res.slice(e);
167
+ // ⚠ FAIL CLOSED. Splicing bytes is only safe because it is checked: if the result does not parse
168
+ // back to exactly the document that was asked for, the plain stringify is returned instead — a
169
+ // reformatted file rather than a wrong one.
170
+ try {
171
+ if (!same(parseDocument(res, { schema: 'core' }).toJS(), doc.toJS())) return out;
172
+ } catch { return out; }
173
+ return res;
174
+ }
175
+
176
+ /**
177
+ * Serialize `value` back over the source it came from, keeping everything the change did not touch.
178
+ *
179
+ * `previousText` is the bytes on disk, or null for a file that does not exist yet — a new file has no
180
+ * formatting to preserve and no comments to lose, so it is `dump`ed exactly as before.
181
+ */
182
+ export function writeSource(previousText, value) {
183
+ if (previousText === null || previousText === undefined) return dump(value);
184
+ const doc = parseDocument(previousText, { schema: 'core' });
185
+ doc.contents = merge(doc, doc.contents, value);
186
+ return emit(doc, previousText);
187
+ }
188
+
189
+ /** Lines that are nothing but a comment — the quantity `writeGated`'s invariant protects. */
190
+ export const commentCount = (text) => text.split('\n').filter((l) => l.trimStart().startsWith('#')).length;