Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit b7f7a1195135bf9ec0f122f820985c4bbb582aa0
parent 0241df1b65171770f5c1d1bfe198a04e74b9e69e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 22 Sep 2026 16:06:02 -0400

tags: a site layer has no rules, rather than rules that do nothing

A rule is evaluated once at index time over records SHARED by every site that
carries the channel, so a rule written at the site layer has no per-site record
to put its hit in — it can only ever LOOK like it worked. The store's header
already said so while mergeTagDefs quietly appended one anyway, "harmless on a
published def": harmless is exactly how it reads to an operator who then waits
for a build that will never tag anything.

So the site coercion drops rules outright — on read, so a hand-edited
sites/<id>/tags.json cannot smuggle one in, and on write, so neither can a
caller — and mergeTagDefs loses its append branch. One place, covering every
writer, next to the label rule that is the layer's other asymmetry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/lib/curatedTags.test.ts | 40++++++++++++++++++++++++++++++++++++++--
Mcommon/lib/curatedTags.ts | 36++++++++++++++++++++++++------------
Mcommon/lib/curatedTagsStore.ts | 15+++++++++------
3 files changed, 71 insertions(+), 20 deletions(-)

diff --git a/common/lib/curatedTags.test.ts b/common/lib/curatedTags.test.ts @@ -226,7 +226,40 @@ test("sanitizeTagsConfig is idempotent", () => { // ─── mergeTagDefs ─── -test("mergeTagDefs overlays presentation fields and appends site rules", () => { +test("the site layer carries no rules, on read and on write", () => { + const raw = { + tags: [ + { + id: "eva-collab", + rules: [{ id: "site", kind: "metadata", pattern: "extra" }], + }, + { + id: "site-only", + label: "Site only", + rules: [{ id: "r1", kind: "caption", pattern: "x" }], + }, + ], + }; + const site = sanitizeTagsConfig(raw, { layer: "site" }); + // Not emptied — DROPPED: no `rules` key at all, so what a site writes has + // none and nothing downstream is ever handed a rule that cannot fire. + for (const def of site.tags) { + assert.equal(Object.prototype.hasOwnProperty.call(def, "rules"), false); + } + // The same coercion runs on read, so a hand-edited site file cannot smuggle + // one back in. + assert.deepEqual( + sanitizeTagsConfig(JSON.parse(JSON.stringify(site)), { layer: "site" }), + site, + ); + // And the corpus layer is untouched by this: the identical input keeps them. + assert.deepEqual( + (sanitizeTagsConfig(raw).tags[0].rules ?? []).map((r) => r.id), + ["site"], + ); +}); + +test("mergeTagDefs overlays presentation fields and cannot add a rule", () => { const merged = mergeTagDefs(DEFS, [ { id: "eva-collab", @@ -235,6 +268,9 @@ test("mergeTagDefs overlays presentation fields and appends site rules", () => { color: "#b48ead", order: 9, hidden: true, + // A rule reaching the merge at all means someone bypassed the site + // coercion; it is still not appended, because a site rule can never tag + // a record and "harmless on a published def" is how it looks like it can. rules: [{ id: "site", kind: "metadata", pattern: "extra", enabled: true }], }, ]); @@ -246,7 +282,7 @@ test("mergeTagDefs overlays presentation fields and appends site rules", () => { assert.equal(collab.hidden, true); assert.deepEqual( (collab.rules ?? []).map((r) => r.id), - ["meta", "site"], + ["meta"], ); // Untouched fields survive, and the global input is not mutated. assert.equal(collab.group, "eva"); diff --git a/common/lib/curatedTags.ts b/common/lib/curatedTags.ts @@ -222,11 +222,21 @@ function coerceTagDef(raw: unknown, layer: TagLayer): CuratedTagDef | null { // override", and inventing one here would rename the tag on that site, since // mergeTagDefs applies every field the overlay carries. const label = trimmedString(r.label) ?? (layer === "site" ? undefined : id); - const rules = Array.isArray(r.rules) - ? r.rules - .map((rule, i) => coerceRule(rule, i)) - .filter((rule): rule is CuratedTagRule => rule !== null) - : []; + // THE SECOND FIELD THE LAYERS TREAT DIFFERENTLY: a site layer carries no + // rules at all, and one written there is dropped — on read AND on write, so + // it can never reach a file, a merge or a published def. + // + // A rule is evaluated once at index time, over records SHARED by every site + // that carries the channel. There is no per-site record for a per-site hit to + // live in, so a site rule could only ever LOOK like it worked. Dropping it in + // the coercion is the one place that covers every writer. Rules bind from + // transcripts/tags.json alone; promote one there to make it fire. + const rules = + layer === "site" || !Array.isArray(r.rules) + ? [] + : r.rules + .map((rule, i) => coerceRule(rule, i)) + .filter((rule): rule is CuratedTagRule => rule !== null); const order = typeof r.order === "number" && Number.isFinite(r.order) ? r.order @@ -348,10 +358,13 @@ export function sanitizeTagsConfig( // Layer a site's tags.json over the corpus one. // // For an id the corpus already defines, the site entry is a FIELD-WISE OVERLAY -// of label/groupLabel/color/order/hidden and may APPEND rules. It can never -// delete a corpus rule (and assignments do not live at the site layer at all — -// an assignment is a fact about a video, not a presentation choice). A site id -// the corpus does not define becomes a full site-only tag. +// of label/groupLabel/color/order/hidden — presentation, and nothing else. It +// can never delete a corpus rule, and it cannot add one: coerceTagDef drops +// rules from a site layer entirely (see there for why a per-site rule cannot +// tag anything), so there is nothing here to append. Assignments do not live at +// the site layer at all — an assignment is a fact about a video, not a +// presentation choice. A site id the corpus does not define becomes a full +// site-only tag, rule-less like every other site row. // // Deliberately NOT mergeAliases' wholesale replacement: aliases are suggestions, // tags are a shared vocabulary that a site may dress up but not gut. @@ -375,9 +388,8 @@ export function mergeTagDefs( if (overlay.color !== undefined) base.color = overlay.color; if (overlay.order !== undefined) base.order = overlay.order; if (overlay.hidden !== undefined) base.hidden = overlay.hidden; - if (overlay.rules && overlay.rules.length > 0) { - base.rules = [...(base.rules ?? []), ...overlay.rules]; - } + // No rule branch: a sanitized site def has none, and the corpus rules on + // `base` are left exactly as the corpus wrote them. } return out; } diff --git a/common/lib/curatedTagsStore.ts b/common/lib/curatedTagsStore.ts @@ -7,12 +7,15 @@ // site-only tags. See common/lib/curatedTags.ts for the pure model, the // coercion and mergeTagDefs. // -// **A site-layer RULE never tags a record.** mergeTagDefs will append one (it -// is harmless on a published /tags.json def), but rules are evaluated once at -// index time over records SHARED by every site that carries the channel — -// there is no per-site record for a per-site hit to live in. Rules bind only -// from transcripts/tags.json; promote one there to make it fire. This is why -// the editor offers no site-side rule editor. +// **A site-layer RULE never tags a record, so a site layer carries none.** +// Rules are evaluated once at index time over records SHARED by every site that +// carries the channel — there is no per-site record for a per-site hit to live +// in, so a rule written at the site layer could only ever LOOK like it worked. +// The site coercion therefore DROPS rules outright: on READ, so a hand-edited +// sites/<id>/tags.json cannot smuggle one in, and on WRITE, so neither can a +// caller — and mergeTagDefs has no rule branch left to append through. Rules +// bind from transcripts/tags.json alone; promote one there to make it fire. +// This is why the editor offers no site-side rule editor. // // Unlike aliasesStore there are NO seeded defaults: a fresh install has no // tags, and an absent global file reads as an empty config.