mirror of
https://github.com/israerusan/unwritten.git
synced 2026-07-22 08:30:00 +00:00
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
240 lines
10 KiB
JavaScript
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 };
|