Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feed-generic-xml-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@forkpoint/agent-lighthouse-core": minor
---

`machine-discovery/feed-entry-identity-and-canonical-integrity` no longer warns when a feed is served as `application/xml` or `text/xml`. Every feed reader parses a generic XML type, and no source names a consumer that treats it worse; `application/rss+xml` was never registered with IANA. The type is still recorded under `details.warnings`, and a non-XML type such as `text/html` still fails.
21 changes: 18 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,21 @@ pnpm build && pnpm test && pnpm typecheck && pnpm lint && pnpm check:dossiers &&
`pnpm build` has to run first. Watch for stale build artifacts: an untracked `.js` or `.d.ts` under
`packages/*/src/` shadows its source in vitest and turns a red suite green.

Add a changeset for anything a user would notice. Removing an audit, changing
what one reports, or moving a tier is a `major` — a scan's output changes even
when the audit carried weight 0.
Add a changeset for anything a user would notice. The bump follows what a
consumer of the output can rely on, not whether a verdict moved:

- **`major`** — the contract changes. An audit id is removed or renamed, a
tier or grade moves (and with it the weight), the result or report schema
changes, or a CLI flag, MCP tool or public export is removed or changes
meaning. Scripts, baselines and dashboards that read the output can break.
This holds even when the audit carried weight 0.
- **`minor`** — the contract grows. A new audit, a new informative or
experimental check, a new `details` key, a new flag or output format.
- **`patch`** — the contract holds and the code is corrected. A verdict that
disagreed with its own dossier is brought into line, a false finding is
removed, a message is reworded. The id, tier, grade, weight and schema stay
the same; some sites' scores move because they were wrong before.

A verdict correction is a `patch` only when the dossier already supports the
new behaviour. If the fix needs a new source or a changed grade, the dossier
changes first and the bump follows the rule above.
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Tier per evidence policy: **scored** — grade B meets the A/B bar required for
## Review history

- 2026-08-20 — proposed by the novel-checks research pass (10-agent evidence workflow); sources URL-verified at research time.
- 2026-10-06 — a generic XML media type stopped returning `warn`; no source documents a consumer penalty, and `application/rss+xml` is unregistered.

## Implementation deviations

Expand All @@ -71,10 +72,14 @@ link already explains the tracking parameters, so the finding names both and the
tracking arm fires on its own only when the page declares no canonical at all
to disagree with.

**A generic `application/xml` or `text/xml` warns rather than fails.** The
registered type is `application/atom+xml` or `application/rss+xml`, but a
generic XML type still parses everywhere; a type that is not XML at all — most
often `text/html` from a misconfigured catch-all — fails.
**A generic `application/xml` or `text/xml` is noted, not scored.** Atom's
`application/atom+xml` is registered by RFC 4287. `application/rss+xml` was
never registered with IANA; it is a convention. A generic XML type parses in
every feed reader, and no source names a consumer that treats it worse. The
audit records it under `details.warnings` and still passes. A type that is not
XML at all — most often `text/html` from a misconfigured catch-all — fails.
Until 2026-10-06 the generic type returned `warn`, which cost half the audit's
weight with no consumer source behind it.

**The newest-five selection uses only timezone-carrying dates.**
`parseFeedDate` returns `undefined` for a timestamp with no offset rather than
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,17 +261,21 @@ describe("FeedEntryIdentityAndCanonicalIntegrityAudit", () => {
expect(strings(r, "failures").join(" ")).toContain("not absolute HTTPS");
});

// A generic XML type parses, so it is a delivery defect rather than a broken feed.
it("warns when the feed is served as text/xml but passes its identity checks", async () => {
const { result } = run({
feed: CLEAN_ATOM,
feedType: "text/xml",
items: CLEAN_ITEMS,
});
const r = await result;
expect(r.status).toBe("warn");
expect(strings(r, "warnings").join(" ")).toContain("text/xml");
});
// A generic XML type parses everywhere and no source names a consumer that
// treats it worse, so it is noted in details but never costs score.
it.each(["text/xml", "application/xml"])(
"passes a feed served as %s and notes the generic type",
async (feedType) => {
const { result } = run({
feed: CLEAN_ATOM,
feedType,
items: CLEAN_ITEMS,
});
const r = await result;
expect(r.status).toBe("pass");
expect(strings(r, "warnings").join(" ")).toContain(feedType);
},
);

it("fails a feed served as text/html", async () => {
const { result } = run({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ const EXPECTED_TYPE: Record<string, RegExp> = {
json: /^application\/(feed\+json|json)/i,
};

/** A generic XML type: not wrong enough to fail, not the registered type either. */
/**
* A generic XML type. Every feed reader parses it, and no source names a
* consumer that treats it worse, so it is recorded but never costs score.
* `application/rss+xml` was never registered with IANA; only Atom's type was.
*/
const GENERIC_XML = /^(application|text)\/xml/i;

/** Does `text` carry a `<content>` that points elsewhere or holds non-text data? */
Expand Down Expand Up @@ -95,7 +99,7 @@ export class FeedEntryIdentityAndCanonicalIntegrityAudit extends Audit {
if (expected && !expected.test(type)) {
if (GENERIC_XML.test(type)) {
warnings.push(
`${where}: served as "${type}" rather than the registered ${feed.declaredType} media type`,
`${where}: served as "${type}", a generic XML type; the ${feed.declaredType}-specific type is optional`,
);
} else {
failures.push(
Expand Down Expand Up @@ -234,7 +238,7 @@ export class FeedEntryIdentityAndCanonicalIntegrityAudit extends Audit {
const displayValue = `${entriesChecked} entries, ${failures.length} problem(s)`;
const expected =
"Every entry carries the id and timestamp its format requires, no id repeats, and item links are absolute HTTPS URLs equal to the page’s canonical";
const found = `${feeds.length} feed(s), ${entriesChecked} entries checked, ${canonicalChecks.length} item URL(s) compared against their canonical; ${failures.length} failure(s), ${warnings.length} warning(s).`;
const found = `${feeds.length} feed(s), ${entriesChecked} entries checked, ${canonicalChecks.length} item URL(s) compared against their canonical; ${failures.length} failure(s), ${warnings.length} media-type note(s).`;
const details = {
feeds: feeds.map((feed) => feed.url),
entriesChecked,
Expand All @@ -256,19 +260,6 @@ export class FeedEntryIdentityAndCanonicalIntegrityAudit extends Audit {
};
}

if (warnings.length > 0) {
return {
...this.warn(
`Entry identity is sound, but ${warnings.length} delivery problem(s) remain.`,
expected,
found,
"Serve the feed as its registered media type.",
),
displayValue,
details,
};
}

return {
...this.pass(
`${entriesChecked} entries across ${feeds.length} feed(s) carry stable identities and canonical links.`,
Expand Down
Loading