test(05-01): scaffold routing workspace with failing contract suite
- fifth npm workspace @araharah/routing (buildless TS, search/ precedent) - root delegates routing:test / routing:typecheck / routing:corpus - C1-C6 RED: contract gates fail while src/types.ts + seed are absent
This commit is contained in:
Generated
+12
-1
@@ -9,7 +9,8 @@
|
|||||||
"theme",
|
"theme",
|
||||||
"bot",
|
"bot",
|
||||||
"web",
|
"web",
|
||||||
"search"
|
"search",
|
||||||
|
"routing"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"bot": {
|
"bot": {
|
||||||
@@ -29,6 +30,10 @@
|
|||||||
"resolved": "bot",
|
"resolved": "bot",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@araharah/routing": {
|
||||||
|
"resolved": "routing",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@araharah/search": {
|
"node_modules/@araharah/search": {
|
||||||
"resolved": "search",
|
"resolved": "search",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -3950,6 +3955,12 @@
|
|||||||
"error-stack-parser-es": "^1.0.5"
|
"error-stack-parser-es": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"routing": {
|
||||||
|
"name": "@araharah/routing",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"name": "@araharah/search",
|
"name": "@araharah/search",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+5
-1
@@ -6,7 +6,8 @@
|
|||||||
"theme",
|
"theme",
|
||||||
"bot",
|
"bot",
|
||||||
"web",
|
"web",
|
||||||
"search"
|
"search",
|
||||||
|
"routing"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build -w theme && npm run build -w web",
|
"build": "npm run build -w theme && npm run build -w web",
|
||||||
@@ -26,6 +27,9 @@
|
|||||||
"search:test": "npm test -w search",
|
"search:test": "npm test -w search",
|
||||||
"search:typecheck": "npm run typecheck -w search",
|
"search:typecheck": "npm run typecheck -w search",
|
||||||
"search:corpus": "node search/scripts/gazetteer-corpus.mjs",
|
"search:corpus": "node search/scripts/gazetteer-corpus.mjs",
|
||||||
|
"routing:test": "npm test -w routing",
|
||||||
|
"routing:typecheck": "npm run typecheck -w routing",
|
||||||
|
"routing:corpus": "node routing/scripts/route-corpus.mjs",
|
||||||
"web:dev": "npm run dev -w web",
|
"web:dev": "npm run dev -w web",
|
||||||
"web:build": "npm run build -w theme && npm run build -w web",
|
"web:build": "npm run build -w theme && npm run build -w web",
|
||||||
"web:build:harness": "npm run build:harness -w web",
|
"web:build:harness": "npm run build:harness -w web",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@araharah/routing",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node test/runner.mjs",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* contract.test.mjs — the RoutingSource contract gates (C1–C6).
|
||||||
|
* -------------------------------------------------------------
|
||||||
|
* RED first (plan 05-01 task 1): every import-bearing case fails while
|
||||||
|
* routing/src/types.ts does not exist — the lazy-import pattern (the
|
||||||
|
* bot/test/initdata.test.mjs precedent) keeps each case reporting its
|
||||||
|
* own failure instead of losing the suite load.
|
||||||
|
*
|
||||||
|
* C1 interface shape — the seam module loads; a stub
|
||||||
|
* { name, route } behaves as the contract
|
||||||
|
* C2 day-one model fields — cumulativeDistanceM + steps: RouteStep[]
|
||||||
|
* + the GUID-05/06 obligation, grep-pinned
|
||||||
|
* by test, not by hope (D-07/D-11)
|
||||||
|
* C3 null contract — the RoutingSource doc comment names the
|
||||||
|
* "router pending" semantics (D-03)
|
||||||
|
* C4 no logging/storage/network — ZERO banned tokens in routing/src
|
||||||
|
* code (comments stripped first — the
|
||||||
|
* web/test/no-storage.test.mjs pattern)
|
||||||
|
* C5 zero runtime deps — no "dependencies" in routing/package.json
|
||||||
|
* (D-05: nothing in the Worker/browser path)
|
||||||
|
* C6 ODbL header present — the committed seed carries license +
|
||||||
|
* attribution + notice (T-05-06)
|
||||||
|
*
|
||||||
|
* Run: npm run routing:test (exit 0 = all green)
|
||||||
|
*/
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { 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)); // routing/test
|
||||||
|
const routingDir = path.resolve(here, ".."); // routing/
|
||||||
|
const srcDir = path.join(routingDir, "src"); // routing/src
|
||||||
|
|
||||||
|
// Lazy types load — missing module => per-case ERR_MODULE_NOT_FOUND (the RED state).
|
||||||
|
let typesPromise = null;
|
||||||
|
function loadTypes() {
|
||||||
|
if (!typesPromise) {
|
||||||
|
typesPromise = import("../src/types.ts");
|
||||||
|
}
|
||||||
|
return typesPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* path-scrape + comment strip (the no-storage gate's contract) */
|
||||||
|
|
||||||
|
function stripComments(src) {
|
||||||
|
return src
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
|
||||||
|
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (benign over-stripping)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BANNED = [
|
||||||
|
"console.",
|
||||||
|
"localStorage",
|
||||||
|
"sessionStorage",
|
||||||
|
"indexedDB",
|
||||||
|
"document.cookie",
|
||||||
|
".put(",
|
||||||
|
"fetch(",
|
||||||
|
"XMLHttpRequest",
|
||||||
|
"process.env",
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ————— C1 — the seam exists and a stub behaves as the contract ————— */
|
||||||
|
|
||||||
|
test("C1 interface shape — the seam module loads; a stub {name, route} behaves as the contract", async () => {
|
||||||
|
await loadTypes(); // types.ts parses under Node 24 type-stripping
|
||||||
|
// Structural: a minimal backend satisfying RoutingSource (D-01) —
|
||||||
|
// route() is a function and resolves a Promise (null = router pending).
|
||||||
|
const stub = {
|
||||||
|
name: "x",
|
||||||
|
route: async () => null,
|
||||||
|
};
|
||||||
|
assert.equal(typeof stub.route, "function", "route() must be a function");
|
||||||
|
const pending = stub.route({ origin: { lat: 0, lng: 0 }, destinationId: "x" });
|
||||||
|
assert.ok(pending instanceof Promise, "route() must return a Promise");
|
||||||
|
assert.equal(await pending, null, "the honest pending state resolves null");
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ————— C2 — the day-one route model fields are grep-pinned ————— */
|
||||||
|
|
||||||
|
test("C2 day-one model fields — D-07/D-11 type gate pins cumulativeDistanceM + steps + GUID-05/06", () => {
|
||||||
|
const text = readFileSync(path.join(srcDir, "types.ts"), "utf8");
|
||||||
|
assert.ok(
|
||||||
|
text.includes("cumulativeDistanceM"),
|
||||||
|
"RouteGeometry.cumulativeDistanceM (snapped-progress support) is missing — D-07 violated",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
text.includes("steps: RouteStep[]"),
|
||||||
|
"RouteOption.steps (day-one step geometry) is missing — D-07 violated",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
text.includes("GUID-05/06 upstream obligation"),
|
||||||
|
"the GUID-05/06 upstream-obligation note is missing from the day-one fields",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ————— C3 — the null contract is contract documentation ————— */
|
||||||
|
|
||||||
|
test("C3 null contract — the RoutingSource doc comment names the router-pending semantics (D-03)", () => {
|
||||||
|
const text = readFileSync(path.join(srcDir, "types.ts"), "utf8");
|
||||||
|
const at = text.indexOf("export interface RoutingSource");
|
||||||
|
assert.ok(at > 0, "export interface RoutingSource is missing");
|
||||||
|
const before = text.slice(Math.max(0, at - 2000), at);
|
||||||
|
const doc = before.match(/\/\*\*[\s\S]*$/); // the last doc block before the interface
|
||||||
|
assert.ok(doc, "RoutingSource must carry a doc comment");
|
||||||
|
assert.ok(
|
||||||
|
doc[0].includes("router pending"),
|
||||||
|
"the D-03 null contract ('router pending') must be documented on the interface",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ————— C4 — the three zero-laws, mechanically ————— */
|
||||||
|
|
||||||
|
test("C4 no-logging/no-storage/no-network — zero banned tokens in routing/src code (comments stripped)", () => {
|
||||||
|
const files = readdirSync(srcDir).filter((f) => f.endsWith(".ts"));
|
||||||
|
assert.ok(files.length >= 1, "scanned a real src tree");
|
||||||
|
for (const file of files) {
|
||||||
|
const code = stripComments(readFileSync(path.join(srcDir, file), "utf8"));
|
||||||
|
for (const banned of BANNED) {
|
||||||
|
assert.ok(
|
||||||
|
!code.includes(banned),
|
||||||
|
`${file} references ${banned} — the D-05/D-12/D-15 zero-laws are violated`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ————— C5 — zero runtime dependencies ————— */
|
||||||
|
|
||||||
|
test("C5 zero runtime dependencies — routing/package.json has no dependencies field (D-05)", () => {
|
||||||
|
const pkg = JSON.parse(readFileSync(path.join(routingDir, "package.json"), "utf8"));
|
||||||
|
assert.equal(pkg.name, "@araharah/routing");
|
||||||
|
assert.equal(pkg.type, "module");
|
||||||
|
assert.equal(
|
||||||
|
pkg.dependencies,
|
||||||
|
undefined,
|
||||||
|
"a runtime dependency would ride the Worker/browser bundle path — D-05 violated",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ————— C6 — the committed seed's ODbL header (T-05-06) ————— */
|
||||||
|
|
||||||
|
test("C6 ODbL header present — the committed seed carries license + attribution + notice", () => {
|
||||||
|
const seed = JSON.parse(
|
||||||
|
readFileSync(path.join(srcDir, "data", "route-fixtures.json"), "utf8"),
|
||||||
|
);
|
||||||
|
assert.equal(seed._license, "ODbL-1.0", "the ODbL license field is missing");
|
||||||
|
assert.ok(
|
||||||
|
String(seed._attribution).includes("OpenStreetMap"),
|
||||||
|
"the attribution must credit OpenStreetMap contributors",
|
||||||
|
);
|
||||||
|
assert.ok(String(seed._notice).includes("ODbL"), "the notice must name ODbL");
|
||||||
|
assert.equal(seed.version, 1);
|
||||||
|
assert.ok(Array.isArray(seed.pairs), "pairs must be an array");
|
||||||
|
assert.equal(
|
||||||
|
seed.pairs.length,
|
||||||
|
0,
|
||||||
|
"the seed ships EMPTY — plan 05-02's build-time script populates it",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* harness.mjs — zero-dependency test registry.
|
||||||
|
* Same hand-rolled shape as bot/test/harness.mjs (the Phase 2 precedent):
|
||||||
|
* suites import { test } and register async cases; runner.mjs drains the registry.
|
||||||
|
*/
|
||||||
|
const tests = [];
|
||||||
|
export function test(name, fn) {
|
||||||
|
tests.push({ name, fn });
|
||||||
|
}
|
||||||
|
export const registry = tests;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* runner.mjs — hand-rolled zero-dependency test runner.
|
||||||
|
*
|
||||||
|
* Same shape as bot/test/runner.mjs (the Phase 2 precedent):
|
||||||
|
* - Auto-discovers *.test.mjs in this directory (sorted) and dynamic-imports them.
|
||||||
|
* - A suite that fails to LOAD is recorded as one failing case
|
||||||
|
* ("suite load failed: <file>") — the run never crashes mid-way.
|
||||||
|
* - Per case: PASS <name> / FAIL <name> (+ error detail).
|
||||||
|
* - Summary line + exit codes: exit 0 only when every case passed.
|
||||||
|
*/
|
||||||
|
import { readdirSync } from "node:fs";
|
||||||
|
import { registry } from "./harness.mjs";
|
||||||
|
|
||||||
|
const dir = import.meta.dirname;
|
||||||
|
const suites = readdirSync(dir)
|
||||||
|
.filter((f) => f.endsWith(".test.mjs"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const file of suites) {
|
||||||
|
try {
|
||||||
|
await import(`./${file}`);
|
||||||
|
} catch (loadErr) {
|
||||||
|
registry.push({
|
||||||
|
name: `suite load failed: ${file}`,
|
||||||
|
fn: () => {
|
||||||
|
throw loadErr;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let passedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
for (const { name, fn } of registry) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
passedCount++;
|
||||||
|
console.log(`PASS ${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
failedCount++;
|
||||||
|
console.log(`FAIL ${name}`);
|
||||||
|
console.error(` ${err && err.stack ? err.stack : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[runner] ${passedCount} passed, ${failedCount} failed`);
|
||||||
|
process.exitCode = failedCount > 0 ? 1 : 0;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src", "test"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user