@sleepy_panda_srl/portulan 0.1.1 → 0.1.2

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/cli/compile.mjs CHANGED
@@ -45,6 +45,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
45
45
  // Host plugin-cache discovery (#123). The record reader, the config directory and the version refusal
46
46
  // all live there, once — see that file's pack-root section for why this half arrived separately.
47
47
  import { AUTO, discoverPackRoots, namedWithAuto, resolutionRoots } from "./discover.mjs";
48
+ import { isInside } from "./inside.mjs";
48
49
 
49
50
  /** Raised when `compile` cannot run, or cannot compile honestly. Always exit 2, never 1. */
50
51
  export class CompileError extends Error {
@@ -1107,10 +1108,24 @@ export function claudeCode(parsed, options = {}) {
1107
1108
  // The generation header. An emitted artifact that does not say what generated it invites the
1108
1109
  // one edit this whole rail exists to catch — a hand-fix that survives until the next compile
1109
1110
  // silently reverts it.
1111
+ // **What compiled this, recorded beside what generated it** (#264). An unpinned run WOULD read
1112
+ // the host's plugin cache while the rail reads the tree — it now refuses that arrangement outright
1113
+ // (#316) — and until this field the artifact said nothing about which answered, so a drift RED named a difference no reader could find in the repository, because the
1114
+ // deciding input was a directory outside it.
1115
+ //
1116
+ // **Origin and version, never the ROOT PATH.** The discovered root is an absolute path under
1117
+ // somebody's home directory; recording it would make a tracked artifact machine-dependent and red
1118
+ // `verify/compile.sh` for every developer and CI — trading a silent hazard for a permanent false
1119
+ // one. `recordedOrigin` collapses the resolver's three tags to two for the same reason: the pinned
1120
+ // rail and a bare run must agree byte for byte about an identical world.
1121
+ const packs = (options.packProvenance ?? [])
1122
+ .map((c) => ({ pack: c.pack, origin: c.origin, version: c.version ?? null }))
1123
+ .sort((a, b) => a.pack.localeCompare(b.pack));
1110
1124
  const value = {
1111
1125
  $portulan: {
1112
1126
  generated: "cli/compile.mjs",
1113
1127
  source,
1128
+ ...(packs.length ? { packs } : {}),
1114
1129
  warning: `Generated file. Edit ${source} and recompile; \`verify/compile.sh\` fails on drift.`,
1115
1130
  },
1116
1131
  permissions: { deny, ask, allow: [] },
@@ -1438,6 +1453,11 @@ function artifactPaths(workspaceDir) {
1438
1453
 
1439
1454
  /** Every backend, run against one parsed policy. The order is the order the matrix prints in. */
1440
1455
  export function backends(parsed, options = {}) {
1456
+ // **Provenance rides in the Claude Code artifact only, and that is an honest limit rather than an
1457
+ // oversight.** `githubRuleset` emits to a fixed external schema that has nowhere to carry a
1458
+ // `$portulan` block; its provenance is the one line it can carry, in `name`. Both backends compile
1459
+ // from ONE root plan per run, so the recorded origins describe the ruleset's inputs too — they are
1460
+ // simply not readable in its file. Stated here and in `../.portulan/compile/README.md` (#264).
1441
1461
  return [claudeCode(parsed, options), githubRuleset(parsed, options)];
1442
1462
  }
1443
1463
 
@@ -1626,6 +1646,103 @@ export function rootPlan(workspaceDir, manifest, { named = [], namedGiven = null
1626
1646
  * them. What it depends on instead is `parse`, which validates every composed rule exactly as it
1627
1647
  * validates a hand-written one, so a malformed fragment is refused by the same code either way.
1628
1648
  */
1649
+ /**
1650
+ * How a resolved pack's ORIGIN is recorded in an emitted artifact — a deliberate collapse of the
1651
+ * resolver's three tags into two, and the collapse is the load-bearing part.
1652
+ *
1653
+ * `resolutionRoots` tags roots `named | derived | discovered`. Recording that raw would be wrong in a
1654
+ * way that turns this whole feature into a liability: the PINNED rail spells its root
1655
+ * (`--pack-root packs`, tagged `named`) while a bare run derives the same directory (tagged
1656
+ * `derived`). Two documented-correct spellings of the same world would emit different bytes, and
1657
+ * `verify/compile.sh` would go red on a tree nothing was wrong with — a per-machine false red, which
1658
+ * is worse than the silent hazard #264 is about.
1659
+ *
1660
+ * So: `discovered` iff the answering root came from discovery — **which is a statement about where the
1661
+ * root was FOUND, not about where it sits.** Discovery reads a record under `CLAUDE_CONFIG_DIR`, and a
1662
+ * hermetic run can point that inside the tree, so a discovered root is not necessarily an external one;
1663
+ * the docblock said "a directory outside this repository" until review caught the conflation, and the
1664
+ * distinction is this field's whole subject rather than a quibble — it is why `outside-tree` exists as
1665
+ * a separate answer below. Otherwise `tree`, when the root is one the repository itself yields. A NAMED root that is not under the workspace root is neither, and is recorded as
1666
+ * `outside-tree` rather than flattened into `tree`, because calling somebody's `--pack-root /elsewhere`
1667
+ * "the tree" would be this field's first lie.
1668
+ */
1669
+ export function recordedOrigin(root, plan, workspaceRoot) {
1670
+ const same = (a, b) => path.resolve(a) === path.resolve(b);
1671
+ const tagged = (plan?.origins ?? []).find((o) => same(o.root, root));
1672
+ if (tagged?.origin === "discovered") return "discovered";
1673
+ // **`derived` and `named` take the SAME inside-the-repository test**, and the first draft trusted
1674
+ // `derived` on its tag alone. It is derived from the manifest's `tree`, which is a string a human
1675
+ // edits: `tree: "../../elsewhere"` resolves a derived root of `/elsewhere/packs` — measured — and
1676
+ // that would have been recorded as `tree`. A tag says where a root CAME FROM; only the path says
1677
+ // whether it is in this repository, and this field's whole job is the second question. Copilot.
1678
+ //
1679
+ // Inside INCLUDES being the repository itself, which the first draft called `outside-tree` on a
1680
+ // `rel` of `""`. By this function's own standard that was the field's first lie.
1681
+ //
1682
+ // **Both sides resolved through `realpathSync`**, because a lexical comparison makes two spellings
1683
+ // of one directory disagree: `--workspace <alias> --pack-root <realpath>` recorded `outside-tree`
1684
+ // for a root plainly inside the tree, which would emit a different artifact for an identical world
1685
+ // — the per-machine false red this whole design refuses. Measured. It is the same trap #220's
1686
+ // second arm repaired in the Stop-gate's divergence sentence, and it is worth stating twice
1687
+ // because the two sites were written a day apart and neither inherits the other's guard.
1688
+ const real = (dir) => {
1689
+ try {
1690
+ return fs.realpathSync(path.resolve(dir));
1691
+ } catch {
1692
+ return path.resolve(dir);
1693
+ }
1694
+ };
1695
+ // **`isInside`, not a fourth hand-rolled spelling.** `!rel.startsWith("..")` calls a directory
1696
+ // literally named `..foo` outside the tree — and this repository has already paid for that exact
1697
+ // reasoning once: `./index.mjs`'s docblock records it as the ninth fail-open found in this
1698
+ // scaffolding, and *the first one written by the change that cites the class*. This was the second.
1699
+ // The helper is exported precisely so a third copy cannot drift into it. Copilot.
1700
+ return isInside(real(workspaceRoot), real(root)) ? "tree" : "outside-tree";
1701
+ }
1702
+
1703
+ /**
1704
+ * Two copies of one pack answered, and this says what differs between them.
1705
+ *
1706
+ * **One carrier, because the comparison has already been wrong twice.** `doctor` grew this first and
1707
+ * its history is the argument for not writing a fourth: a first spelling projected `[id, tier, action]`
1708
+ * and so read a copy differing in `reason` as agreeing — while `composeFragments` pushes the WHOLE
1709
+ * fragment, so those differences do reach the compiled policy; a second compared the whole fragment
1710
+ * but claimed *byte-identical*, which `JSON.stringify` of a parsed value cannot promise. This is that
1711
+ * third, correct version, moved here so `doctor` and `compile` cannot drift apart about what a shadow
1712
+ * IS — proposal `0020`'s rule, applied to a function rather than to a sentence.
1713
+ *
1714
+ * Parsed and key-order-normalised is the RIGHT comparison: reformatting a `pack.json` changes nothing
1715
+ * about what the pack contributes, so reporting it would be noise.
1716
+ */
1717
+ export function packDifferences(mine, other) {
1718
+ const canonical = (v) =>
1719
+ Array.isArray(v)
1720
+ ? v.map(canonical)
1721
+ : v && typeof v === "object"
1722
+ ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])]))
1723
+ : v;
1724
+ const frag = (m) => JSON.stringify(canonical(m?.contributes?.gates ?? []));
1725
+ const mineV = mine?.portulan?.version ?? "no version";
1726
+ const otherV = other?.portulan?.version ?? "no version";
1727
+ const differs = [];
1728
+ if (mineV !== otherV) differs.push(`version ${mineV} against the tree's ${otherV}`);
1729
+ if (frag(mine) !== frag(other)) differs.push("gate fragments that differ once parsed");
1730
+ return differs;
1731
+ }
1732
+
1733
+ /**
1734
+ * The non-discovered copy of a pack that a discovered root answered for — or `null` when there is no
1735
+ * shadow. `originOf` maps a root to its tag; pass `plan.origins`' lookup.
1736
+ *
1737
+ * Only a **discovered** answer can be shadowed in the sense that matters: a named root replaces the
1738
+ * derived one, so nothing is behind it, and a derived answer is the tree's own copy already.
1739
+ */
1740
+ export function shadowedCopy(name, answeringOrigin, roots, originOf) {
1741
+ if (answeringOrigin !== "discovered") return null;
1742
+ const behind = resolvePack(name, roots.filter((r) => originOf(r) !== "discovered"));
1743
+ return behind.dir ? behind : null;
1744
+ }
1745
+
1629
1746
  export function packContributions(workspaceRoot, workspaceDir = ".portulan", options = {}) {
1630
1747
  const base = path.join(workspaceRoot, workspaceDir);
1631
1748
  let manifest;
@@ -1666,6 +1783,83 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
1666
1783
  continue;
1667
1784
  }
1668
1785
  const packManifest = readJson(found.manifest, `the pack manifest for \`${found.name}\``);
1786
+ // **A shadowed pack is a could-not-run, not a resolution** (#316). Two roots answered for one
1787
+ // declared name and the invocation does not say which the caller meant, so this compiler has
1788
+ // no honest way to pick — and unlike `doctor`, which reports and never grades because an
1789
+ // install state is a fact about a machine rather than a verdict about the repository, this one
1790
+ // does not report: it EMITS. Picking silently wrote the discovered copy's fragments into the
1791
+ // artifact while `verify/compile.sh` read the tree's, which on this project's own host meant a
1792
+ // `git commit --no-verify` matcher that the tree had deliberately removed as false coverage.
1793
+ //
1794
+ // **Refused at RESOLUTION, so `--check` and the write path inherit one rule.** Refusing only
1795
+ // on write would make one tool answer one ambiguity two ways: the check would go on adopting
1796
+ // the discovered world and exit 1 — asserting that the *repository* drifted when it had not.
1797
+ // An exit code is the machine-read API, and a false 1 is a false verdict about the tree.
1798
+ //
1799
+ // **Every unasked shadow refuses, including one whose manifests agree**, which reads as
1800
+ // over-strict until you follow the bytes: `recordedOrigin` tags the answering root into
1801
+ // `$portulan.packs[].origin`, so a discovered answer emits `discovered` where the rail's
1802
+ // artifact says `tree`. Agreement in the manifests is therefore not agreement in the artifact,
1803
+ // and a carve-out for it would ship a compile that still reds the recipe it was meant to
1804
+ // reconcile with. The message distinguishes the two cases; the refusal does not.
1805
+ //
1806
+ // **Only where the caller did not choose.** `--pack-root auto` is discovery ELECTED, and
1807
+ // electing it is the choice this refusal exists to ask for. Both spellings are offered in the
1808
+ // message, because naming only the tree would make the refusal decide the question it claims
1809
+ // to be handing back.
1810
+ //
1811
+ // **The `named` half of this guard is defence in depth, and that was measured rather than
1812
+ // assumed.** Forcing the condition to `true` — refuse unconditionally — leaves the named-root
1813
+ // case still passing: a named root REPLACES the derived one, so the plan carries no discovered
1814
+ // root, `shadowedCopy` finds nothing behind, and there is no shadow to refuse. The protection
1815
+ // is structural. The clause stays because it states the intent at the site where a future
1816
+ // change to `resolutionRoots` could quietly make it load-bearing.
1817
+ if (!(options.forced ?? false) && plan.source !== "named") {
1818
+ const behind = shadowedCopy(found.name, recordedOrigin(found.root, plan, workspaceRoot), roots, (r) =>
1819
+ (plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin,
1820
+ );
1821
+ if (behind) {
1822
+ let other = null;
1823
+ try {
1824
+ other = JSON.parse(fs.readFileSync(behind.manifest, "utf8"));
1825
+ } catch (cause) {
1826
+ throw new CompileError(
1827
+ `\`${found.name}\` resolved under the root ${found.root} while the root ` +
1828
+ `${path.relative(workspaceRoot, behind.root)} also carries it, ` +
1829
+ `and that second copy could not be read (${cause.message}) — so which one this would compile from could not be established. ` +
1830
+ "Name the root: `--pack-root packs` compiles from the tree, `--pack-root auto` from the installed copy.",
1831
+ );
1832
+ }
1833
+ const differs = packDifferences(packManifest, other);
1834
+ // **Both ROOTS by path — the roots, not the pack directories.** `resolvePack` returns
1835
+ // both, and a first cut printed `dir` (`<root>/<category>/<pack>`) while calling it a
1836
+ // root: a diagnostic that mislabels the thing it names, in a message whose own claim is
1837
+ // to name both roots. The root is also the more useful of the two, because it is what a
1838
+ // reader types back into `--pack-root`. A refusal naming only one hands back a choice
1839
+ // while withholding half of what it is between. Printing the absolute path is safe in a
1840
+ // way it is not in `$portulan.packs`: this is stderr, read once by a human, never a
1841
+ // tracked artifact, so #264's origins-never-paths rule (which exists to stop a
1842
+ // machine-dependent path being committed) does not reach it. `doctor` already prints it
1843
+ // in its resolves-from note, so the two tools now name the same two directories.
1844
+ //
1845
+ // **"Discovered on this host", never "outside this repository".** Discovery reads a
1846
+ // record under `CLAUDE_CONFIG_DIR`, which a hermetic run can point INSIDE the tree — so
1847
+ // a discovered root is not necessarily an external one, and the message must not assert
1848
+ // a location it has not tested. `recordedOrigin` keeps `discovered` and `outside-tree`
1849
+ // as separate answers for exactly this reason: where a root came from and where it sits
1850
+ // are different questions, and the path is printed right there for a reader who wants
1851
+ // the second one. Caught by review after the first cut conflated them.
1852
+ throw new CompileError(
1853
+ `\`${found.name}\` is SHADOWED — it resolved under ${found.root}, a root discovered on this ` +
1854
+ `host, while the root ${path.relative(workspaceRoot, behind.root)} also carries it. ` +
1855
+ (differs.length
1856
+ ? `They differ by ${differs.join(" and ")}, so the two roots compile to different policies. `
1857
+ : "Their manifests agree, but the emitted artifact still records which root answered, so the two roots compile to different bytes. ") +
1858
+ "Refusing to pick: name the root instead — `--pack-root packs` compiles from the tree, which is what " +
1859
+ "`verify/compile.sh` checks, and `--pack-root auto` compiles from the installed copy.",
1860
+ );
1861
+ }
1862
+ }
1669
1863
  const fragments = packManifest?.contributes?.gates;
1670
1864
  // Checked here rather than left to `composeFragments` iterating it: a manifest is a file a
1671
1865
  // human edits, `doctor` may not have run (the two tools have no ordering between them), and a
@@ -1678,7 +1872,18 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
1678
1872
  `Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
1679
1873
  );
1680
1874
  }
1681
- contributions.push({ pack: found.name, dir: found.dir, fragments: fragments ?? [] });
1875
+ contributions.push({
1876
+ pack: found.name,
1877
+ dir: found.dir,
1878
+ fragments: fragments ?? [],
1879
+ // **Provenance, recorded per pack**, so the artifact this compiles into can say what
1880
+ // compiled it (#264). `version` comes from `portulan.version` — the pack manifest's own
1881
+ // location for it, not a top-level `version` — and its ABSENCE is recorded as such rather
1882
+ // than as a blank, because "this pack declares no version" and "I did not look" are
1883
+ // different facts and only one of them is the pack's.
1884
+ origin: recordedOrigin(found.root, plan, workspaceRoot),
1885
+ version: typeof packManifest?.portulan?.version === "string" ? packManifest.portulan.version : null,
1886
+ });
1682
1887
  }
1683
1888
  // `plan` travels with the result because every consumer prints it: which source won, and the path
1684
1889
  // it won with, is what tells a feed resolution from a local one.
@@ -2106,7 +2311,14 @@ export function run(argv, options = {}) {
2106
2311
  // fails open, silently. That is the exact defect class this whole change exists to close,
2107
2312
  // reintroduced through the cross-compile path. Found by the pre-commit checkpoint, which
2108
2313
  // demonstrated it rather than reasoning about it.
2109
- const columns = backends(parsed, { source, root: path.resolve(workspaceRoot), workspaceDir });
2314
+ const columns = backends(parsed, {
2315
+ source,
2316
+ root: path.resolve(workspaceRoot),
2317
+ workspaceDir,
2318
+ // What resolved each pack, carried to the artifact so the file the rail compares says
2319
+ // which world compiled it (#264).
2320
+ packProvenance: contributions,
2321
+ });
2110
2322
 
2111
2323
  // Printed before the backends, because a rule's provenance changes how its compiled line reads
2112
2324
  // — and an unresolvable pack is a declaration the workspace believes it composed.
@@ -2162,7 +2374,34 @@ export function run(argv, options = {}) {
2162
2374
  continue;
2163
2375
  }
2164
2376
  if (current !== column.artifact.text) {
2165
- say(`RED — ${file} has drifted from ${policyFile}. Recompile.`);
2377
+ // **Name the origin difference AT THE MOMENT OF FAILURE** (#264). A drift caused by
2378
+ // a shadowed pack was previously reported as a diff nobody could reproduce from the
2379
+ // files in front of them, because the deciding input was a directory outside the
2380
+ // repository — and the remedy the sentence prescribed, "Recompile", is the very
2381
+ // unpinned act that caused it. Read DEFENSIVELY: the artifact on disk is a file a
2382
+ // human may have edited, so anything unparseable leaves the plain sentence standing
2383
+ // rather than turning a drift report into a crash.
2384
+ let why = "";
2385
+ try {
2386
+ const onDisk = JSON.parse(current)?.$portulan?.packs;
2387
+ const mine = JSON.parse(column.artifact.text)?.$portulan?.packs;
2388
+ if (Array.isArray(onDisk) && Array.isArray(mine)) {
2389
+ const key = (p) => `${p.origin ?? "?"} ${p.version ?? "no version"}`;
2390
+ const byName = new Map(mine.map((p) => [p.pack, p]));
2391
+ const moved = onDisk
2392
+ .filter((p) => byName.has(p.pack) && key(byName.get(p.pack)) !== key(p))
2393
+ .map((p) => `\`${p.pack}\` was compiled from the ${key(p)} copy; this check reads the ${key(byName.get(p.pack))} one`);
2394
+ if (moved.length) {
2395
+ why =
2396
+ `\n ${moved.join("\n ")}` +
2397
+ "\n That is a different world, not a stale file: recompile with the root this check uses" +
2398
+ `\n — \`node cli/compile.mjs --workspace . --pack-root packs\` — rather than bare, or the drift returns.`;
2399
+ }
2400
+ }
2401
+ } catch {
2402
+ // Unparseable or hand-edited. The plain sentence below is still true.
2403
+ }
2404
+ say(`RED — ${file} has drifted from ${policyFile}. Recompile.${why}`);
2166
2405
  drifted += 1;
2167
2406
  }
2168
2407
  }
package/cli/doctor.mjs CHANGED
@@ -43,7 +43,16 @@ import { fileURLToPath, pathToFileURL } from "node:url";
43
43
  // it looks like enforcement that quietly stopped covering something. Same reasoning that has
44
44
  // ./gate.mjs import the matcher instead of writing a second one. Zero
45
45
  // dependencies on both sides, so nothing is added to what this tool needs to run.
46
- import { parse, backends, resolvePack, rootPlan, packContributions, composeFragments } from "./compile.mjs";
46
+ import {
47
+ parse,
48
+ backends,
49
+ resolvePack,
50
+ rootPlan,
51
+ packContributions,
52
+ composeFragments,
53
+ packDifferences,
54
+ shadowedCopy,
55
+ } from "./compile.mjs";
47
56
  // The containment test the memory-index siting rule turns on, imported for the reason directly
48
57
  // above: the copy that used to live here drifted into the identical fail-open as the original.
49
58
  import { isInside, recordType } from "./index.mjs";
@@ -1815,6 +1824,50 @@ export async function inspect(workspaceDir, options = {}) {
1815
1824
  "packs",
1816
1825
  `\`${name}\` resolves${from} to ${path.relative(dir, found.dir)} and validates against Pack Definition ${manifest.portulan.pack} — contributes ${parts.length ? parts.join(", ") : "nothing"}`,
1817
1826
  );
1827
+
1828
+ // **A SHADOWED pack is reported, and the report says WHAT DIFFERS** (#264). Resolution is
1829
+ // first-match-wins and discovered roots come first, so where the host carries a copy of a
1830
+ // pack the tree also carries, the installed one answers and nothing said so. Silence about
1831
+ // the shadowed copy is the part that makes the failure unreproducible: the compiled artifact
1832
+ // moves, the rail reds, and the deciding input is a directory outside the repository.
1833
+ //
1834
+ // A REPORT, never a verdict — an installed pack shadowing a tree copy is a fact about the
1835
+ // machine, and this tool must not turn a fact about the machine into a verdict about the
1836
+ // repository (the boundary this file already states for discovered roots).
1837
+ {
1838
+ const shadowed = shadowedCopy(name, origin, roots, (r) => originOf.get(r)) ?? { dir: null };
1839
+ if (shadowed.dir) {
1840
+ let other = null;
1841
+ try {
1842
+ other = JSON.parse(fs.readFileSync(shadowed.manifest, "utf8"));
1843
+ } catch (cause) {
1844
+ // Could-not-compare, in so many words. A shadow we cannot read is not a shadow
1845
+ // we can call harmless, and saying nothing here would be the silence this whole
1846
+ // report exists to end.
1847
+ report(
1848
+ "packs",
1849
+ `\`${name}\` is SHADOWED — the tree-derived root also carries it at ${path.relative(dir, shadowed.dir)}, and that copy could not be read (${cause.message}), so what differs could not be compared`,
1850
+ );
1851
+ }
1852
+ if (other) {
1853
+ // **The comparison lives in `compile.mjs`, and this calls it.** It grew here,
1854
+ // and its two earlier spellings each claimed more than they compared — a
1855
+ // projection over `[id, tier, action]` that read a copy differing in `reason`
1856
+ // as agreeing, and a *byte-identical* claim `JSON.stringify` of a parsed value
1857
+ // cannot make. `compile` now has to answer the same question in order to refuse
1858
+ // a shadow it cannot honestly pick between (#316), and a second copy of a
1859
+ // comparison already corrected twice is exactly proposal `0020`'s defect. The
1860
+ // rationale for parsed-and-key-sorted moved with the code.
1861
+ const differs = packDifferences(manifest, other);
1862
+ report(
1863
+ "packs",
1864
+ `\`${name}\` is SHADOWED — the installed copy answered and the tree-derived root also carries it at ` +
1865
+ `${path.relative(dir, shadowed.dir)}; ${differs.length ? `they differ by ${differs.join(" and ")}` : "the two agree today"}. ` +
1866
+ "An unpinned `compile` or `recipe-set` REFUSES this rather than picking, and so does `index` for a workspace declaring a scope index — name the root: `--pack-root packs` for the tree, which is what the recipes check",
1867
+ );
1868
+ }
1869
+ }
1870
+ }
1818
1871
  }
1819
1872
  }
1820
1873
 
package/cli/index.mjs CHANGED
@@ -74,7 +74,10 @@ import { pathToFileURL } from "node:url";
74
74
  // compiler and `doctor` already resolve it. A second resolver here would be a second answer to
75
75
  // "which pack is that" — and issue #74 already records what a memory store cost this project when
76
76
  // three copies decided what counted as a record.
77
- import { resolvePack, rootPlan } from "./compile.mjs";
77
+ // `shadowedCopy` rides along with the two this file already imports: the predicate for *is this pack
78
+ // shadowed* is one carrier by #316's own argument, and a second spelling here would be the defect that
79
+ // change was filed against, committed one tool over.
80
+ import { resolvePack, rootPlan, shadowedCopy } from "./compile.mjs";
78
81
  // Host plugin-cache discovery (#123) — imported, never re-implemented, for the reason above.
79
82
  import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
80
83
 
@@ -101,34 +104,14 @@ const NOT_A_RECORD = new Set(["README.md"]);
101
104
 
102
105
  const KB = 1024;
103
106
 
104
- /**
105
- * Is `child` inside `parent`? Exported because `doctor` asks the same question about the same two
106
- * paths, and the obvious spelling — `!path.relative(parent, child).startsWith("..")` — is wrong in a
107
- * way that fails OPEN.
108
- *
109
- * A name beginning with `..` is an ordinary filename, not a traversal: `path.relative` of
110
- * `memory/..index.md` against `memory/` is `..index.md`, which `startsWith("..")` calls *outside*.
111
- * Measured end to end before this was written — an index declared at `memory/..index.md` was written
112
- * into the store, reported `ok`, and `doctor` then counted it as a second record, reporting it for
113
- * stating no retirement condition. That is precisely the outcome the siting rule exists to prevent,
114
- * in the check chosen over a filename exemption **because** an exemption would be a door any record
115
- * could walk through. The door was in the containment test. Found by Copilot on #72, in the
116
- * suppressed half of the round; ninth fail-open found in this repository's scaffolding, and the
117
- * first one written by the change that cites the class.
118
- *
119
- * Only `..` exactly, or `..` followed by a separator, means outside. An absolute result means the
120
- * two paths share no root and cannot contain one another.
121
- *
122
- * It lives here and `doctor` imports it, on the reasoning that already put `compile`'s accounting
123
- * behind one import: two copies of a rule drift, and these two copies drifted into the identical
124
- * defect before either had shipped.
125
- */
126
- export function isInside(parent, child) {
127
- const rel = path.relative(parent, child);
128
- if (rel === "") return true;
129
- if (path.isAbsolute(rel)) return false;
130
- return rel !== ".." && !rel.startsWith(`..${path.sep}`);
131
- }
107
+ // `isInside` moved to `./inside.mjs` and is re-exported here, so `./doctor.mjs` and every other
108
+ // importer is untouched. It left because `./compile.mjs` needs it too and this file imports FROM
109
+ // `compile.mjs` — taking it from here would have closed an import cycle, which this repository has
110
+ // already had exit 13 in silence once. The predicate was not copied: one implementation, no new edges.
111
+ // Imported AND re-exported: a bare `export … from` re-exports without binding the name in this
112
+ // module's own scope, and this file calls `isInside` itself — 104 tests said so immediately.
113
+ import { isInside } from "./inside.mjs";
114
+ export { isInside };
132
115
 
133
116
  // ===========================================================================================
134
117
  // Titles
@@ -455,7 +438,12 @@ export function readScopes(dir, workspace, options = {}) {
455
438
  const plan = rootPlan(dir, workspace, {
456
439
  named: options.packRoots ?? [],
457
440
  namedGiven: options.packRoots !== undefined && options.packRoots !== null,
458
- discovery: () => discoverPackRoots(),
441
+ // **Injectable, defaulting to the real reader.** It was hardcoded to `discoverPackRoots()`,
442
+ // which meant no case could exercise this tool's discovery path without the machine's own
443
+ // plugin cache deciding the outcome — so the shadow refusal below would have been testable
444
+ // only by arranging a real install. `recipe-set`'s `resolverFor` already takes the thunk;
445
+ // this is the same seam, and the default keeps every existing caller byte-identical.
446
+ discovery: options.discovery ?? (() => discoverPackRoots()),
459
447
  forced: options.discoverPacks === true,
460
448
  });
461
449
  if (plan.refusal) throw new IndexError(plan.refusal);
@@ -478,6 +466,53 @@ export function readScopes(dir, workspace, options = {}) {
478
466
  unresolved.push(found);
479
467
  continue;
480
468
  }
469
+ // **A shadowed pack is refused here too** (#318), on the same ground `compile` refuses it and
470
+ // NOT on the same argument — the difference matters enough to write down, because copying
471
+ // compile's sentence here would state something this file spends four lines denying.
472
+ //
473
+ // The ground is the ambiguity: two roots answered for one declared name, the invocation does
474
+ // not say which the caller meant, and this tool goes on to digest the answering copy's memory
475
+ // scope into a **committed** index. Picking silently makes a tracked artifact a function of
476
+ // what happens to be installed.
477
+ //
478
+ // What does NOT transfer is compile's bytes argument. Compile refuses even an agreeing shadow
479
+ // because `recordedOrigin` tags the answering root into its artifact; this index records no
480
+ // such thing, by the design stated above and in this tool's own help. So an agreeing shadow
481
+ // here really would produce identical bytes — and the refusal is still unconditional, because
482
+ // the alternative is a per-tool predicate for *which* differences reach the digest. That
483
+ // predicate is narrower than "the manifests differ" and would silently widen with every key a
484
+ // later Pack Definition adds: fail-open by construction, and a third carrier of a comparison
485
+ // this repository has already had wrong twice.
486
+ //
487
+ // **Only where the caller did not choose**, exactly as in `compile`: a named root replaces the
488
+ // derived one, and `auto` is discovery elected.
489
+ if (!(options.discoverPacks === true) && plan.source !== "named") {
490
+ // Path-resolved, matching `compile`. `resolutionRoots` builds `origins` and `roots` from
491
+ // the same strings today, so an exact-string lookup is equal — but two resolve-equal
492
+ // spellings of one directory would make `compile` refuse where this passed, and three
493
+ // tools answering one question two ways is the shape this whole family is about.
494
+ const originAt = (r) => (plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin;
495
+ const behind = shadowedCopy(name, originAt(found.root), roots, originAt);
496
+ if (behind) {
497
+ // **Relative to the REPOSITORY, so the path matches the flag the reader will type.**
498
+ // `dir` is the WORKSPACE directory, so relativising against it printed `../packs`
499
+ // beside a remedy reading `--pack-root packs` — one sentence disagreeing with itself.
500
+ // A first fix printed the absolute root, which made the sentence self-consistent and
501
+ // still showed a path nobody would type. `compile` and `recipe-set` relativise against
502
+ // the repository root; this derives the same root from the manifest's own `tree`, and
503
+ // falls back to the absolute path where a workspace declares none, since a workspace
504
+ // with no `tree` has no repository for a relative path to be relative to.
505
+ const repoRoot = typeof workspace?.tree === "string" && workspace.tree.trim() ? path.resolve(dir, workspace.tree) : null;
506
+ const behindShown = repoRoot ? path.relative(repoRoot, behind.root) : behind.root;
507
+ throw new IndexError(
508
+ `\`${name}\` is SHADOWED — it resolved under ${found.root}, a root discovered on this host, ` +
509
+ `while the root ${behindShown} also carries it. This index digests the ` +
510
+ `answering copy's memory scope, so which root answered decides what a committed file says. ` +
511
+ "Refusing to pick: name the root — `--pack-root packs` for the tree, which is what " +
512
+ "`verify/index.sh` checks, or `--pack-root auto` for the installed copy.",
513
+ );
514
+ }
515
+ }
481
516
 
482
517
  let manifest;
483
518
  try {
@@ -759,9 +794,24 @@ export function inspect(dir, { write = false, packRoots: extraRoots, discoverPac
759
794
  const findings = [];
760
795
  const fail = (series, check, message) => findings.push({ severity: "fail", series, check, message });
761
796
 
762
- const memory = judgeMemory(dir, workspace, { write, fail });
763
- const handoffs = judgeHandoffs(dir, workspace, { write, fail });
764
- const scopes = judgeScopes(dir, workspace, { write, fail, packRoots: extraRoots, discoverPacks });
797
+ // **The remedy a finding prints must be runnable on the machine that printed it** (#318). These
798
+ // findings surface through the PINNED `verify/index.sh` run — so on a pack developer’s host, where
799
+ // a declared pack is both installed and in the tree, a bare `node cli/index.mjs <dir>` now exits 2
800
+ // with the SHADOWED refusal instead of regenerating. That is the same defect this change repaired
801
+ // in `verify/index.sh`’s own comment, left standing one layer down: one rule, two sites, repaired
802
+ // at one — which is proposal `0020`’s shape and what the pre-commit sweep exists to catch.
803
+ //
804
+ // Echoing the caller’s flags is provably runnable: a finding only prints under an invocation that
805
+ // reached judgment, so that invocation’s own spelling resolved. A hardcoded `--pack-root packs`
806
+ // would be wrong for an adopter whose packs sit elsewhere.
807
+ const remedyFlags = [
808
+ ...(Array.isArray(extraRoots) ? extraRoots.map((r) => ` --pack-root ${r}`) : []),
809
+ ...(discoverPacks ? [" --pack-root auto"] : []),
810
+ ].join("");
811
+
812
+ const memory = judgeMemory(dir, workspace, { write, fail, remedyFlags });
813
+ const handoffs = judgeHandoffs(dir, workspace, { write, fail, remedyFlags });
814
+ const scopes = judgeScopes(dir, workspace, { write, fail, packRoots: extraRoots, discoverPacks, remedyFlags });
765
815
 
766
816
  return {
767
817
  dir,
@@ -814,7 +864,7 @@ function siteOutside(dir, declaredPath, slot, word) {
814
864
  * in `./doctor.mjs`, `./new.mjs`, `./init.mjs`, `./plugin-lint.mjs` and `./compile.mjs` — this was the
815
865
  * one read in the repository that did not carry it.
816
866
  */
817
- function compareOrWrite({ dir, declaredPath, indexPath, expected, write, series, source, fail }) {
867
+ function compareOrWrite({ dir, declaredPath, indexPath, expected, write, series, source, fail, remedyFlags = "" }) {
818
868
  if (write) {
819
869
  try {
820
870
  fs.mkdirSync(path.dirname(indexPath), { recursive: true });
@@ -846,14 +896,14 @@ function compareOrWrite({ dir, declaredPath, indexPath, expected, write, series,
846
896
  }
847
897
 
848
898
  if (actual === null) {
849
- fail(series, "index", `${declaredPath} is declared and absent — run \`node cli/index.mjs ${dir}\` to generate it`);
899
+ fail(series, "index", `${declaredPath} is declared and absent — run \`node cli/index.mjs${remedyFlags} ${dir}\` to generate it`);
850
900
  } else if (!actual.equals(Buffer.from(expected, "utf8"))) {
851
- fail(series, "index", `${declaredPath} is out of date against the ${source} — run \`node cli/index.mjs ${dir}\` to regenerate it`);
901
+ fail(series, "index", `${declaredPath} is out of date against the ${source} — run \`node cli/index.mjs${remedyFlags} ${dir}\` to regenerate it`);
852
902
  }
853
903
  }
854
904
 
855
905
  /** The memory store's index and its budgets. */
856
- function judgeMemory(dir, workspace, { write, fail }) {
906
+ function judgeMemory(dir, workspace, { write, fail, remedyFlags = "" }) {
857
907
  const memory = workspace.memory;
858
908
  if (!memory) return { declared: false, path: null, expected: null, budgets: 0 };
859
909
 
@@ -890,7 +940,7 @@ function judgeMemory(dir, workspace, { write, fail }) {
890
940
  if (broken) return { declared: true, path: indexPath, expected: null, budgets: 0 };
891
941
 
892
942
  const expected = render(workspace, store);
893
- compareOrWrite({ dir, declaredPath, indexPath, expected, write, series: "memory", source: "store", fail });
943
+ compareOrWrite({ dir, declaredPath, indexPath, expected, write, series: "memory", source: "store", fail, remedyFlags });
894
944
  const budgets = budgetFindings(memory, store, expected, fail);
895
945
 
896
946
  return { declared: true, path: indexPath, expected, budgets };
@@ -900,7 +950,7 @@ function judgeMemory(dir, workspace, { write, fail }) {
900
950
  * The handoff series' index. No budget — see spec/slots.md: every remedy a budget could ask for on an
901
951
  * append-only series is barred, so a rail here is one built to be broken.
902
952
  */
903
- function judgeHandoffs(dir, workspace, { write, fail }) {
953
+ function judgeHandoffs(dir, workspace, { write, fail, remedyFlags = "" }) {
904
954
  const declaredPath = workspace.handoffs?.index?.path;
905
955
  if (!declaredPath) return { declared: false, path: null, expected: null };
906
956
 
@@ -935,7 +985,7 @@ function judgeHandoffs(dir, workspace, { write, fail }) {
935
985
  if (broken) return { declared: true, path: indexPath, expected: null };
936
986
 
937
987
  const expected = renderHandoffIndex(workspace, series);
938
- compareOrWrite({ dir, declaredPath, indexPath, expected, write, series: "handoffs", source: "series", fail });
988
+ compareOrWrite({ dir, declaredPath, indexPath, expected, write, series: "handoffs", source: "series", fail, remedyFlags });
939
989
 
940
990
  return { declared: true, path: indexPath, expected };
941
991
  }
@@ -954,7 +1004,7 @@ function judgeHandoffs(dir, workspace, { write, fail }) {
954
1004
  * that sweep the layer would accept anything at all and the positive control would only ever examine the
955
1005
  * ones it already expected.
956
1006
  */
957
- function judgeScopes(dir, workspace, { write, fail, packRoots: extraRoots, discoverPacks }) {
1007
+ function judgeScopes(dir, workspace, { write, fail, packRoots: extraRoots, discoverPacks, remedyFlags = "" }) {
958
1008
  const declaredPath = workspace.personas?.index?.path;
959
1009
  if (!declaredPath) return { declared: false, path: null, expected: null };
960
1010
 
@@ -1064,7 +1114,7 @@ function judgeScopes(dir, workspace, { write, fail, packRoots: extraRoots, disco
1064
1114
  }
1065
1115
 
1066
1116
  const expected = renderScopeIndex(workspace, series);
1067
- compareOrWrite({ dir, declaredPath, indexPath, expected, write, series: "scopes", source: "packs this workspace composes", fail });
1117
+ compareOrWrite({ dir, declaredPath, indexPath, expected, write, series: "scopes", source: "packs this workspace composes", fail, remedyFlags });
1068
1118
 
1069
1119
  // The landing itself, and it happens after the index is written rather than before: if the render
1070
1120
  // could not be derived there is nothing to land, and a directory created for a scope no index
package/cli/inside.mjs ADDED
@@ -0,0 +1,38 @@
1
+ // Is one path inside another? One predicate, one file, no dependencies.
2
+ //
3
+ // **It lives alone because two large modules need it and must not import each other.** It began in
4
+ // `./index.mjs`, which `./doctor.mjs` imports; when `./compile.mjs` needed it too, importing it from
5
+ // there would have closed a cycle — `index.mjs` already imports `resolvePack`/`rootPlan` from
6
+ // `compile.mjs`. This repository has paid for a cycle once already: `../docs/plan.md`'s session log
7
+ // records **an import cycle exiting 13 in silence**, and three modules (`./plugin-lint.mjs:1369`,
8
+ // `./skills-set.mjs:48` and `:114`) carry comments explaining that they chose their import direction
9
+ // to avoid making one. Copying the predicate instead was the other option and is the one its own
10
+ // docblock forbids: two copies of this rule drifted into the identical defect before either shipped.
11
+ //
12
+ // So the rule keeps one implementation and gains no edges. `./index.mjs` re-exports it, so every
13
+ // existing importer is untouched.
14
+ //
15
+ // Found by Copilot on the change that closed #264, which had reached for the `index.mjs` export.
16
+
17
+ import path from "node:path";
18
+
19
+ /**
20
+ * Is `child` inside `parent`?
21
+ *
22
+ * **The obvious spelling — `!path.relative(parent, child).startsWith("..")` — is wrong, and it fails
23
+ * OPEN.** A name beginning with `..` is an ordinary filename, not a traversal: `path.relative` of
24
+ * `memory/..index.md` against `memory/` is `..index.md`, which `startsWith("..")` calls *outside*.
25
+ * Measured end to end before this was written — an index declared at `memory/..index.md` was written
26
+ * into the store, reported `ok`, and `doctor` then counted it as a second record. That is the ninth
27
+ * fail-open found in this repository's scaffolding, and the first one written by the change that
28
+ * cites the class; `recordedOrigin` in `./compile.mjs` was the second, which is why this moved here.
29
+ *
30
+ * Only `..` exactly, or `..` followed by a separator, means outside. An absolute result means the two
31
+ * paths share no root and cannot contain one another. A path is inside itself.
32
+ */
33
+ export function isInside(parent, child) {
34
+ const rel = path.relative(parent, child);
35
+ if (rel === "") return true;
36
+ if (path.isAbsolute(rel)) return false;
37
+ return rel !== ".." && !rel.startsWith(`..${path.sep}`);
38
+ }