From f1b31ca7edc26788beb2a1137a7f8cf3eca79d88 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 00:32:57 +0800 Subject: [PATCH] =?UTF-8?q?test(04-01):=20RED=20=E2=80=94=20gazetteer=20sc?= =?UTF-8?q?hema/license=20gates=20(G1-G11)=20+=20three-environment=20loade?= =?UTF-8?q?r=20proof=20(L1-L3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - G1-G6: dataset invariants — schema, slug-id uniqueness, canonical-name law ('KLCC' is an alias, never a name), DUKE-as-alias, KL bbox, the ODbL header traveling inside the JSON - G7-G9: the licensing gate — verbatim GPL-3.0 LICENSE, LICENSES.md, the README no longer claiming 'all rights reserved' - G10-G11: no logging/storage anywhere in the module (D-15); the loader builds its index inside a function, never at module top level - L1-L3: the phase's top mechanical risk — bare-Node loading with the SRCH-01/negative probes, the static JSON import law, esbuild INLINING the dataset + the wrangler dry-run build guard 13 cases fail while the dataset, loader, LICENSE files, and README fix are absent — the RED gate. --- search/test/gazetteer.test.mjs | 175 ++++++++++++++++++++++++++++++++ search/test/loader-env.test.mjs | 135 ++++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 search/test/gazetteer.test.mjs create mode 100644 search/test/loader-env.test.mjs diff --git a/search/test/gazetteer.test.mjs b/search/test/gazetteer.test.mjs new file mode 100644 index 0000000..949a07a --- /dev/null +++ b/search/test/gazetteer.test.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +/* + * gazetteer.test.mjs — dataset invariants + licensing gates (D-04, D-06, + * D-07, D-08, D-15). + * -------------------------------------------------------------------- + * RED phase (plan 04-01 task 3): the data-dependent cases fail while + * kl-gazetteer.json does not exist, and the licensing cases fail while + * LICENSE / LICENSES.md / the README correction are absent. + * + * G1 entry schema — id/name/aliases/lat/lng/category/address + * G2 id uniqueness + slug charset (/^[a-z0-9-]{1,64}$/) + * G3 canonical names vs shorthand — "Suria KLCC" is the NAME, "KLCC" + * is an ALIAS (RESEARCH Verification A3: the alias list IS the product) + * G4 "DUKE" is an alias of the-duke; no entry is NAMED "DUKE" + * G5 coordinates inside the KL metro bbox + * G6 the ODbL header travels INSIDE the JSON + * G7 LICENSE exists at the repo root and is the GPL-3.0 text + * G8 LICENSES.md documents BOTH licences + the OSM attribution + * G9 the README no longer claims "all rights reserved" + * G10 the shared module has no logging and no storage (D-15/T-04-01/02) + * G11 the loader is lazy — no index build at module top level (T-04-04) + * + * Run: npm run search:test (exit 0 = all green) + */ +import { strict as assert } from "node:assert"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "./harness.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); // search/test +const srcDir = join(here, "..", "src"); // search/src +const repoRoot = join(here, "..", ".."); // repo root + +/** Closed category set the seed contract pins. */ +const CATEGORIES = new Set([ + "mall", "office-tower", "park", "station", "hospital", "road", "highway", + "landmark", "convention", "village", "education", "government", +]); + +function loadGazetteerJson() { + const text = readFileSync(join(srcDir, "data", "kl-gazetteer.json"), "utf8"); + return JSON.parse(text); +} + +/** Strip block comments and full-line // comments. */ +function stripComments(text) { + return text.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/^\s*\/\/.*$/gm, " "); +} + +/** Every .ts file under search/src (recursive). */ +function srcTsFiles() { + const files = readdirSync(srcDir, { recursive: true }); + const out = []; + for (const f of files) { + if (typeof f === "string" && f.endsWith(".ts")) out.push(join(srcDir, f)); + } + return out; +} + +test("G1 entry schema — every entry is complete, finite, and categorised", () => { + const { entries } = loadGazetteerJson(); + assert.ok(Array.isArray(entries) && entries.length >= 30, `seed floor is 30 entries, got ${entries.length}`); + for (const e of entries) { + assert.ok(typeof e.id === "string" && e.id.length > 0, `id: ${e.id}`); + assert.ok(typeof e.name === "string" && e.name.length > 0, `name of ${e.id}`); + assert.ok(Array.isArray(e.aliases) && e.aliases.length > 0, `aliases of ${e.id}`); + assert.ok(e.aliases.includes(e.name), `${e.id}: aliases must include the canonical name`); + assert.ok(Number.isFinite(e.lat) && Number.isFinite(e.lng), `coords of ${e.id}`); + assert.ok(CATEGORIES.has(e.category), `category of ${e.id}: ${e.category}`); + assert.ok(typeof e.address === "string" && e.address.length > 0, `address of ${e.id}`); + } +}); + +test("G2 id uniqueness + slug charset", () => { + const { entries } = loadGazetteerJson(); + const ids = entries.map((e) => e.id); + assert.equal(new Set(ids).size, ids.length, "ids must be unique — they are the deep-link currency (D-11)"); + for (const id of ids) { + assert.match(id, /^[a-z0-9-]{1,64}$/, `id charset: ${id}`); + } +}); + +test("G3 canonical name vs shorthand — 'Suria KLCC' is the name, 'KLCC' is an alias", () => { + const { entries } = loadGazetteerJson(); + const klcc = entries.find((e) => e.id === "suria-klcc"); + assert.notEqual(klcc, undefined, "suria-klcc must exist"); + assert.equal(klcc.name, "Suria KLCC"); + assert.ok(klcc.aliases.includes("KLCC"), "'KLCC' must be an explicit alias — it is not the OSM name"); + assert.ok(!entries.some((e) => e.name === "KLCC"), "no entry may be NAMED the shorthand"); +}); + +test("G4 'DUKE' is an alias; no entry is named the acronym", () => { + const { entries } = loadGazetteerJson(); + const duke = entries.find((e) => e.id === "the-duke"); + assert.notEqual(duke, undefined, "the-duke must exist"); + assert.ok(duke.aliases.includes("DUKE"), "the highway acronym rides as an alias"); + assert.notEqual(duke.name, "DUKE", "DUKE is an acronym, not a landmark name"); + for (const e of entries) { + assert.notEqual(e.name, "DUKE"); + } +}); + +test("G5 coordinates sit inside the KL metro bbox", () => { + const { entries } = loadGazetteerJson(); + for (const e of entries) { + assert.ok(e.lat >= 2.9 && e.lat <= 3.4, `lat of ${e.id}: ${e.lat}`); + assert.ok(e.lng >= 101.4 && e.lng <= 102.0, `lng of ${e.id}: ${e.lng}`); + } +}); + +test("G6 the ODbL header travels inside the JSON (ODbL §4.4 share-alike)", () => { + const data = loadGazetteerJson(); + assert.ok(String(data._license).includes("ODbL"), "_license must name ODbL"); + assert.ok(String(data._attribution).includes("OpenStreetMap"), "_attribution must credit OpenStreetMap"); + assert.ok( + String(data._notice).includes("opendatacommons.org/licenses/odbl"), + "_notice must link the ODbL text", + ); + assert.ok(data.version >= 1); +}); + +test("G7 LICENSE exists at the repo root and is the GPL-3.0 text (D-04)", () => { + const license = readFileSync(join(repoRoot, "LICENSE"), "utf8"); + assert.ok(license.includes("GNU GENERAL PUBLIC LICENSE")); + assert.ok(license.includes("Version 3, 29 June 2007")); + assert.ok(license.includes("TERMS AND CONDITIONS")); +}); + +test("G8 LICENSES.md documents both licences + the OSM attribution", () => { + const doc = readFileSync(join(repoRoot, "LICENSES.md"), "utf8"); + for (const needle of ["GPL-3.0", "ODbL", "OpenStreetMap"]) { + assert.ok(doc.includes(needle), `LICENSES.md must name ${needle}`); + } +}); + +test("G9 the README licence section is no longer self-contradictory", () => { + const readme = readFileSync(join(repoRoot, "README.md"), "utf8"); + assert.ok(readme.includes("GPL-3.0"), "README must point at the GPL-3.0 LICENSE"); + assert.ok(!readme.includes("all rights reserved"), "the private-repo claim must be gone"); +}); + +test("G10 the shared module has no logging and no storage surface (D-15)", () => { + const banned = ["console.log", "localStorage", "sessionStorage", "indexedDB", "document.cookie"]; + for (const file of srcTsFiles()) { + const stripped = stripComments(readFileSync(file, "utf8")); + for (const token of banned) { + assert.ok(!stripped.includes(token), `${file} must not contain ${token} — the query text is user payload`); + } + } +}); + +test("G11 the loader is lazy — the index is built inside a function, never at module scope", async () => { + const loaderPath = join(srcDir, "gazetteer.ts"); + const stripped = stripComments(readFileSync(loaderPath, "utf8")); + const firstFn = stripped.search(/\bfunction\b/); + assert.ok(firstFn >= 0, "gazetteer.ts must declare functions"); + const topLevel = stripped.slice(0, firstFn); + assert.ok(!topLevel.includes("new Map("), "no index construction at module top level (Workers 1s startup, T-04-04)"); + assert.ok(!topLevel.includes("JSON."), "no JSON traversal at module top level"); + assert.ok(!topLevel.includes("for ("), "no dataset walking at module top level"); + + const mod = await import("../src/gazetteer.ts"); + for (const fn of ["getEntries", "getEntryById", "getIndex"]) { + assert.equal(typeof mod[fn], "function", `export ${fn} must be a function`); + } + const entries = mod.getEntries(); + assert.ok(entries.length >= 30); + const byId = mod.getEntryById("suria-klcc"); + assert.notEqual(byId, null, "honest lookup finds the seed anchor"); + assert.equal(mod.getEntryById("not-a-real-id"), null, "unknown id -> honest null"); + const index = mod.getIndex(); + assert.ok(index instanceof Map); + assert.ok(index.has("klcc"), "the index is keyed by NORMALIZED aliases (D-09 symmetry)"); +}); diff --git a/search/test/loader-env.test.mjs b/search/test/loader-env.test.mjs new file mode 100644 index 0000000..f912c34 --- /dev/null +++ b/search/test/loader-env.test.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/* + * loader-env.test.mjs — the THREE-ENVIRONMENT loader proof (D-07). + * ---------------------------------------------------------------- + * THE phase's top mechanical risk (RESEARCH): the JSON dataset must be + * reachable identically from (1) the bare-Node corpus runner, (2) the + * Vite bundle, and (3) the wrangler/esbuild Worker bundle — Workers have + * no fs, so the dataset MUST be a static build-time import, never a + * runtime fetch. + * + * L1 bare Node (type-stripping): the module graph loads, the dataset + * parses, search('KLCC') resolves suria-klcc, out-of-area is refused + * L2 static JSON import in the loader + (once plan 04-04 lands) the + * Vite bundle carries the dataset marker; until then that half is + * SKIPPED visibly while the static-import half is still asserted + * L3 esbuild — the bundler family wrangler uses — INLINES the dataset + * (its bundle carries the 'suria-klcc' marker), and the Worker + * dry-run build guard exits 0 + * + * Run: npm run search:test (exit 0 = all green) + */ +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "./harness.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); // search/test +const searchDir = path.resolve(here, ".."); // search/ +const repoRoot = path.resolve(searchDir, ".."); // repo root +const esbuildBin = path.join(repoRoot, "node_modules", ".bin", "esbuild"); +const wranglerBin = path.join(repoRoot, "node_modules", ".bin", "wrangler"); + +// Lazy index load — missing module => per-case ERR_MODULE_NOT_FOUND (the RED state). +let indexPromise = null; +function loadIndexModule() { + if (!indexPromise) { + indexPromise = import("../src/index.ts"); + } + return indexPromise; +} + +test("L1 bare Node (type-stripping) — the loader works with zero infrastructure", async () => { + const mod = await loadIndexModule(); + assert.ok(mod.getEntries().length >= 30, "the dataset parses in bare Node"); + const source = mod.createGazetteerSource(); + assert.equal(source.name, "gazetteer"); + const hits = await source.search({ text: "KLCC" }); + assert.ok(hits.length > 0, "KLCC must resolve"); + assert.equal(hits[0].entry.id, "suria-klcc", "SRCH-01: KLCC resolves to suria-klcc at rank 1"); + assert.equal((await source.search({ text: "Kuching" })).length, 0, "out-of-area is refused honestly (D-13)"); + assert.equal((await source.search({ text: "Penang" })).length, 0, "out-of-area is refused honestly (D-13)"); + assert.equal(source.byId("suria-klcc")?.name, "Suria KLCC", "byId is synchronous and honest"); + assert.equal(source.byId("../etc/passwd"), null, "byId rejects non-slug ids"); +}); + +test("L2 static JSON import in the loader (Vite-bundle half guarded until 04-04)", async () => { + const loaderPath = path.join(searchDir, "src", "gazetteer.ts"); + const loaderSrc = readFileSync(loaderPath, "utf8"); + assert.match( + loaderSrc, + /import raw from "\.\/data\/kl-gazetteer\.json"/, + "the dataset must be a STATIC import (build-time inlined)", + ); + assert.ok(!/fetch\(/.test(loaderSrc), "a runtime fetch would hard-fail in a Worker"); + assert.ok(!/readFileSync/.test(loaderSrc), "Workers have no fs — never read the dataset at runtime"); + + // The Vite-bundle half is only assertable once plan 04-04 wires web/ to + // @araharah/search. Until then: visible skip, static-import half asserted. + const webSrcDir = path.join(repoRoot, "web", "src"); + let webImportsSearch = false; + try { + for (const entry of readdirSync(webSrcDir, { recursive: true })) { + const rel = String(entry); + if (rel.endsWith(".ts") || rel.endsWith(".tsx")) { + if (readFileSync(path.join(webSrcDir, rel), "utf8").includes("@araharah/search")) { + webImportsSearch = true; + break; + } + } + } + } catch { + // no web/src — treat as not-yet-landed + } + if (!webImportsSearch) { + console.log("SKIP L2 (web-bundle marker) — web/ does not import @araharah/search yet (lands in plan 04-04); the static-import half is asserted above"); + return; + } + const distAssets = path.join(repoRoot, "web", "dist", "assets"); + assert.ok(existsSync(distAssets), "web/dist/assets must exist once web imports the search module"); + let marker = false; + for (const file of readdirSync(distAssets)) { + if (file.endsWith(".js") && readFileSync(path.join(distAssets, file), "utf8").includes("suria-klcc")) { + marker = true; + break; + } + } + assert.ok(marker, "the Vite bundle must inline the gazetteer (build-time, never fetched)"); +}); + +test("L3 esbuild inlines the gazetteer; the wrangler dry-run build guard exits 0", () => { + // — the inline half (provable at wave 1): bundle the search module with the + // SAME bundler family wrangler uses and grep the output for the marker — + const bundleDir = "/tmp/opencode/araharah-search-bundle"; + mkdirSync(bundleDir, { recursive: true }); + const outfile = path.join(bundleDir, "index.js"); + const bundle = spawnSync( + esbuildBin, + [path.join(searchDir, "src", "index.ts"), "--bundle", `--outfile=${outfile}`, "--log-level=error"], + { encoding: "utf8", timeout: 60000 }, + ); + assert.equal(bundle.status, 0, `esbuild bundle failed: ${bundle.stderr || bundle.stdout}`); + const bundled = readFileSync(outfile, "utf8"); + assert.ok( + bundled.includes("suria-klcc"), + "a static JSON import MUST be inlined at build time — a runtime fetch would be a hard Worker failure", + ); + + // — the Worker dry-run half (build regression guard ONLY): the bundle- + // marker grep for the WORKER bundle is plan 04-03 task 2's criterion, + // after bot.ts imports createGazetteerSource() — + if (!existsSync(path.join(repoRoot, "web", "dist", "index.html"))) { + console.log("SKIP L3 (wrangler dry-run) — web/dist is not built; run npm run build first"); + return; + } + const dryrunDir = "/tmp/opencode/araharah-worker-dryrun"; + mkdirSync(dryrunDir, { recursive: true }); + const dry = spawnSync(wranglerBin, ["deploy", "--dry-run", `--outdir=${dryrunDir}`], { + cwd: path.join(repoRoot, "bot"), + encoding: "utf8", + timeout: 180000, + }); + assert.equal(dry.status, 0, `wrangler dry-run failed: ${(dry.stderr || dry.stdout).slice(-800)}`); +});