israerusan_unwritten/scripts/sync-shared.mjs
Israel Avila d3f0d0f278 Fix the NUL that made gaps.mjs undiffable, the slider combo that killed pairs, and a null-settings crash
src/core/gaps.mjs held a RAW NUL byte: pairKey wrote the pair separator as a literal 0x00
instead of an escape. Git classified the file as binary, so `git show --numstat` printed
"- -" and every change to it rendered as "Binary files differ" — the file holding all three
heuristics could never be reviewed in a diff again, and ripgrep/git grep skipped it. The
escape produces a byte-identical runtime string. test/source-hygiene.test.mjs asserts no
source file under src/, scripts/ or test/ contains a NUL, and pins the separator as a byte
no path can contain: with a printable one, ("A","B|C") and ("A|B","C") key the same and the
two pairs merge.

bridgeMaxLinks and maxOutlinksPerNote are independent sliders, so bridge >= cap was
reachable — at the cap slider's OWN MINIMUM, with the bridge slider at its default (6 vs 5).
Every note that survived the cap was then a bridge by definition, cooccur was never written
for any vault, and "Unexplained pairs" read "None found." forever with pairsConsidered: 0 and
no warning. The headline heuristic was silently off. resolveGapOptions now clamps the cap
above the bridge size; it only ever raises the cap, so a user's bridges stay bridges.

An explicit null string in data.json — a sync merge, a truncated write, a hand edit —
survived Object.assign (which only fills `undefined`), and refreshLicense's licenseKey.trim()
then threw inside onload(). The plugin did not degrade, it FAILED TO LOAD: no settings tab,
no commands, no way back without hand-editing JSON. Every string field is re-typed at load,
behind a `satisfies StringKeys<UnwrittenSettings>` list so a new string setting that is not
coerced is a compile error.

buildSnapshot retained every note's body for the whole run — ~160 MB of live JS strings on a
20k-note vault, on a plugin that ships isDesktopOnly:false and therefore runs on phones —
when only decision-tagged notes are ever read. Bodies are now retained only for decision
notes; word counts, which every note needs for stub hubs, are still computed for all.
snapshot.ts and gaps.mjs share one exported decisionSourceOf() so they cannot drift and
strand a decision note with no body (which would report it as unjustified — the worst
possible false positive).

community-plugins.json credited "saiken" while the manifest credits "Israel Avila". The
contract test pinned every other field, so the drift alarm had a hole in exactly the one that
disagreed; it now asserts author, and that the listed repo lives under the manifest's
authorUrl account.

Also re-synced src/shared from obsidian-plugin-core and committed src/shared/MANIFEST.sha256,
so `sync-shared --check` gates the vendored tree on a fresh clone.

Deliberately untouched: the Pro card (a later phase builds the features it advertises).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3f6GZ2A8FbZerhJEpJQBE
2026-07-14 12:35:40 -04:00

240 lines
10 KiB
JavaScript

#!/usr/bin/env node
/**
* Sync the vendored shared core in a plugin repo against the canonical
* obsidian-plugin-core checkout.
*
* Run from a plugin repo root (each plugin vendors a copy of this script):
* node scripts/sync-shared.mjs # copy canonical -> src/shared/
* node scripts/sync-shared.mjs --check # exit 1 if the vendored copy drifted
*
* Run from obsidian-plugin-core itself:
* node scripts/sync-shared.mjs --manifest # regenerate shared/MANIFEST.sha256
*
* THE DRIFT GATE HAS TO WORK WITHOUT THE CANONICAL REPO. This script used to exit 0 from
* `--check` whenever ../obsidian-plugin-core was absent — which is ALWAYS true in CI and in a
* fresh clone. So `npm test`'s drift check enforced nothing on any pull request, and a
* hand-edited `src/shared/verifyLicense.mjs` merged green: exactly the drift class this repo
* exists to prevent, and exactly how two divergent copies of the verifier happened.
*
* So `sync:shared` also writes MANIFEST.sha256 — the SHA-256 of every synced file — INTO the
* vendored tree, and `--check` validates the vendored files against that manifest when the
* canonical repo is not around. Editing a vendored file now fails the check on any machine,
* with or without the canonical checkout; editing the manifest to match is a deliberate act
* that shows up in the diff of a file whose header says not to.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
/** [canonical-relative, plugin-relative] pairs kept in sync. */
const FILES = [
["shared/verifyLicense.mjs", "src/shared/verifyLicense.mjs"],
["shared/verifyLicense.d.mts", "src/shared/verifyLicense.d.mts"],
["shared/suiteLicense.mjs", "src/shared/suiteLicense.mjs"],
["shared/suiteLicense.d.mts", "src/shared/suiteLicense.d.mts"],
["shared/revokedLicenses.mjs", "src/shared/revokedLicenses.mjs"],
["shared/revokedLicenses.d.mts", "src/shared/revokedLicenses.d.mts"],
["shared/licenseTransition.mjs", "src/shared/licenseTransition.mjs"],
["shared/licenseTransition.d.mts", "src/shared/licenseTransition.d.mts"],
["shared/featureGates.mjs", "src/shared/featureGates.mjs"],
["shared/featureGates.d.mts", "src/shared/featureGates.d.mts"],
["shared/hash.mjs", "src/shared/hash.mjs"],
["shared/hash.d.mts", "src/shared/hash.d.mts"],
["shared/engine/protocol.mjs", "src/shared/engine/protocol.mjs"],
["shared/engine/protocol.d.mts", "src/shared/engine/protocol.d.mts"],
["shared/engine/chunk.mjs", "src/shared/engine/chunk.mjs"],
["shared/engine/chunk.d.mts", "src/shared/engine/chunk.d.mts"],
["shared/engine/installPlan.mjs", "src/shared/engine/installPlan.mjs"],
["shared/engine/installPlan.d.mts", "src/shared/engine/installPlan.d.mts"],
["shared/engine/engineRelease.mjs", "src/shared/engine/engineRelease.mjs"],
["shared/engine/engineRelease.d.mts", "src/shared/engine/engineRelease.d.mts"],
["shared/engine/EngineHost.ts", "src/shared/engine/EngineHost.ts"],
["shared/engine/EngineBroker.ts", "src/shared/engine/EngineBroker.ts"],
["shared/engine/EngineInstallModal.ts", "src/shared/engine/EngineInstallModal.ts"],
["shared/signals/signalsAggregate.mjs", "src/shared/signals/signalsAggregate.mjs"],
["shared/signals/signalsAggregate.d.mts", "src/shared/signals/signalsAggregate.d.mts"],
["shared/signals/SignalStore.ts", "src/shared/signals/SignalStore.ts"],
["shared/signals/SignalsBroker.ts", "src/shared/signals/SignalsBroker.ts"],
["scripts/sync-shared.mjs", "scripts/sync-shared.mjs"],
];
/** Generated, not synced: it is the checksum OF the synced files. */
const MANIFEST = ["shared/MANIFEST.sha256", "src/shared/MANIFEST.sha256"];
const MANIFEST_HEADER = [
"# SHA-256 of every file in the vendored shared tree, keyed by its canonical path.",
"# GENERATED by scripts/sync-shared.mjs. Do not edit, and do not edit a shared file",
"# without re-running `npm run sync:shared` — `npm test` verifies this manifest even",
"# when the canonical obsidian-plugin-core checkout is not present (i.e. in CI).",
];
/** Compare content, not line endings — git autocrlf checkouts differ per OS. */
const normalize = (text) => text.replace(/\r\n/g, "\n");
const sha256 = (text) => crypto.createHash("sha256").update(normalize(text), "utf8").digest("hex");
/**
* @param {string} root repo root
* @param {(pair: string[]) => string} pick canonical-relative or plugin-relative
* @returns {string} the manifest body
*/
export function buildManifest(root, pick) {
const lines = [...MANIFEST_HEADER];
for (const pair of FILES) {
const file = path.join(root, pick(pair));
if (!fs.existsSync(file)) throw new Error(`sync-shared: cannot hash a missing file: ${file}`);
lines.push(`${sha256(fs.readFileSync(file, "utf8"))} ${pair[0]}`);
}
return lines.join("\n") + "\n";
}
const canonicalManifest = (root) => buildManifest(root, ([from]) => from);
const vendoredManifest = (root) => buildManifest(root, ([, to]) => to);
/** Write `body` to `file` only when it would change it. Returns true when it wrote. */
function writeIfChanged(file, body) {
const have = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
if (have !== null && normalize(have) === normalize(body)) return false;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body);
return true;
}
/** The vendored tree, checked against the manifest that shipped with it. No canonical repo
* needed — this is the check that actually runs on a pull request. */
function checkAgainstManifest(repoRoot) {
const manifestPath = path.join(repoRoot, MANIFEST[1]);
if (!fs.existsSync(manifestPath)) {
console.error(
`sync-shared: ${MANIFEST[1]} is missing — the vendored tree cannot be verified.\n` +
'Run "npm run sync:shared" against the canonical obsidian-plugin-core checkout.'
);
return 1;
}
let want;
try {
want = vendoredManifest(repoRoot);
} catch (error) {
console.error(String(error instanceof Error ? error.message : error));
return 1;
}
const have = fs.readFileSync(manifestPath, "utf8");
if (normalize(have) === normalize(want)) {
console.log(`sync-shared: vendored tree matches ${MANIFEST[1]} (${FILES.length} files).`);
return 0;
}
// Name the files, not just the mismatch — the whole point is to say what was hand-edited.
const expected = new Map(
normalize(have)
.split("\n")
.filter((line) => line && !line.startsWith("#"))
.map((line) => {
const [hash, file] = line.split(/\s+/);
return [file, hash];
})
);
let drifted = 0;
for (const [from, to] of FILES) {
const file = path.join(repoRoot, to);
const actual = fs.existsSync(file) ? sha256(fs.readFileSync(file, "utf8")) : "<missing>";
if (expected.get(from) !== actual) {
console.error(`sync-shared: DRIFT in ${to} (does not match ${MANIFEST[1]})`);
drifted++;
}
}
if (drifted === 0) {
console.error(`sync-shared: ${MANIFEST[1]} does not match the files it lists — it was edited by hand.`);
drifted = 1;
}
console.error(
`sync-shared: ${drifted} file(s) drifted from the canonical shared tree. Edit them in ` +
'obsidian-plugin-core and run "npm run sync:shared" — never in this repo.'
);
return 1;
}
function main(argv) {
const repoRoot = process.cwd();
const canonical = process.env.PLUGIN_CORE_PATH || path.resolve(repoRoot, "..", "obsidian-plugin-core");
const check = argv.includes("--check");
const manifestOnly = argv.includes("--manifest");
// Regenerate the canonical manifest in obsidian-plugin-core itself. Its own test suite
// runs this in --check mode, so a shared file edited without regenerating fails there too.
if (manifestOnly) {
const file = path.join(repoRoot, MANIFEST[0]);
const body = canonicalManifest(repoRoot);
if (check) {
const have = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
if (normalize(have) !== normalize(body)) {
console.error(`sync-shared: ${MANIFEST[0]} is stale — run "npm run manifest:shared".`);
return 1;
}
console.log(`sync-shared: ${MANIFEST[0]} is current.`);
return 0;
}
console.log(writeIfChanged(file, body) ? `sync-shared: wrote ${MANIFEST[0]}.` : `sync-shared: ${MANIFEST[0]} is current.`);
return 0;
}
if (!fs.existsSync(canonical)) {
// No canonical checkout: CI, or a fresh clone. This is the case the old script treated
// as "nothing to do", which is what made the gate decorative. The vendored manifest is
// the authority here.
if (check) return checkAgainstManifest(repoRoot);
console.log(`sync-shared: canonical repo not found at ${canonical} — nothing to copy.`);
return 0;
}
let drifted = 0;
for (const [from, to] of FILES) {
const src = path.join(canonical, from);
const dest = path.join(repoRoot, to);
if (!fs.existsSync(src)) {
console.error(`sync-shared: missing canonical file ${src}`);
return 1;
}
const want = fs.readFileSync(src, "utf8");
const have = fs.existsSync(dest) ? fs.readFileSync(dest, "utf8") : null;
if (have !== null && normalize(want) === normalize(have)) continue;
if (check) {
console.error(`sync-shared: DRIFT in ${to} (canonical: ${from})`);
drifted++;
} else {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, want);
console.log(`sync-shared: updated ${to}`);
}
}
if (check) {
if (drifted > 0) {
console.error(`sync-shared: ${drifted} file(s) drifted — run "npm run sync:shared" to update.`);
return 1;
}
// The files match the canonical tree; the manifest must match them too, or CI (which
// has no canonical tree and can only consult the manifest) would disagree with us.
return checkAgainstManifest(repoRoot);
}
// Keep both manifests honest: the canonical one, so obsidian-plugin-core's own test passes,
// and the vendored one, which is the only thing CI can check.
const body = canonicalManifest(canonical);
if (writeIfChanged(path.join(canonical, MANIFEST[0]), body)) {
console.log(`sync-shared: refreshed ${MANIFEST[0]} in the canonical repo`);
}
if (writeIfChanged(path.join(repoRoot, MANIFEST[1]), body)) console.log(`sync-shared: updated ${MANIFEST[1]}`);
console.log("sync-shared: done.");
return 0;
}
// Importable (obsidian-plugin-core's own manifest test uses buildManifest) and runnable.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(main(process.argv.slice(2)));
}
export { FILES, MANIFEST, main };