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 +3 -2
- package/src/schema-ops.js +68 -213
- package/src/yaml.js +184 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dreamteamer",
|
|
3
|
-
"version": "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
|
-
|
|
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 —
|
|
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
|
|
366
|
-
//
|
|
367
|
-
// the edit landed rather than trusting the
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
// ⚠
|
|
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`
|
|
441
|
-
//
|
|
442
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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;
|