@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/README.md +15 -18
- package/cli/README.md +88 -125
- package/cli/compile.mjs +242 -3
- package/cli/doctor.mjs +54 -1
- package/cli/index.mjs +92 -42
- package/cli/inside.mjs +38 -0
- package/cli/recipe-set.mjs +42 -1
- package/cli/skills-set.mjs +166 -4
- package/cli/stop-gate.mjs +194 -24
- package/cli/version-carriers.mjs +211 -0
- package/core/operating/memory.md +7 -8
- package/core/operating/verification.md +4 -12
- package/package.json +1 -1
- package/packs/README.md +11 -21
- package/packs/tools/README.md +1 -4
- package/spec/README.md +19 -22
- package/spec/migrations/README.md +2 -3
- package/spec/slots.md +4 -9
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({
|
|
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, {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
+
}
|