@orkestrel/scaffold 0.0.55 → 0.0.56

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.
@@ -1211,6 +1211,97 @@ function listDirectories(root) {
1211
1211
  return directories.sort();
1212
1212
  }
1213
1213
  /**
1214
+ * Lists the canon paths a target holds, filtered to a plan's groups.
1215
+ *
1216
+ * @param target - The target directory to inspect.
1217
+ * @param groups - The artifact groups the plan covers; a held path whose group
1218
+ * is outside them is not listed.
1219
+ * @returns Every held canon path as a `/`-separated target-relative path, a
1220
+ * directory member expanded to the files beneath it, and `[]` when the target
1221
+ * holds none or cannot be resolved.
1222
+ * @throws `ScaffoldError('TARGET', …)` when a held canon directory cannot be
1223
+ * inventoried; {@link listFiles} states each refusal.
1224
+ *
1225
+ * @remarks
1226
+ * A release stages the canon for reading rather than for a target, so a copy
1227
+ * sitting in one is an artifact of a release that vendored it, and reading it
1228
+ * here is what lets the deletion verb take it.
1229
+ *
1230
+ * A directory member is read by file, which is what pairs the paths a plan
1231
+ * claims inside the canon with their artifacts and leaves only the rest foreign,
1232
+ * with no case for a planned file. The plan's own selection gates the reading,
1233
+ * the same rule that decides a vendored path's group, so a scoped audit reports
1234
+ * nothing outside its groups. A member that cannot resolve inside the target is
1235
+ * not held by it.
1236
+ *
1237
+ * @example
1238
+ * ```ts
1239
+ * import { listCanonPaths } from '@orkestrel/scaffold/server'
1240
+ *
1241
+ * listCanonPaths('vacant', ['orchestration']) // []
1242
+ * ```
1243
+ */
1244
+ function listCanonPaths(target, groups) {
1245
+ const held = [];
1246
+ for (const member of _src_core.CANON_PATHS) {
1247
+ const full = resolveContainedPath(target, member);
1248
+ if (full === void 0) continue;
1249
+ if (isPhysicalFile(full)) held.push(member);
1250
+ else if (isPhysicalDirectory(full)) for (const name of listFiles(full)) held.push(`${member}/${name}`);
1251
+ }
1252
+ return held.filter((path) => groups.includes((0, _src_core.inferGroup)(path)));
1253
+ }
1254
+ /**
1255
+ * Removes every directory one set of deletions emptied.
1256
+ *
1257
+ * @param target - The directory the deleted paths are relative to.
1258
+ * @param removed - The `/`-separated target-relative paths the deletion took.
1259
+ * @returns Every removed directory as a target-relative path, in the order they
1260
+ * were taken, and `[]` when the target is absent or nothing it holds was
1261
+ * emptied.
1262
+ *
1263
+ * @remarks
1264
+ * A deletion that takes the last file out of a directory leaves the directory
1265
+ * standing, and git records no directory, so a swept target keeps the shape of a
1266
+ * set it no longer holds while every reading of it reports clean. Only an
1267
+ * ancestor of a path this deletion took is a candidate, so nothing the deletion
1268
+ * did not reach is inspected, and the target itself is never a candidate.
1269
+ *
1270
+ * Candidates are taken deepest first, which is what lets a whole chain go: the
1271
+ * directory holding nothing but the emptied directory is empty in turn by the
1272
+ * time it is read. Each one is resolved through the containment law and left
1273
+ * standing when it escapes the target, is not a physical directory, still holds
1274
+ * an entry, or refuses removal, and a directory left standing is absent from the
1275
+ * answer.
1276
+ *
1277
+ * @example
1278
+ * ```ts
1279
+ * import { pruneEmptiedDirectories } from '@orkestrel/scaffold/server'
1280
+ *
1281
+ * pruneEmptiedDirectories('vacant', ['notes/entry.md']) // []
1282
+ * ```
1283
+ */
1284
+ function pruneEmptiedDirectories(target, removed) {
1285
+ const candidates = /* @__PURE__ */ new Set();
1286
+ for (const path of removed) {
1287
+ let parent = (0, node_path.dirname)(path);
1288
+ while (parent !== "." && parent !== (0, node_path.dirname)(parent) && !candidates.has(parent)) {
1289
+ candidates.add(parent);
1290
+ parent = (0, node_path.dirname)(parent);
1291
+ }
1292
+ }
1293
+ const ordered = [...candidates].sort((left, right) => (0, _orkestrel_contract.compareValues)(right.split("/").length, left.split("/").length) || (0, _orkestrel_contract.compareValues)(left, right));
1294
+ const pruned = [];
1295
+ for (const candidate of ordered) {
1296
+ const directory = resolveContainedPath(target, candidate);
1297
+ if (directory === void 0 || !isPhysicalDirectory(directory)) continue;
1298
+ const entries = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readdirSync)(directory));
1299
+ if (!entries.success || entries.value.length > 0) continue;
1300
+ if ((0, _orkestrel_contract.attempt)(() => (0, node_fs.rmdirSync)(directory)).success) pruned.push(candidate);
1301
+ }
1302
+ return pruned;
1303
+ }
1304
+ /**
1214
1305
  * Read one contained file as its exact bytes in lowercase hexadecimal.
1215
1306
  *
1216
1307
  * @param root - The containing host directory.
@@ -1519,8 +1610,11 @@ function readManifestEntry(destination, source) {
1519
1610
  * went missing, names an undeclared path, or leaves a host-owned path absent
1520
1611
  * answers `undefined`. Deferred paths are presence-only and retain the installed
1521
1612
  * floor bytes that their catalog or mirror surface owns; repair never writes
1522
- * those floor bytes. One `Host` can therefore carry live host bytes beside floor
1523
- * bytes without mixing baselines within a surface.
1613
+ * those floor bytes. A canon path is read the same way for a different reason:
1614
+ * the canon is staged for reading rather than for a target, so every canon
1615
+ * destination keeps its floor bytes here, claimed or not, and a fill that carries
1616
+ * no row for one is complete rather than spoiled. One `Host` can therefore carry
1617
+ * live host bytes beside floor bytes without mixing baselines within a surface.
1524
1618
  *
1525
1619
  * The emitted entries keep the release's own order and its storage and
1526
1620
  * executable declarations, and carry digests recomputed over the bytes the fill
@@ -1532,8 +1626,12 @@ function readManifestEntry(destination, source) {
1532
1626
  * ```ts
1533
1627
  * import { filesToHost } from '@orkestrel/scaffold/server'
1534
1628
  *
1535
- * filesToHost([{ path: 'AGENTS.md', lookup: 'found', hex: '23204167656e74730a' }], floor)
1536
- * // { manifest: { entries: [ … ], roots: [ … ], digest: '…' }, bytes: { 'AGENTS.md': '…' } }
1629
+ * // A floor declaring the host-owned `scripts/codex.sh` path and the canon
1630
+ * // `AGENTS.md` destination. The script's live bytes are taken; the canon
1631
+ * // destination keeps the floor's.
1632
+ * filesToHost([{ path: 'scripts/codex.sh', lookup: 'found', hex: '23212f62696e2f73680a' }], floor)
1633
+ * // { manifest: { entries: [ … ], roots: [ … ], digest: '…' },
1634
+ * // bytes: { 'scripts/codex.sh': '23212f62696e2f73680a', 'AGENTS.md': floor.bytes['AGENTS.md'] } }
1537
1635
  * ```
1538
1636
  */
1539
1637
  function filesToHost(files, floor) {
@@ -1541,12 +1639,12 @@ function filesToHost(files, floor) {
1541
1639
  const held = /* @__PURE__ */ new Map();
1542
1640
  for (const file of files) {
1543
1641
  if (file.lookup !== "found" || !declared.has(file.path)) return void 0;
1544
- if (!(0, _src_core.isDeferredPath)(file.path)) held.set(file.path, file.hex);
1642
+ if (!(0, _src_core.isDeferredPath)(file.path) && !(0, _src_core.isCanonPath)(file.path)) held.set(file.path, file.hex);
1545
1643
  }
1546
1644
  const entries = [];
1547
1645
  const bytes = {};
1548
1646
  for (const entry of floor.manifest.entries) {
1549
- const hex = (0, _src_core.isDeferredPath)(entry.destination) ? floor.bytes[entry.destination] : held.get(entry.destination);
1647
+ const hex = (0, _src_core.isDeferredPath)(entry.destination) || (0, _src_core.isCanonPath)(entry.destination) ? floor.bytes[entry.destination] : held.get(entry.destination);
1550
1648
  if (hex === void 0) return void 0;
1551
1649
  entries.push({
1552
1650
  storage: entry.storage,
@@ -1677,9 +1775,15 @@ function stageBytes(host, root, destinations) {
1677
1775
  * once. A directory is the same case — declaring an absent directory as an empty
1678
1776
  * root would create an empty directory in every generated workspace.
1679
1777
  *
1680
- * The vendoring deny-list applies to what the walk discovers beneath a vendored
1778
+ * The walk covers `HOST_PATHS` and `CANON_PATHS` together, because a release
1779
+ * ships both: a target receives the first set, and reads the second one out of
1780
+ * the installed package. Everything downstream — the missing-path refusal, the
1781
+ * storage collision guard, the sort, the digests, and the root inventory — reads
1782
+ * the union, so a canon path is staged under exactly the law a vendored one is.
1783
+ *
1784
+ * The vendoring deny-list applies to what the walk discovers beneath a staged
1681
1785
  * directory, where a maintainer's local credential can legitimately sit, and
1682
- * such a path is skipped. A path `HOST_PATHS` names itself is curated data
1786
+ * such a path is skipped. A path either list names itself is curated data
1683
1787
  * rather than discovery, so it is staged or the stage is refused.
1684
1788
  *
1685
1789
  * @example
@@ -1698,7 +1802,7 @@ function stageHost(checkout, host) {
1698
1802
  const vendored = [];
1699
1803
  const roots = [];
1700
1804
  const missing = [];
1701
- for (const path of _src_core.HOST_PATHS) {
1805
+ for (const path of [..._src_core.HOST_PATHS, ..._src_core.CANON_PATHS]) {
1702
1806
  const full = resolveContainedPath(source, path);
1703
1807
  if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Vendored path leaves its checkout at ${path}`, {
1704
1808
  checkout: source,
@@ -2652,7 +2756,8 @@ var Materializer = class Materializer {
2652
2756
  *
2653
2757
  * @param plan - The compiled plan to compare.
2654
2758
  * @param target - The directory to inspect.
2655
- * @returns One finding per hydrated planned path, plus foreign files beneath owned host roots.
2759
+ * @returns One finding per hydrated planned path, plus the foreign files
2760
+ * beneath owned host roots and inside the instruction canon.
2656
2761
  * @throws {@link ScaffoldError} coded `INVALID` when an argument is not the
2657
2762
  * exact shape, `TARGET` when the host or target cannot be read within its
2658
2763
  * bounds, and `DESTROYED` after teardown.
@@ -2660,8 +2765,16 @@ var Materializer = class Materializer {
2660
2765
  * @remarks
2661
2766
  * Host directories expand before the target is read, so this method and
2662
2767
  * {@link repair} compare the same paths with the same ownership. Foreign
2663
- * candidates are files beneath those expanded roots only; a root file never
2664
- * becomes a deletion candidate merely because its group is selected.
2768
+ * candidates are files beneath those expanded roots and files the target holds
2769
+ * at a `CANON_PATHS` member, each in a group the plan selects; a file outside
2770
+ * both populations never becomes a deletion candidate merely because its group
2771
+ * is selected.
2772
+ *
2773
+ * The canon is staged for reading rather than for a target, so a copy of one of
2774
+ * its paths sitting in a target is a superseded artifact and reports foreign. A
2775
+ * path the plan claims inside the canon — the root instruction pointers and the
2776
+ * catalog agent file — pairs with its artifact and is compared like any other
2777
+ * planned path.
2665
2778
  */
2666
2779
  audit(plan, target) {
2667
2780
  this.#assertAlive();
@@ -2860,9 +2973,20 @@ var Materializer = class Materializer {
2860
2973
  * recovery mechanism, so a path it cannot restore is not one this verb takes.
2861
2974
  * A tree carrying uncommitted work is refused whole for the same reason.
2862
2975
  *
2976
+ * One candidate list carries both foreign populations {@link audit} reports, so
2977
+ * a superseded instruction copy the target holds inside the canon is deleted in
2978
+ * the same transaction as a stray beneath an owned root. Membership decides it,
2979
+ * never byte identity: a copy a release behind no longer matches the bytes the
2980
+ * canon now stages, and matching bytes is exactly how such a copy would be
2981
+ * spared. An untracked leftover is left where it sits and stays a finding, which
2982
+ * is the seam a maintainer keeps a git-ignored file in.
2983
+ *
2863
2984
  * The whole call refuses when the preview disagrees with the re-derivation on
2864
2985
  * any foreign finding, including one the deletion itself would skip, because a
2865
2986
  * preview stale anywhere is stale evidence.
2987
+ *
2988
+ * Every directory the deletions emptied is taken after the commit, so a swept
2989
+ * target does not keep the shape of the set it no longer holds.
2866
2990
  */
2867
2991
  remove(plan, audit, worktree, target) {
2868
2992
  this.#assertAlive();
@@ -2980,6 +3104,7 @@ var Materializer = class Materializer {
2980
3104
  });
2981
3105
  for (const name of listFiles(directory)) paths.add(`${root}/${name}`);
2982
3106
  }
3107
+ for (const path of listCanonPaths(target, plan.groups)) paths.add(path);
2983
3108
  return {
2984
3109
  findings: (0, _src_core.planToFindings)(hydrated, readSnapshot(target, [...paths])),
2985
3110
  questions: []
@@ -3285,6 +3410,7 @@ var Materializer = class Materializer {
3285
3410
  });
3286
3411
  const removed = this.#close(transaction, staged, target);
3287
3412
  for (const path of removed) this.#emitter.emit("remove", path);
3413
+ pruneEmptiedDirectories(target, removed);
3288
3414
  return this.#finish({
3289
3415
  target,
3290
3416
  written: [],
@@ -4183,6 +4309,7 @@ exports.isUpstreamHooks = isUpstreamHooks;
4183
4309
  exports.isUpstreamOptions = isUpstreamOptions;
4184
4310
  exports.isVacant = isVacant;
4185
4311
  exports.isWorktree = isWorktree;
4312
+ exports.listCanonPaths = listCanonPaths;
4186
4313
  exports.listDirectories = listDirectories;
4187
4314
  exports.listFiles = listFiles;
4188
4315
  exports.matchesAnchor = matchesAnchor;
@@ -4194,6 +4321,7 @@ exports.matchesPrecondition = matchesPrecondition;
4194
4321
  exports.matchesProtectedPath = matchesProtectedPath;
4195
4322
  exports.matchesSensitivePath = matchesSensitivePath;
4196
4323
  exports.pathToStorage = pathToStorage;
4324
+ exports.pruneEmptiedDirectories = pruneEmptiedDirectories;
4197
4325
  exports.readAnchor = readAnchor;
4198
4326
  exports.readExpectation = readExpectation;
4199
4327
  exports.readFileHex = readFileHex;