From 8b2e2c38a56e777cc986a8a8ff7161213ac81ba0 Mon Sep 17 00:00:00 2001 From: Kiril Kirov Date: Tue, 6 Oct 2026 09:00:50 +0000 Subject: [PATCH] fix(core): read only page dates in lastmod verifiability Nested Review, Comment, Answer and Question dates record when someone else wrote, not when the page changed. Reading them as page signals reported product pages that publish no page date as divergent instead of unverifiable. Count lastmods within one hour as one build stamp. A generator that writes the clock per URL spreads one run over seconds, and exact string matching missed it. --- .changeset/lastmod-page-signals.md | 5 + .../sitemap-lastmod-verifiability.md | 10 ++ .../sitemap-lastmod-verifiability.test.ts | 88 ++++++++++++++++- .../sitemap-lastmod-verifiability.ts | 95 ++++++++++++++++--- 4 files changed, 184 insertions(+), 14 deletions(-) create mode 100644 .changeset/lastmod-page-signals.md diff --git a/.changeset/lastmod-page-signals.md b/.changeset/lastmod-page-signals.md new file mode 100644 index 00000000..ab706413 --- /dev/null +++ b/.changeset/lastmod-page-signals.md @@ -0,0 +1,5 @@ +--- +"@forkpoint/agent-lighthouse-core": patch +--- + +Fix two false readings in `machine-discovery/sitemap-lastmod-verifiability`. Dates on nested reviews, comments, questions and answers no longer count as the page's modification time, so a product page with customer reviews and no page date is unverifiable, not divergent. Lastmod values written seconds apart in one generator run now count as one build stamp. diff --git a/docs/evidence/audits/machine-discovery/sitemap-lastmod-verifiability.md b/docs/evidence/audits/machine-discovery/sitemap-lastmod-verifiability.md index 942414bc..d5edbf59 100644 --- a/docs/evidence/audits/machine-discovery/sitemap-lastmod-verifiability.md +++ b/docs/evidence/audits/machine-discovery/sitemap-lastmod-verifiability.md @@ -97,6 +97,16 @@ Content-page scans do not infer a mount. Origin files, origin probes, feed disco - **One hour of clock skew is tolerated** before a value counts as future-dated, so a server a few minutes ahead of the scanner is not reported. +### Page signals exclude contributed dates (2026-10-06) + +The sketch says to parse all JSON-LD blocks for `dateModified` and `datePublished`. The audit reads those dates only from nodes that describe the page: top-level nodes, `@graph` members and what they nest. It skips a nested `Review`, `Comment`, `Answer` or `Question`. Those dates record when someone else wrote, not when the page changed. A top-level `Review` is the page itself and keeps its dates. + +The trigger was a large retail site. Its product pages publish no modification time, but each one nests customer reviews. The review dates were read as page signals, and five pages with no page date were reported as divergent. They are now unverifiable. + +### A build stamp is one run, not one string (2026-10-06) + +The claim is "one identical lastmod equal to the last deploy date". The audit compared lastmod strings exactly. A generator that writes the clock per URL spreads one run over seconds, and exact matching saw every value as different. One large retail site stamped 2451 product URLs between 09:57:16 and 09:57:33 on one day. The audit now counts the largest group of sampled values inside a one-hour window. The 90% share and the 3-day recency rules are unchanged. Values hours apart still count as separate dates. + ## Deferred - **Only the first level of a `` is walked**, per the shared diff --git a/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.test.ts b/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.test.ts index b3f0545d..e95a906a 100644 --- a/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.test.ts +++ b/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.test.ts @@ -36,6 +36,8 @@ interface PageSpec { lastModified?: string; /** , if the page carries one. */ metaModified?: string; + /** A raw JSON-LD block, served beside any dateModified block. */ + jsonLd?: object; } function html(spec: PageSpec): string { @@ -47,10 +49,13 @@ function html(spec: PageSpec): string { dateModified: spec.dateModified, })}` : ""; + const raw = spec.jsonLd + ? `` + : ""; const meta = spec.metaModified ? `` : ""; - return `${jsonLd}${meta}

Copy.

`; + return `${jsonLd}${raw}${meta}

Copy.

`; } function run( @@ -116,6 +121,65 @@ describe("SitemapLastmodVerifiabilityAudit", () => { expect(result.status).toBe("na"); }); + // A product page's customer reviews carry their own datePublished. Those + // dates say when a shopper wrote, not when the page changed; read as page + // signals they turned unverifiable lastmods into "divergent" ones. + describe("contributed dates", () => { + const product = (reviewDaysAgo: number) => ({ + "@context": "https://schema.org", + "@type": "Product", + name: "Hoodie", + review: [ + { + "@type": "Review", + author: { "@type": "Person", name: "A shopper" }, + datePublished: iso(reviewDaysAgo), + }, + ], + }); + + it("does not read a nested Review date as a page signal", async () => { + const result = await run( + Array.from({ length: 5 }, (_v, i) => ({ + loc: `https://example.com/p-${i}`, + lastmod: iso(5 + i * 9), + jsonLd: product(60 + i * 9), + })), + ); + expect(result.status).toBe("warn"); + expect(result.found).toContain("0 divergent, 5 unverifiable"); + }); + + it("does not read a nested Review inside an @graph member either", async () => { + const result = await run( + Array.from({ length: 5 }, (_v, i) => ({ + loc: `https://example.com/p-${i}`, + lastmod: iso(5 + i * 9), + jsonLd: { + "@context": "https://schema.org", + "@graph": [product(60 + i * 9)], + }, + })), + ); + expect(result.found).toContain("0 divergent, 5 unverifiable"); + }); + + it("keeps the dates of a page that is itself a Review", async () => { + const result = await run( + Array.from({ length: 5 }, (_v, i) => ({ + loc: `https://example.com/r-${i}`, + lastmod: iso(10 + i * 7), + jsonLd: { + "@context": "https://schema.org", + "@type": "Review", + datePublished: iso(10 + i * 7), + }, + })), + ); + expect(result.status).toBe("pass"); + }); + }); + it("passes when every lastmod matches the page dateModified", async () => { const result = await run(consistent(5)); expect(result.status).toBe("pass"); @@ -173,6 +237,28 @@ describe("SitemapLastmodVerifiabilityAudit", () => { expect(result.message).toContain("build stamp"); }); + // A generator that writes the clock per URL spreads one run over seconds. + // Exact-string matching saw every value as distinct and missed the stamp. + it("fails when recent lastmods spread over seconds cover over 90% of sampled URLs", async () => { + const base = Date.now() - DAY; + const urls = Array.from({ length: 10 }, (_v, i) => ({ + loc: `https://example.com/s-${i}`, + lastmod: new Date(base + i * 2_000).toISOString(), + })); + const result = await run(urls); + expect(result.status).toBe("fail"); + expect(result.message).toContain("one lastmod run"); + }); + + it("does not call lastmods hours apart a build stamp", async () => { + const urls = Array.from({ length: 10 }, (_v, i) => ({ + loc: `https://example.com/h-${i}`, + lastmod: new Date(Date.now() - DAY - i * 2 * 3_600_000).toISOString(), + })); + const result = await run(urls); + expect(result.message).not.toContain("build stamp"); + }); + it("fails when over 20% of sampled URLs diverge from every page signal by more than 7 days", async () => { const urls = Array.from({ length: 5 }, (_v, i) => ({ loc: `https://example.com/x-${i}`, diff --git a/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.ts b/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.ts index 18cc43de..b051f529 100644 --- a/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.ts +++ b/packages/core/src/audits/machine-discovery/sitemap-lastmod-verifiability.ts @@ -15,7 +15,7 @@ import { parseHtml, extractJsonLd, extractMetaTags, - allJsonLdNodes, + topLevelJsonLd, } from "../../parser"; import { siteSitemapTree, @@ -31,6 +31,13 @@ const SAMPLE_SIZE = 6; const FUTURE_SKEW_MS = 60 * 60 * 1000; /** One value on this share of the sample is a stamp, not a set of content dates. */ const MODAL_SHARE = 0.9; +/** + * Values this close together are one stamp. A generator that writes the + * clock per URL spreads one run over seconds: one retail site stamped 2451 + * product URLs between 09:57:16 and 09:57:33, and exact-string matching saw + * 2451 different dates. + */ +const STAMP_WINDOW_MS = 60 * 60 * 1000; /** ...but only when that value is this recent, which is what makes it a deploy date. */ const MODAL_RECENCY_DAYS = 3; /** How far a lastmod may sit from every page signal before it is unsupported. */ @@ -53,6 +60,56 @@ function parseTime(value: unknown): number | undefined { return Number.isNaN(parsed) ? undefined : parsed; } +/** + * Types whose dates belong to someone else's contribution, not to the page. + * + * A product page carries its customers' reviews as `review: [{ "@type": + * "Review", datePublished }]`. Those dates say when a shopper wrote, not + * when the page changed, and on one retail site they turned five pages with + * no modification time at all into "divergent" lastmods. + */ +const CONTRIBUTED_TYPES = new Set([ + "Review", + "CriticReview", + "UserReview", + "EmployerReview", + "Comment", + "Answer", + "Question", +]); + +function typeNames(node: Record): string[] { + const raw = node["@type"]; + const list = Array.isArray(raw) ? raw : [raw]; + return list + .filter((t): t is string => typeof t === "string") + .map((t) => t.replace(/^.*[/:#]/, "")); +} + +/** + * The JSON-LD nodes whose dates describe the page: every top-level node and + * `@graph` member, and what they nest, minus nested contributions. A top-level + * Review is the page itself and keeps its dates. + */ +function pageDateNodes(jsonLd: object[]): Record[] { + const out: Record[] = []; + const visit = (node: unknown, nested: boolean): void => { + if (Array.isArray(node)) { + for (const item of node) visit(item, nested); + return; + } + if (!isObject(node)) return; + if (nested && typeNames(node).some((t) => CONTRIBUTED_TYPES.has(t))) return; + out.push(node); + for (const [key, value] of Object.entries(node)) { + if (key === "@context") continue; + if (value && typeof value === "object") visit(value, true); + } + }; + for (const top of topLevelJsonLd(jsonLd)) visit(top, false); + return out; +} + /** Every modification time the page itself publishes, in no particular order. */ function pageSignals( headers: Record, @@ -66,8 +123,7 @@ function pageSignals( }; add(headers["last-modified"]); - for (const node of allJsonLdNodes(jsonLd)) { - if (!isObject(node)) continue; + for (const node of pageDateNodes(jsonLd)) { add(node["dateModified"]); add(node["datePublished"]); } @@ -218,16 +274,29 @@ export class SitemapLastmodVerifiabilityAudit extends Audit { } } - // The modal test only looks at URLs we could compare, so a site whose pages - // publish nothing is never accused of stamping builds. - const counts = new Map(); - for (const entry of sample) - counts.set(entry.lastmod, (counts.get(entry.lastmod) ?? 0) + 1); - const [modalValue, modalCount] = [...counts.entries()].sort( - (a, b) => b[1] - a[1], - )[0] ?? ["", 0]; + // The modal test counts the whole sample, compared or not: a stamp is a + // property of the sitemap, and needs no page signal to be seen. It takes + // the largest group of values inside one STAMP_WINDOW_MS window. + const ordered = sample + .map((entry) => ({ + lastmod: entry.lastmod, + time: Date.parse(entry.lastmod), + })) + .sort((a, b) => a.time - b.time); + let modalCount = 0; + let modalFirst = ""; + let modalLast = ""; + for (let start = 0, end = 0; end < ordered.length; end++) { + while (ordered[end]!.time - ordered[start]!.time > STAMP_WINDOW_MS) + start += 1; + if (end - start + 1 > modalCount) { + modalCount = end - start + 1; + modalFirst = ordered[start]!.lastmod; + modalLast = ordered[end]!.lastmod; + } + } const modalRecent = - (now - Date.parse(modalValue)) / DAY_MS <= MODAL_RECENCY_DAYS; + (now - Date.parse(modalLast)) / DAY_MS <= MODAL_RECENCY_DAYS; const buildStamp = sample.length > 1 && modalCount / sample.length > MODAL_SHARE && @@ -243,7 +312,7 @@ export class SitemapLastmodVerifiabilityAudit extends Audit { } if (buildStamp) { problems.push( - `${modalCount} of ${sample.length} sampled URLs (${pct(modalCount, sample.length)}%) share the single lastmod ${modalValue}, within ${MODAL_RECENCY_DAYS} days of this scan — the signature of a build stamp rather than a content date`, + `${modalCount} of ${sample.length} sampled URLs (${pct(modalCount, sample.length)}%) share ${modalFirst === modalLast ? `the single lastmod ${modalFirst}` : `one lastmod run, ${modalFirst} to ${modalLast}`}, within ${MODAL_RECENCY_DAYS} days of this scan — the signature of a build stamp rather than a content date`, ); } if (compared > 0 && divergent / compared > DIVERGENT_SHARE) {