@sleepy_panda_srl/portulan 0.1.1 → 0.1.3

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/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/init.mjs CHANGED
@@ -118,7 +118,10 @@ export const SLUG = (() => {
118
118
  const SPEC = "2.7";
119
119
 
120
120
  /** The gate-policy spec `cli/compile.mjs` reads. Checked by its own suite, not guessed at here. */
121
- const GATE_POLICY_SPEC = "2.2";
121
+ // Exported so `cli/new.test.mjs` can assert that the OTHER policy-generating carrier —
122
+ // `core/templates/gate-policy.md`, which `new gate-policy` emits — declares the same version.
123
+ // #329 was precisely these two disagreeing, with only this one exercised.
124
+ export const GATE_POLICY_SPEC = "2.2";
122
125
 
123
126
  const RESIDENCES = new Set(["in-repo", "pointer"]);
124
127
 
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
+ }