diff --git a/package.json b/package.json
index b793f67..31a2bb5 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,7 @@
"build": "node build/build.mjs",
"lint": "eslint .",
"prepare": "node build/build.mjs",
- "test": "node --experimental-test-module-mocks --test test/pdf-vfs.test.js"
+ "test": "node --experimental-test-module-mocks --test test/*.test.js"
},
"peerDependencies": {
"marked": ">=12",
diff --git a/test/fixtures/xss-lint-covers.json b/test/fixtures/xss-lint-covers.json
new file mode 100644
index 0000000..9e3ce36
--- /dev/null
+++ b/test/fixtures/xss-lint-covers.json
@@ -0,0 +1,12 @@
+{
+ "_comment": [
+ "DS §15 — this repo's declaration of what the XSS rule covers.",
+ "Product source only. build/build.mjs is tooling and is globally ignored."
+ ],
+ "covers": [
+ "src/exporters/html.js",
+ "src/core.js",
+ "src/common.js"
+ ],
+ "why": "html.js is the HTML export surface (htmlPage, wordHtml, printDoc). core.js is renderHTML/escapeHtml. common.js is the shared helpers those call. Not build/build.mjs — tooling, globally ignored, and naming it would be the uploadwizard-app failure mode (a green count on files that do not render HTML)."
+}
diff --git a/test/fixtures/xss-lint-fixture.js b/test/fixtures/xss-lint-fixture.js
new file mode 100644
index 0000000..54762c4
--- /dev/null
+++ b/test/fixtures/xss-lint-fixture.js
@@ -0,0 +1,70 @@
+/**
+ * TGWAB — fixture for the interpolated-JSON XSS rule. DEV-STANDARDS §15.
+ *
+ * Place at: test/fixtures/xss-lint-fixture.js
+ * Its test: test/xss-lint-fixture.test.js (templates/xss-lint-fixture.test.js)
+ *
+ * WHY A FIXTURE SHIPS WITH THE RULE. A rule only ever shown to pass on clean
+ * input has not been shown to do anything. This file is the positive control:
+ * five cases that MUST flag and two that MUST NOT, so a run proves the rule can
+ * both fire and stay silent. It is also how the selector choice was measured —
+ * the direct-child form catches cases 1 and 2 only.
+ *
+ * THIS FILE IS DELIBERATELY NOT LINTED BY THE REPO'S OWN RUN. The config's
+ * scope is `files: ['**\/*.js']`, which DOES reach this file, so what keeps it
+ * out is the explicit `'**\/xss-lint-fixture.js'` entry in that config's global
+ * ignores — named there with this reason. That is what lets `npm run lint` stay
+ * green with a file full of deliberate hazards in the tree; without it this
+ * fixture contributes five errors to every adopting repo. The test does NOT lint it where it sits — that would apply no rule at
+ * all — it reads this text and lints it under a synthetic src/ path so the
+ * repo's real scoping applies. See the test's header.
+ */
+/* eslint-disable no-unused-vars, no-undef -- every case here is a specimen: it
+ is never called, and `escapeHtml` is deliberately undeclared because the
+ point is the SHAPE of the call, not a working implementation. Only these two
+ rules are disabled, and never no-restricted-syntax — disabling that would
+ silence the very thing this file exists to trip. */
+// Seven cases: five that MUST flag, two that MUST NOT.
+// Numbered so a run's output can be read against the spec without counting.
+
+// ---- MUST FLAG -------------------------------------------------------------
+
+// 1. Direct child. The shape the shipped rules were written for.
+function case1(n) {
+ return ``;
+}
+
+// 2. Direct child, no `;
+}
+
+// 4. Grandchild via a wrapper call. NOT exploitable — see the template comment —
+// but the same wrong pattern with the same remedy.
+function case4(n) {
+ return ``;
+}
+
+// 5. Grandchild via an arrow inside .map().
+function case5(xs) {
+ return ``;
+}
+
+// ---- MUST NOT FLAG ---------------------------------------------------------
+
+// 6. The literal TEXT "JSON.stringify" inside a browser-script template. No call.
+function case6() {
+ return ``;
+}
+
+// 7. An ordinary call, nowhere near a template literal.
+function case7(x) {
+ const body = JSON.stringify(x);
+ return body;
+}
diff --git a/test/xss-lint-fixture.test.js b/test/xss-lint-fixture.test.js
new file mode 100644
index 0000000..098f054
--- /dev/null
+++ b/test/xss-lint-fixture.test.js
@@ -0,0 +1,178 @@
+/**
+ * TGWAB — the XSS rule's own control. DEV-STANDARDS §15.
+ *
+ * Runner is node:test (this repo does not use vitest); assertions match the
+ * vendored kit. The specimen (`fixtures/xss-lint-fixture.js`) is byte-identical
+ * to tgwab-standards `templates/xss-lint-fixture.js`. Adding vitest for this
+ * one file would be a second runner that only the kit uses — gh-office already
+ * made this call.
+ */
+import test from "node:test";
+import assert from "node:assert/strict";
+import { ESLint } from "eslint";
+import { existsSync, readdirSync, readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { basename, dirname, join, relative, resolve } from "node:path";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const FIXTURE = resolve(here, "fixtures/xss-lint-fixture.js");
+const COVERS = resolve(here, "fixtures/xss-lint-covers.json");
+
+async function flaggedCases() {
+ const code = readFileSync(FIXTURE, "utf8");
+ const eslint = new ESLint();
+ const [result] = await eslint.lintText(code, {
+ filePath: resolve(process.cwd(), "src/__xss-lint-fixture__.js"),
+ });
+ const lines = code.split("\n");
+ const cases = new Set();
+ for (const m of result.messages) {
+ if (m.ruleId !== "no-restricted-syntax") continue;
+ for (let i = m.line - 1; i >= 0; i--) {
+ const name = /function (case\d)/.exec(lines[i]);
+ if (name) {
+ cases.add(name[1]);
+ break;
+ }
+ }
+ }
+ return [...cases].sort();
+}
+
+test("the interpolated-JSON XSS rule flags all five hazards (DS §15)", async () => {
+ const flagged = await flaggedCases();
+ assert.deepEqual(flagged, ["case1", "case2", "case3", "case4", "case5"]);
+});
+
+test("the interpolated-JSON XSS rule leaves the two negative controls alone", async () => {
+ const flagged = await flaggedCases();
+ assert.ok(flagged.includes("case1"));
+ assert.ok(!flagged.includes("case6"));
+ assert.ok(!flagged.includes("case7"));
+});
+
+const KIT_FILES = new Set([
+ "eslint.config.js",
+ "eslint.config.mjs",
+ "eslint.config.cjs",
+ "xss-lint-fixture.js",
+ "xss-lint-fixture.test.js",
+]);
+
+function* shippedFiles(dir, root) {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name === ".git" || entry.name === "node_modules") continue;
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) yield* shippedFiles(full, root);
+ else if (entry.isFile()) yield relative(root, full);
+ }
+}
+
+function carriesTheRule(config) {
+ const entry = config?.rules?.["no-restricted-syntax"];
+ if (!Array.isArray(entry) || entry[0] !== 2) return false;
+ return entry
+ .slice(1)
+ .some(
+ (o) =>
+ typeof o?.selector === "string" &&
+ o.selector.includes("JSON") &&
+ o.selector.includes("stringify"),
+ );
+}
+
+async function ruleCoverage(root = process.cwd()) {
+ const eslint = new ESLint();
+ const covered = [];
+ let linted = 0;
+ for (const rel of shippedFiles(root, root)) {
+ if (KIT_FILES.has(basename(rel))) continue;
+ if (await eslint.isPathIgnored(rel)) continue;
+ linted++;
+ if (carriesTheRule(await eslint.calculateConfigForFile(rel))) covered.push(rel);
+ }
+ return { covered, linted };
+}
+
+function repoRoot() {
+ let dir = here;
+ for (;;) {
+ if (existsSync(join(dir, ".git"))) return dir;
+ const up = dirname(dir);
+ if (up === dir) return null;
+ dir = up;
+ }
+}
+
+function resolveNamed(named, root) {
+ const fromCwd = resolve(process.cwd(), named);
+ if (existsSync(fromCwd)) return fromCwd;
+ if (root) {
+ const fromRoot = resolve(root, named);
+ if (existsSync(fromRoot)) return fromRoot;
+ }
+ return null;
+}
+
+const NOT_PRODUCT_SOURCE = [
+ /(^|\/)[^/]*\.config\.(js|mjs|cjs)$/,
+ /^(scripts|migrations|tests?|__tests__|e2e|loadtest|test-kit)\//,
+];
+
+function readDeclaration() {
+ let raw;
+ try {
+ raw = readFileSync(COVERS, "utf8");
+ } catch {
+ throw new Error(
+ `DS §15: ${relative(process.cwd(), COVERS)} is missing. Create it naming ` +
+ "the product source this repo ships that the rule must cover.",
+ );
+ }
+ const d = JSON.parse(raw);
+ if (!Array.isArray(d.covers)) throw new Error("DS §15: `covers` must be an array.");
+ return d;
+}
+
+test("the rule resolves for every file this repo NAMES as covered product source", async () => {
+ const d = readDeclaration();
+ const { covered, linted } = await ruleCoverage();
+ process.stdout.write(
+ `DS §15 coverage: the rule resolves for ${covered.length} of ${linted} ` +
+ `linted files; ${d.covers.length} named as product source.\n`,
+ );
+
+ if (d.covers.length === 0) {
+ assert.ok(
+ typeof d.declaredGap === "string" && d.declaredGap.trim().length > 0,
+ "DS §15: `covers` is empty, so `declaredGap` MUST say why",
+ );
+ return;
+ }
+
+ const eslint = new ESLint();
+ const root = repoRoot();
+ for (const named of d.covers) {
+ const abs = resolveNamed(named, root);
+ const normalized = abs && root ? relative(root, abs) : named;
+
+ assert.equal(
+ KIT_FILES.has(basename(named)),
+ false,
+ `DS §15: \`${named}\` is a file this repo GAINS by adopting the kit.`,
+ );
+ assert.equal(
+ NOT_PRODUCT_SOURCE.some((re) => re.test(normalized)),
+ false,
+ `DS §15: \`${named}\` is build tooling, not product source.`,
+ );
+ assert.ok(
+ abs !== null,
+ `DS §15: \`${named}\` is named in xss-lint-covers.json but does not exist.`,
+ );
+ assert.ok(
+ carriesTheRule(await eslint.calculateConfigForFile(abs)),
+ `DS §15: the rule does NOT resolve for \`${named}\`.`,
+ );
+ }
+});