dreamteamer 0.9.0 → 0.9.1

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.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "A workspace compiler for coding agents \u2014 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>",
package/src/compile.js CHANGED
@@ -840,7 +840,13 @@ export function compile({ root, pkg }) {
840
840
  // clear each kind's folder, plus `system/` — a runtime compiled by a pre-flatten engine has the
841
841
  // whole tree under there, and leaving it would keep stale descriptors on disk beside the fresh
842
842
  // ones. Never `rm -rf` the runtime root itself: it also holds the write lock.
843
- for (const kind of KINDS) fs.rmSync(path.join(RUNTIME, kind), { recursive: true, force: true });
843
+ // DERIVED_KINDS too, not just KINDS. `modules/` is projected rather than staged, so it was not
844
+ // in this loop and never got cleared — a module that was RENAMED or REMOVED left its old record
845
+ // behind forever, listing collections that no longer exist. `check` reads those records like any
846
+ // other, so it surfaced as a dangling reference in a file nobody had touched, twice in one day
847
+ // (`hq3-workspace` after the workspace-module rename, and again after `crm` was folded in). The
848
+ // runtime is build output; stale build output is the compiler's problem, not the reader's.
849
+ for (const kind of [...KINDS, ...DERIVED_KINDS]) fs.rmSync(path.join(RUNTIME, kind), { recursive: true, force: true });
844
850
  fs.rmSync(path.join(RUNTIME, 'system'), { recursive: true, force: true });
845
851
  fs.rmSync(path.join(RUNTIME, 'ui'), { recursive: true, force: true });
846
852
  for (const [rt, e] of entries) {
package/src/schema-ops.js CHANGED
@@ -95,6 +95,50 @@ function descriptorSourceDir(ws, name) {
95
95
  return { dir: kindDir(moduleRoot, 'collections'), sources };
96
96
  }
97
97
 
98
+ /**
99
+ * Set one scalar in a YAML document TEXTUALLY, so comments and key order survive.
100
+ *
101
+ * This exists because `load` → mutate → `dump` is lossy in the one way that matters here: it drops
102
+ * every comment. That is fine for a generated artifact and wrong for a module SOURCE, which is where
103
+ * this project writes down why a collection exists. Only `renameCollection` uses it, and only for the
104
+ * three scalars a rename changes; anything more ambitious belongs in a real round-trip YAML library,
105
+ * not in a regex.
106
+ *
107
+ * Handles both spellings the descriptors actually use — a top-level key, a nested block mapping, and
108
+ * the inline `storage: { path: x, suffix: y }` flow form. Callers MUST re-parse and assert, because a
109
+ * shape not covered here fails by changing nothing rather than by throwing.
110
+ */
111
+ function setScalar(text, keyPath, value) {
112
+ const [head, child] = keyPath;
113
+ if (!child) return text.replace(new RegExp(`^${head}:.*$`, 'm'), `${head}: ${value}`);
114
+
115
+ // inline flow mapping: `storage: { path: data/x, suffix: y }`
116
+ const flow = new RegExp(`^${head}:\\s*\\{([^}]*)\\}\\s*$`, 'm').exec(text);
117
+ if (flow) {
118
+ let body = flow[1];
119
+ body = new RegExp(`\\b${child}:\\s*[^,}]+`).test(body)
120
+ ? body.replace(new RegExp(`(\\b${child}:\\s*)[^,}]+`), `$1${value}`)
121
+ : `${body.trimEnd()}, ${child}: ${value}`;
122
+ return text.slice(0, flow.index) + `${head}: {${body}}` + text.slice(flow.index + flow[0].length);
123
+ }
124
+
125
+ // block mapping: `storage:\n path: data/x`
126
+ const block = new RegExp(`^${head}:\\n(?:[ \\t]+.*\\n)*?[ \\t]+${child}:.*$`, 'm').exec(text);
127
+ if (block) {
128
+ return text.slice(0, block.index)
129
+ + block[0].replace(new RegExp(`([ \\t]+${child}:).*$`, 'm'), `$1 ${value}`)
130
+ + text.slice(block.index + block[0].length);
131
+ }
132
+
133
+ // the key is absent under an existing block — insert it directly after the parent
134
+ const parent = new RegExp(`^${head}:\\s*$`, 'm').exec(text);
135
+ if (parent) {
136
+ const at = parent.index + parent[0].length + 1;
137
+ return text.slice(0, at) + ` ${child}: ${value}\n` + text.slice(at);
138
+ }
139
+ return text;
140
+ }
141
+
98
142
  // ---- ops ------------------------------------------------------------------------
99
143
 
100
144
  export function createCollection(ws, store, { name, template, namespace }) {
@@ -263,21 +307,66 @@ export function renameCollection(ws, store, oldName, newName) {
263
307
  };
264
308
  for (const id of ids) captureRefs(`${oldName}/${id}`);
265
309
  captureRefs(`collections/${oldName}`);
266
- const restoreRefs = () => { for (const [f, bytes] of refFiles) fs.writeFileSync(f, bytes); };
310
+ const restoreRefs = () => {
311
+ for (const [f, bytes] of refFiles) {
312
+ fs.mkdirSync(path.dirname(f), { recursive: true }); // pruneEmpty may have taken the parent
313
+ fs.writeFileSync(f, bytes);
314
+ }
315
+ };
267
316
 
268
317
  const touched = new Set();
269
318
  let rewrites = 0;
270
319
  try {
271
- // 1. the descriptor source, at its new path
320
+ // 1. the descriptor source, at its new path — EDITED TEXTUALLY, never re-dumped.
321
+ //
322
+ // ⚠ `fs.writeFileSync(dest, dump(doc))` destroyed every comment in the descriptor, and a
323
+ // descriptor's comments are where this project keeps its reasoning: 194 lines across 24
324
+ // files in one real migration, including 22-line headers stating what belongs in a
325
+ // collection and which failure mode it guards against. The record survived; the thinking
326
+ // did not, and nothing said so.
327
+ //
328
+ // A rename changes exactly three scalars. Rewriting those three in place keeps the
329
+ // comments, the key order and the author's formatting — and the parse afterwards proves
330
+ // the edit landed rather than trusting the regex.
331
+ const edited = setScalar(setScalar(setScalar(srcBytes.toString('utf8'),
332
+ ['name'], newName),
333
+ ['storage', 'path'], newPath),
334
+ ['storage', 'suffix'], newSuffix);
335
+ const parsed = load(edited);
336
+ if (parsed?.name !== newName || parsed?.storage?.path !== newPath || parsed?.storage?.suffix !== newSuffix) {
337
+ throw new Error(`could not rewrite ${path.relative(ws.root, src)} in place — name/storage.path/storage.suffix did not take. nothing was changed.`);
338
+ }
272
339
  doc.name = newName;
273
340
  doc.storage = { ...doc.storage, path: newPath, suffix: newSuffix };
274
341
  fs.mkdirSync(path.dirname(dest), { recursive: true });
275
- fs.writeFileSync(dest, dump(doc));
342
+ fs.writeFileSync(dest, edited);
276
343
  if (dest !== src) fs.rmSync(src);
277
344
  touched.add(src);
278
345
  touched.add(dest);
279
346
 
280
- // 2. the record folder, then the per-file suffix if it was derived
347
+ // 2. INBOUND REFERENCES FIRST, while the records are still where the store thinks they are.
348
+ //
349
+ // ⚠ This used to run AFTER the folder move and it silently missed every SELF-reference.
350
+ // `store.rewriteRefs` walks `recordFiles()`, which resolves each collection's directory
351
+ // from the descriptor loaded when the Store was built — i.e. the OLD `storage.path`. Move
352
+ // the records first and that walk finds an empty directory, so a record pointing at its
353
+ // own collection is never rewritten and dangles the moment compile catches up.
354
+ //
355
+ // It is not a corner case: it hit `finance/accounts`, where every card and loan carries
356
+ // `settled_by: <the account that settles it>` — 5 dangling refs out of 11 records, found
357
+ // only because `check` ran afterwards. Doing the rewrite first needs no descriptor reload
358
+ // and no second code path: the files are still at the old path, which is exactly what the
359
+ // old refs say.
360
+ for (const id of ids) {
361
+ const out = store.rewriteRefs(`${oldName}/${id}`, `${newName}/${id}`);
362
+ rewrites += out.rewrites;
363
+ for (const f of out.touched) touched.add(f);
364
+ }
365
+ const collOut = store.rewriteRefs(`collections/${oldName}`, `collections/${newName}`);
366
+ rewrites += collOut.rewrites;
367
+ for (const f of collOut.touched) touched.add(f);
368
+
369
+ // 3. the record folder, then the per-file suffix if it was derived
281
370
  if (newDir !== oldDir && fs.existsSync(oldDir)) {
282
371
  fs.mkdirSync(path.dirname(newDir), { recursive: true });
283
372
  fs.renameSync(oldDir, newDir);
@@ -295,33 +384,48 @@ export function renameCollection(ws, store, oldName, newName) {
295
384
  }
296
385
  if (movedData) { touched.add(oldDir); touched.add(newDir); }
297
386
 
298
- // 3. inbound references: per record id, plus the collection's own id in `collections`
299
- // (which is what ui-views and command-bindings point at).
300
- for (const id of ids) {
301
- const out = store.rewriteRefs(`${oldName}/${id}`, `${newName}/${id}`);
302
- rewrites += out.rewrites;
303
- for (const f of out.touched) touched.add(f);
304
- }
305
- const collOut = store.rewriteRefs(`collections/${oldName}`, `collections/${newName}`);
306
- rewrites += collOut.rewrites;
307
- for (const f of collOut.touched) touched.add(f);
308
-
309
387
  // 4. bare `x-reference: <oldName>` in every descriptor SOURCE. Not a `<collection>/<id>`
310
- // ref, so step 3 cannot see it — and leaving it makes compile fail on an unknown target.
388
+ // ref, so step 2 cannot see it — and leaving it makes compile fail on an unknown target.
389
+ //
390
+ // ⚠ TEXTUAL, for the same reason step 1 is. This used to `load` → mutate → `dump`, which
391
+ // meant that ANY descriptor needing a retarget lost every comment in it — including the
392
+ // renamed one itself when it self-references, which is how step 1's careful preservation
393
+ // was undone one step later. 17 of the 24 descriptors stripped in the migration that
394
+ // found this were stripped HERE, not there.
395
+ //
396
+ // `retargetRefs` still decides WHETHER a file is affected — it walks the parsed schema and
397
+ // knows about nested properties and `items` — but the write is a line edit, and the parse
398
+ // afterwards proves it landed.
311
399
  for (const f of descriptorSources(ws, store)) {
312
400
  const before = fs.readFileSync(f, 'utf8');
313
- const doc2 = load(before);
314
- if (!doc2 || !retargetRefs(doc2.schema, oldName, newName)) continue;
401
+ const probe = load(before);
402
+ if (!probe || !retargetRefs(probe.schema, oldName, newName)) continue;
403
+ // ⚠ the boundary must cover BOTH spellings. A descriptor may write the block form
404
+ // (`x-reference: accounts` to end of line) or the inline flow form
405
+ // (`{ type: string, x-reference: accounts }`), where the value ends at `,` or `}`.
406
+ // Anchoring on `$` alone silently matched nothing in the flow form — and the assert
407
+ // below turned that silence into a refusal, which is how it was found.
408
+ const after = before.replace(
409
+ new RegExp(`(x-reference:\\s*)(['"]?)${oldName.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}\\2(?=\\s*(?:[,}]|#|$))`, 'gm'),
410
+ (_m, lead) => `${lead}${newName.includes('/') ? `'${newName}'` : newName}`);
411
+ const reparsed = load(after);
412
+ if (!reparsed || retargetRefs(reparsed.schema, oldName, newName)) {
413
+ throw new Error(`could not retarget x-reference "${oldName}" in ${path.relative(ws.root, f)} without reformatting it — nothing was changed.`);
414
+ }
315
415
  if (!refFiles.has(f)) refFiles.set(f, Buffer.from(before));
316
- fs.writeFileSync(f, dump(doc2));
416
+ fs.writeFileSync(f, after);
317
417
  touched.add(f);
318
418
  rewrites++;
319
419
  }
320
420
 
321
421
  compile(ws); // the gate: an uncompilable rename never reaches history
322
422
  } catch (e) {
323
- restoreRefs();
423
+ // ⚠ undo() FIRST. A captured file can be a SELF-reference — a record of the collection being
424
+ // renamed — so its path only exists again once undo() has moved the folder back. Restoring
425
+ // before that wrote into a directory that was no longer there, and the ENOENT masked the
426
+ // error actually being rolled back from.
324
427
  undo();
428
+ restoreRefs();
325
429
  try { compile(ws); } catch { /* pre-rename sources were compilable */ }
326
430
  throw e;
327
431
  }
@@ -338,8 +442,8 @@ export function renameCollection(ws, store, oldName, newName) {
338
442
  execFileSync('git', ['commit', '--quiet', '-m', `dreamteamer: collections rename ${oldName} → ${newName}`, '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET });
339
443
  } catch (e) {
340
444
  try { execFileSync('git', ['reset', '--quiet', '--', ...rels], { cwd: ws.root, stdio: GIT_QUIET }); } catch { /* nothing staged */ }
341
- restoreRefs();
342
445
  undo();
446
+ restoreRefs();
343
447
  try { compile(ws); } catch { /* pre-rename sources were compilable */ }
344
448
  throw new Error(`git commit failed — the rename was rolled back, nothing was changed. (${e.message.split('\n')[0]})`);
345
449
  }
package/src/store.js CHANGED
@@ -299,13 +299,31 @@ export class Store {
299
299
  return new RegExp(`${ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w/-])`, 'g');
300
300
  }
301
301
 
302
+ // ⚠ EACH FILE EXACTLY ONCE. The `modules` collection's storage.path is `modules` and
303
+ // `sourceRoots()` includes the workspace root, so walking it RE-YIELDS every module source that
304
+ // its own kind's walk already produced — 173 files in one real vault, every module source
305
+ // among them.
306
+ //
307
+ // That was harmless while every rewrite was idempotent, and stopped being harmless the day
308
+ // namespaces arrived: replacing `draft-docs/x` with `rnd/draft-docs/x` is NOT idempotent,
309
+ // because the result still contains the pattern. A second pass wrote
310
+ // `data/rnd/rnd/draft-docs/x` into module-source comments during a real migration. Dedupe HERE
311
+ // rather than making each caller idempotent — `findInboundRefs` is a caller too, and its counts
312
+ // were quietly doubled by the same walk.
302
313
  *recordFiles() {
314
+ const seen = new Set();
303
315
  for (const d of this.descriptors.values()) {
304
316
  // for runtime-based collections, inbound-ref surgery targets SOURCES, not the runtime
305
317
  const roots = d.storage.base === 'runtime' ? this.sourceRoots() : [this.root];
306
318
  for (const srcRoot of roots) {
307
319
  const dir = path.join(srcRoot, d.storage.path);
308
- if (fs.existsSync(dir)) yield* walk(dir);
320
+ if (!fs.existsSync(dir)) continue;
321
+ for (const f of walk(dir)) {
322
+ const key = path.resolve(f);
323
+ if (seen.has(key)) continue;
324
+ seen.add(key);
325
+ yield f;
326
+ }
309
327
  }
310
328
  }
311
329
  }