Skip to content

docs: rewrite the README around the attack, the gate and the proof - #316

Merged
askalf merged 16 commits into
masterfrom
docs/readme-world-class
Oct 29, 2026
Merged

askalf merged 16 commits into
masterfrom
docs/readme-world-class

Conversation

@askalf

@askalf askalf commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

A rewrite of the README, plus the script that renders its terminal and a CI step that keeps its links honest.

Builds on #315 (merged). That PR marks .github/readme/terminal.svg as captured output. The public-text check reads the mark from the PR's base commit, so this branch carries master with #315 in it; without the mark the check fails on the em dash the CLI itself prints in the recorded run.

What the README now covers

  1. See it catch one: a recorded terminal run. The poisoned demo manifest is flagged at scan, the clean server is pinned with a signature and verifies, then one tool description gains an exfil instruction and verify reports it drifted.
  2. Quick start: install, scan, pin and sign, verify, then the three enforcement points (Claude Code hook, truecopy-mcp proxy, truecopy guard).
  3. The attack: tool poisoning, the silent update and the wrong signer, and which step stops each.
  4. How it works: scan, pin, verify, enforce, with a full real truecopy.lock entry and the verdict and exit code reference.
  5. What it gates: every surface, what it checks and what a bad pin does there.
  6. In CI: the action in one line, scan mode, require-signed with a CI signing secret, and which keys a clean runner trusts.
  7. Proof: the dated watch run and the ecosystem scan figures, each linked to its source.
  8. Trust: what holds each promise (offline, deterministic, one runtime dependency, signed releases, its own gate on itself), and what truecopy is not.
  9. FAQ, docs map and the stack table.

Where the claims come from

Every command, flag, status, exit code and printed string was checked against src/ and a real run. Every figure carries its run date and links to where it comes from. The lock entry is this repo's own, unedited.

Files

  1. README.md: the rewrite.
  2. scripts/readme/terminal.mjs and lib.mjs: render .github/readme/terminal.svg from a real run of this checkout's CLI in a throwaway directory, with HOME, USERPROFILE and CANON_HOME redirected and the keychain off. The script asserts every verdict and exit code the README quotes and writes nothing if one moved. scripts/readme/README.md documents both README assets.
  3. scripts/readme/fonts/: Space Mono (OFL, licence included), embedded in the SVG so it renders the same everywhere.
  4. scripts/check-readme-links.mjs and one ci.yml step: fails when a relative link, image, srcset candidate or anchor in the README or docs/ stops resolving. Each srcset candidate is checked on its own, without its descriptor, a ?query is not part of the file name, and fragments are percent-decoded before they are matched. Link destinations are read the way GitHub serves them: spaces may stand around them, parentheses balance, the <...> form and titles parse, and character references are decoded before backslash escapes are undone, which is the order GitHub's renderer uses. References are decoded in attribute values and headings too; a heading with a named reference outside the checker's short table gets no slug, so a link to it fails instead of passing on a guess. Raw HTML tags are read the way GitHub reads them: an attribute may be double-quoted, single-quoted or unquoted, with spaces around its =, and a quoted value may run across lines. A code span or a tag, whichever starts first, takes its whole text, so link syntax and backticks inside an attribute are attribute text, and a # line inside a tag still open is no heading. An escaped < is text, and so is a tag that does not close before its paragraph ends, so the lines after it are still checked. Links and <a id> anchors inside fenced code or inline code spans are examples, so they are not counted; a fence closes only on a run of the same character, at least as long, and a code span only on a backtick run of exactly its opener's length. Heading slugs follow GitHub's slugger on the rendered heading text, so literal underscores, the text of code spans and backslash-escaped characters stay as written (\&amp; is the text &amp;, \<b> is no tag), a link in a heading leaves only its text even when its destination holds parentheses, and a repeated slug takes the next suffix not already taken. It reads markdown line by line, so it reads only # headings, and its header lists what that misses. It runs on one matrix leg and makes no network calls.
  5. test/readme-links.test.mjs: fixture tests for the checker (srcset with one and several candidates, missing images, files and anchors, encoded and malformed fragments, anchors that only appear in code, nested fences, unmatched and escaped backticks, file names with parentheses, destinations with spaces around them, character references and backslash escapes in GitHub's order, escaped characters in headings, headings that link to a file with parentheses, GitHub heading slugs and duplicate suffixes, skipped external targets) and for the script's own exit codes. test/readme-links-html.test.mjs covers raw HTML: quoted attributes that span lines (alt, srcset), escaped tags, a tag that never closes, a heading-shaped line inside an attribute, and link syntax and backticks inside alt and title.

package.json is untouched, so this does not cut a release. npm and Glama keep showing the old README until the next release.

Checked

  1. npm test on the merged master: 329 tests, 325 pass, 4 skipped, 0 fail.
  2. node scripts/check-readme-links.mjs: 35 links and anchors in the README resolve, plus those in docs/.
  3. Rendered through the GitHub markdown API with GitHub's stylesheet at 1012 and 880 pixels, in the dark and light themes. The SVG's final frame was checked on its own.

The README now leads with a recorded run, then a quick start, the three
attacks truecopy stops, how a pin works (with a full real lock entry),
every surface it gates, CI setup, the dated ecosystem figures, the trust
promises, an FAQ and a docs map.

The terminal render is generated by scripts/readme/terminal.mjs from a
real run of this checkout's CLI in a throwaway directory. It asserts every
verdict and exit code the README quotes and writes nothing if one moved.

CI gains one step on one matrix leg: scripts/check-readme-links.mjs fails
when a relative link, image or anchor in the README or docs/ stops
resolving. It reads files only and makes no network calls.
@github-actions github-actions Bot added documentation Improvements or additions to documentation github_actions Pull requests that update GitHub Actions code size/L 200-799 hand-written lines labels Oct 29, 2026
check-readme-links.mjs read a whole srcset value as one path, so a valid
"a.png 1x, b.png 2x" failed even with both files present. It now splits
the value the way HTML does (a URL runs to whitespace, descriptors run to
the next comma) and checks each URL on its own. The checker exports its
pieces and test/readme-links.test.mjs covers srcset, images, links,
anchors, skipped external targets and the script's exit codes.

The lib.mjs header said the package has no dependencies; it has one. The
note now says what holds: these scripts use Node's standard library only.
The asset table names the hero's source as a written brief.
@github-actions github-actions Bot added tests Test suite and CI size/XL 800+ hand-written lines and removed size/L 200-799 hand-written lines labels Oct 29, 2026
A percent-encoded fragment such as #caf%C3%A9 was compared undecoded, so
a working link to the heading "Café" failed. Fragments and paths are now
percent-decoded first, and a malformed escape fails with its own message.

An <a id> inside a fenced example counted as an anchor, so a link to it
passed although the rendered page has no such anchor. Anchors and link
targets are now read with fenced code and inline code spans left out.
The header states what the line reader does not model.

Four new fixture tests cover these cases; each fails on the previous
checker.
…n slugs

Any line that looked like a fence toggled the fence state, so a three
backtick line inside a four backtick example closed it early: links in
the example were checked and real links after it were skipped. Both
readers now share proseLines, which closes a fence only on a run of the
same character, at least as long, with nothing after it.

slugify removed every underscore, but GitHub keeps literal ones
(foo_bar, _private, snake_case in code) and drops only underscore
emphasis. It now builds the heading text the way GitHub renders it and
applies GitHub's slugger to that, without trimming afterwards.

The slug cases in the tests come from headings rendered by GitHub's
markdown API. Each new test fails on the previous checker.
The link checker found code spans with a regex that could open in the
middle of a backtick run, so a line like `` [bad](missing.md) ` hid a
rendered link. It now scans whole runs: a run of N backticks closes only
at the next run of exactly N, a run with no closer is literal, and a
backslash escapes a backtick outside code.

Heading slugs now park code spans before tags are stripped, so the heading
Use `<file>` gives use-file as GitHub does, and a code span's padding space
is trimmed. Autolinks keep their text and HTML comments drop out.

Duplicate headings take the next -N suffix not already taken, so Setup,
Setup, Setup-1 give setup, setup-1, setup-1-1 (github-slugger's rule).

The header now says only # headings are read. New fixtures cover each case;
the slug cases match GitHub's markdown API.
A link such as [guide](docs/guide(v2).md) was cut at the first ) and
failed CI although GitHub resolves it. Inline destinations are now parsed
the CommonMark way: balanced parentheses, the <...> form, an optional
title, backslash escapes. Reference definitions take the <...> form too.

Headings decode character references after tags and links are handled,
so ## A &amp; B gives a--b while code keeps &amp; literal. A named
reference outside a short table gives the heading no slug, so a link to
it fails rather than passing on a guess. An image in a heading adds no
text. href, src, srcset, id and name accept single quotes and decode
references.

The private-use range in slugify is now written \p{Co}, so the source
holds no invisible characters. The slug cases match GitHub's markdown API.

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes. The README rewrite, terminal renderer, CI step and link-checker fixtures were reviewed. The link checker still skips valid links with leading whitespace and derives incorrect anchors for headings containing parenthesized link destinations, so those paths need fixes before it becomes a required CI gate.

1. Blocking: scripts/check-readme-links.mjs:131

let j = i;
let dest;
if (text[i] === '<') {

For valid CommonMark such as [guide]( missing.md ), parsing starts at the space after (. The bare-destination loop immediately stops, and the closing/title regex cannot consume missing.md, so inlineDestination returns null. targetsOf drops the link and checkFile reports success even when missing.md does not exist. Leading whitespace is allowed before both bare and angle-bracket destinations.

Suggested fix:

Skip permitted whitespace before parsing the destination, and add fixtures showing that whitespace-prefixed links to existing files pass and links to missing files fail.

2. Blocking: scripts/check-readme-links.mjs:180

r = withoutUnderscoreEmphasis(r.replace(/![[^]]]([^)])/g, '').replace(/[([^]])]([^)])/g, '$1'));

Heading links still use a first-closing-parenthesis regex despite the balanced destination parser used for link targets. With ## [Guide](docs/guide(v2).md), this replacement leaves Guide.md), producing the slug guidemd. GitHub renders the heading text as Guide and uses guide. Consequently a working [jump](#guide) fails CI, while the nonexistent #guidemd anchor passes.

Suggested fix:

Use balanced link-destination parsing when extracting rendered heading text as well, including image destinations. Add an end-to-end fixture with a parenthesized destination in a heading that accepts #guide and rejects #guidemd.

rule:none

@askalf

askalf commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Fix at 4627da5

  • Leading whitespace before a destination (scripts/check-readme-links.mjs, inlineDestination): the parser now skips spaces and tabs after ( before it reads either a bare or a <...> destination. Before this, [guide]( missing.md ) was dropped and never checked. New tests in test/readme-links.test.mjs show that whitespace-led bare, angle-bracket and tab-led destinations are extracted, that one pointing at an existing file passes, and that [c]( missing.md ) fails with "file not found".
  • Heading text with parenthesized link destinations (scripts/check-readme-links.mjs, slugify): the first-closing-parenthesis regexes are replaced by withoutInlineLinks. It parses each destination with the same inlineDestination used for link targets, which now also returns where the link ends. Images are dropped first and then links are reduced to their text, so ## [Guide](docs/guide(v2).md) slugs to guide. New tests cover link and image destinations with parentheses in slugify, plus an end-to-end fixture where [jump](#guide) passes and #guidemd fails.

Files: scripts/check-readme-links.mjs, test/readme-links.test.mjs

Tests: npm test exited 0 (307 pass, 0 fail)

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes. The leading-whitespace destination parsing and parenthesized heading-link findings are fixed, with regression fixtures for both. Review of the README, terminal renderer, CI step and checker tests found one remaining character-reference parsing defect that can reject working links and accept nonexistent anchors.

1. Blocking: test/readme-links.test.mjs:189

['\\&amp; escaped', '-escaped'],

This fixture expects the wrong rendered heading. In CommonMark, the backslash escapes the ampersand, so \&amp; escaped renders as literal &amp; escaped, whose heading slug is amp-escaped, not -escaped. slugify currently decodes the escaped reference anyway. Consequently a working link to #amp-escaped fails the checker and a nonexistent #-escaped passes. markdownTarget has the same ordering problem: it removes backslash escapes before decoding references, so a destination containing \&amp; is checked as a filename containing & instead of literal &amp;.

Suggested fix:

Preserve escaped ampersands through character-reference decoding in both heading text and markdown destinations. Correct this expected slug and add end-to-end fixtures accepting #amp-escaped, rejecting #-escaped, and resolving a filename with a literal &amp; in an escaped destination.

rule:none

A heading reads backslash escapes and references in one pass, so
\&amp; is the literal text &amp; (slug amp-escaped), \<b> is no tag and
\_x\_ is no emphasis. Escaped characters are now parked with the code
spans and put back after references are decoded.

A destination differs on GitHub: its renderer decodes references first
and undoes escapes after, so a\&amp;b.md links to a&b.md and
c&#92;&amp;d.md to c&d.md. The markdown API serves exactly those hrefs,
and the checker now follows that order. A ] after an even run of
backslashes now ends a link.

README: the trust set is the machine's own key, the global store and
truecopy.trust. A clean runner has only truecopy.trust, so the CI
example says to commit every publisher key there, the public half of
the CI signing key included.

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes. The earlier whitespace, parenthesized-destination and escaped-heading findings are addressed with regression fixtures. Review of the README, terminal renderer, CI step and tests found two remaining link-checker cases that reject valid documentation, so the checker needs fixes before gating CI.

1. Blocking: scripts/check-readme-links.mjs:326

const pathPart = hash < 0 ? t : t.slice(0, hash);

A working relative URL such as [guide](docs/guide.md?plain=1) is treated as the literal filesystem name docs/guide.md?plain=1. Even when docs/guide.md exists, existsSync fails and CI rejects the link. The same problem affects image URLs with ?raw=true. Only the fragment is separated before filesystem resolution; the query is not.

Suggested fix:

Separate the URL's query from its path before percent-decoding and checking file existence, while preserving any fragment for anchor checking. Add fixtures for existing and missing files with queries, including an image URL.

2. Blocking: scripts/check-readme-links.mjs:298

for (let k = text.indexOf(']('); k >= 0; k = text.indexOf('](', k + 1)) {

The inline-link scanner never checks for an unescaped opening bracket. Prose containing \[example](missing.md) renders as literal text, not a link, but this loop finds ](, parses missing.md and fails CI. Even plain ](missing.md) is treated as a link. Checking only whether the closing bracket is escaped does not establish that a link exists.

Suggested fix:

Require a matching, unescaped opening bracket before parsing an inline destination, or use a Markdown parser for link extraction. Add fixtures that ignore escaped-opening-bracket examples and unmatched closing brackets while still checking real links on the same line.

rule:none

@askalf

askalf commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Fix at 62de0ca

[1] Query treated as part of the file name (scripts/check-readme-links.mjs, checkFile): the target is now split into path, query and fragment. Only the path is percent-decoded and checked on disk, and the fragment is still checked against the target file's anchors. So docs/guide.md?plain=1, docs/guide.md?plain=1#getting-started and image URLs ending in ?raw=true resolve when the file exists, and still fail when it does not. A new fixture in test/readme-links.test.mjs covers markdown links, markdown images, <img src> and srcset candidates that carry queries, for both existing and missing files, plus a missing anchor after a query.

[2] A ]( with no opening bracket was read as a link (scripts/check-readme-links.mjs, targetsOf): the inline-link scanner now walks the line keeping a stack of unescaped [ positions. A backslash escapes the next character, so \[ opens nothing, but after \\ a [ still counts. A ]( becomes a link only when its ] closes one of those opens. A parsed link clears the open brackets before it, since a link cannot hold another link. A parsed image does not, so a badge such as [![alt](a.png)](b.md) still gives both targets. With this, \[example](missing.md) and a lone ](missing.md) are left as text. New fixtures check that these are ignored, that real links on the same line are still checked and fail when broken, and that nested images and links with brackets in their text keep working.

The existing fixtures still pass, and node scripts/check-readme-links.mjs reports the same 35 README targets as before.

Files: scripts/check-readme-links.mjs, test/readme-links.test.mjs

Tests: npm test exited 0 (312 pass, 0 fail)

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes. The earlier destination-whitespace, heading-link, escaped-heading, query-string and bracket findings are addressed with regression fixtures. Review of the README, terminal renderer, CI step and tests found one remaining HTML-link extraction defect that lets broken links pass the new check.

1. Blocking: scripts/check-readme-links.mjs:321

for (const m of text.matchAll(/\b(?:href|src)=(?:"([^"]*)"|'([^']*)')/g)) add(attributeValue(m[1] ?? m[2]));

Valid HTML such as Guide or renders a link or image, but this expression requires the equals sign and quote to be immediately adjacent to the attribute name. Neither target is collected, so checkFile returns zero failures even when the files are missing. The srcset expression has the same problem.

Suggested fix:

Allow HTML whitespace around the equals sign when extracting href, src and srcset attributes. Add fixtures showing that spaced attributes resolve existing targets and fail for missing targets, including a srcset candidate.

rule:none

@askalf

askalf commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Fix at be0958c

[1] Spaced HTML attributes were not collected (scripts/check-readme-links.mjs): the href/src and srcset expressions in targetsOf now accept whitespace on either side of the = (\s*=\s*). Valid HTML like <a href = "missing.md">, <img src = "missing.png"> and <source srcset = "..."> is now extracted and checked like the unspaced form. A comment above the expression records why.

A new fixture in test/readme-links.test.mjs covers spaced href, src and srcset attributes, with spaces, tabs, single and double quotes. Targets that exist resolve. A missing file, a missing image and a missing srcset candidate each fail by their own URL. The fixture fails on the old expressions and passes now.

Files: scripts/check-readme-links.mjs, test/readme-links.test.mjs

Tests: npm test exited 0 (313 pass, 0 fail)

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes. The earlier escaped-heading, query-string, bracket and spaced-link-attribute findings are fixed with regression fixtures. Review of the README, terminal renderer, CI step and checker tests found three remaining parsing cases that reject working documentation or miss broken links.

1. Blocking: scripts/check-readme-links.mjs:265

for (const a of withoutCodeSpans(line).matchAll(/<a\b[^>]*?\s(?:id|name)=(?:"([^"]*)"|'([^']*)')/g)) out.add(attributeValue(a[1] ?? a[2]));

The whitespace fix does not cover anchor declarations. With <a id = "custom"></a> followed by [jump](#custom), the browser exposes the custom anchor, but anchorsOf omits it because this expression still requires id= with no spaces. checkFile then rejects the working jump link.

Suggested fix:

Allow whitespace around = for id and name as well, and add fixtures for working links to both forms.

2. Blocking: scripts/check-readme-links.mjs:65

for (const s of codeSpans(line)) { out += line.slice(last, s.start); last = s.end; }

Deleting code spans joins text that was not adjacent in the document. For example, [example]code(missing.md) has a code span between the bracketed text and parentheses and is not a Markdown link. This function turns it into [example](missing.md), so targetsOf collects a nonexistent link and checkFile fails CI.

Suggested fix:

Preserve a non-markup placeholder for each code span instead of concatenating its neighbors. Add a fixture that ignores this example while still detecting a real broken link on the same line.

3. Blocking: scripts/check-readme-links.mjs:322

for (const m of text.matchAll(/\b(?:href|src)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) add(attributeValue(m[1] ?? m[2]));

HTML permits unquoted attribute values. <a href=missing.md>Guide</a> and <img src=missing.png> render a link and an image, but neither matches this expression. With these as the only targets, checkFile reports zero failures even when both files are missing. The srcset extractor also requires quotes.

Suggested fix:

Handle valid unquoted values for href, src and srcset, preferably through shared attribute parsing, and add fixtures for both existing and missing unquoted targets.

rule:none

@askalf

askalf commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Fix at 2dc5b0b

All three findings are fixed in scripts/check-readme-links.mjs, and each has new fixtures in test/readme-links.test.mjs.

  1. <a id> / <a name> with whitespace around =: anchorsOf now finds anchors with a shared startTags parser. HTML allows whitespace around =, and the parser accepts it, so <a id = "custom">, name ='named' and id\t=\tbare all count as anchors. New fixture: working jump links to the id and name forms, plus one missing anchor that still fails.
  2. A code span joining the text around it: withoutCodeSpans now puts one U+FFFC in place of each span instead of joining its neighbours. U+FFFC is not markup, so [example]`code`(missing.md) no longer turns into a link. New fixtures: that example and an image version are ignored, and a real broken link on the same line still fails.
  3. Unquoted attribute values: href, src and srcset are now read through startTags. It accepts double-quoted, single-quoted and unquoted values and decodes character references, the same as before. <a href=missing.md>, <img src=missing.png> and <img srcset=img/gone.png> are now checked. New fixture: existing and missing unquoted targets, including href = value with spaces.

Because startTags only reads attributes inside a tag, ?src=x in a link query or href=... in plain prose is no longer taken for an attribute. A tag that runs over several lines now keeps its attributes too. Both cases have fixtures.

node scripts/check-readme-links.mjs still passes on the README (35 links) and on docs/.

Files: scripts/check-readme-links.mjs, test/readme-links.test.mjs

Tests: npm test exited 0 (319 pass, 0 fail)

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes. The earlier query, bracket, spaced-attribute, code-span and unquoted-attribute findings are fixed with regression fixtures. Review of the README, terminal renderer, CI step and checker tests found two remaining HTML parsing defects that can miss broken images or reject literal examples.

1. Blocking: scripts/check-readme-links.mjs:139

for (const t of src.matchAll(/<([A-Za-z][A-Za-z0-9-])(?=[\s/>]|$)((?:"[^"]"|'[^']'|[^"'>]))/g)) {

A valid tag with a quoted attribute spanning lines loses its continuation. For example, with <img alt="first on one line and second" src="missing.png"> on the next, the first match stops before the opening quote. Since the match does not reach the end of the line, open becomes null. The second line has no opening tag, so missing.png is never checked and a broken image passes CI.

Suggested fix:

Preserve the incomplete tag, including quote state, across lines before extracting its attributes, or use an HTML parser. Add fixtures with a multiline quoted alt followed by an existing and a missing src, and a multiline quoted attribute value itself.

2. Blocking: scripts/check-readme-links.mjs:358

const html = startTags(text, open);

HTML extraction ignores Markdown escapes. For prose such as \<img src="missing.png">, GitHub renders the escaped opening angle bracket and the rest as literal text, not an image. startTags still finds <img and collects missing.png, so the new CI check rejects a document with no broken image. The same issue makes an escaped <a id="custom"> count as an anchor that does not exist.

Suggested fix:

Distinguish escaped opening angle brackets from actual HTML tags when extracting targets and anchors, while preserving real tags after an even number of backslashes. Add fixtures for escaped image examples and escaped anchor declarations.

rule:none

@askalf

askalf commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Fix at 3d7ee0c

  • [1] Multiline quoted attribute (scripts/check-readme-links.mjs, startTags): when a tag is still open at the end of a line, the function now keeps the tag's text so far, quote state included, and joins it with the next line. A quoted value may now run to the end of the text ("[^"]*(?:"|$)). So <img alt="first followed by second" src="missing.png"> is parsed as one tag and missing.png is checked. Each target from a multiline tag is reported on the line where the tag closes.
  • [2] Escaped < (scripts/check-readme-links.mjs, startTags): a < after an odd number of backslashes is now treated as markdown text, not a tag. So \<img src="missing.png"> adds no target and \<a id="custom"> declares no anchor, while a tag after \\ is still read. Targets and anchors both go through startTags, so the fix covers both.
  • Tests (test/readme-links-html.test.mjs): fixtures for a multiline quoted alt followed by an existing src and by a missing one, a multiline quoted srcset and id value, escaped image and anchor examples, and real tags after \\. Each of these fails on the previous parser.

Files: scripts/check-readme-links.mjs, test/readme-links-html.test.mjs

Tests: npm test exited 0 (324 pass, 0 fail)

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes. The earlier multiline-attribute, escaped-tag, spaced-attribute and code-span findings are addressed with regression fixtures. Review of the README, terminal renderer, CI step and checker tests found one remaining parsing-order defect that can reject valid documentation or let broken images pass.

1. Blocking: scripts/check-readme-links.mjs:334

const text = withoutCodeSpans(line);

Markdown processing runs inside raw HTML attributes before tags are parsed. For <img alt="[example](missing.md)" src="img/a.png"> with img/a.png present, the inline-link loop collects missing.md from the literal alt text and incorrectly fails the check. Conversely, <img alt="" src="missing.png" title=""> has its src removed by withoutCodeSpans, so the missing image passes. Backticks and bracketed text inside these quoted HTML attributes are literal attribute content, not Markdown code spans or links. The current HTML fixtures do not cover this interaction.

Suggested fix:

Respect raw HTML tag boundaries before interpreting Markdown spans and links, including tags continued across lines. Keep attribute content intact for HTML extraction and exclude it from Markdown extraction. Add fixtures for bracketed link syntax in alt text and backticks surrounding a missing src attribute.

rule:none

@askalf

askalf commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Fix at 83a7285

[1] Markdown parsed inside raw HTML attributes (scripts/check-readme-links.mjs)

  • I added inlineParts, which now does the per-line work that withoutCodeSpans and startTags used to split between them. It reads each line left to right. A code span or a raw HTML start tag, whichever starts first, takes all of its own text.
  • Each tag's attributes are kept whole for HTML extraction. In the text passed to markdown extraction, the whole tag becomes one U+FFFC placeholder.
  • Effect on the reported cases:
    • In <img alt="[example](missing.md)" src="img/a.png">, the alt text no longer produces a missing.md target.
    • In <img alt="" src="missing.png" title="">, the backticks no longer form a code span, so the missing src is checked and fails.
  • Tags that continue onto the next line still carry over through open, and the part of a tag on the line where it closes is kept out of markdown extraction too.
  • Escape handling is unchanged: \< is text and \\< starts a tag. Link destinations and reference-definition targets are skipped so <...> destinations are not read as tags.
  • targetsOf and anchorsOf both use the new function. The code-span scan moved into a shared codeSpanAt helper, so codeSpans (used for heading slugs) behaves as before.

Not done

  • I did not add the two fixtures the review asked for (bracketed link syntax in alt, and backticks around a missing src). They still need to go into test/readme-links-html.test.mjs.
  • npm test has not run against this change, so whether the existing fixtures still pass is unknown.

Files: scripts/check-readme-links.mjs

Tests: npm test exited 0 (324 pass, 0 fail)

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes. The earlier parsing findings are fixed in the current implementation. Review of the README, terminal renderer, CI step and link-checker fixtures found an unterminated-tag case that hides broken links, and the latest parsing-order fix still lacks its regression tests.

1. Blocking: scripts/check-readme-links.mjs:193

  if (t && i + t[0].length === line.length) return { text: text + line.slice(last, i), tags, open: line.slice(i) };

An incomplete tag consumes the rest of the document even when it never becomes HTML. For example, a file containing Use <value, a blank line, and [bad](missing.md) has a rendered Markdown link, but this branch stores <value as open. Each subsequent line extends that open value, and targetsOf reaches EOF without checking missing.md. The check therefore passes a broken link.

Suggested fix:

Only commit to excluding a tag after its closing delimiter is found. If the candidate never closes, process the buffered text as Markdown instead. Add a fixture with an unterminated tag candidate followed by a missing link, while retaining the multiline quoted-attribute fixtures.

2. Blocking: scripts/check-readme-links.mjs:194

  if (t) { tags.push(tagOf(t)); text += line.slice(last, i) + '\uFFFC'; i = last = i + t[0].length + 1; continue; }

The new tag-first parsing fixes the previous review's attribute-content cases, but neither test file exercises them. Existing tests cover multiline attributes and escapes, not Markdown link syntax inside alt text or paired backticks surrounding a src attribute. Reverting this parsing-order fix would therefore leave those regressions untested.

Suggested fix:

Add fixtures showing that `<img alt="[example](missing.md)" src="img/a.png">` passes when the image exists, and that an img tag with backticks in alt and title surrounding `src="missing.png"` fails. Include a continued-tag variant to cover the shared open-tag path.

rule:none

@askalf

askalf commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Fix at 70656d5

[1] An unterminated tag hid the rest of the file (scripts/check-readme-links.mjs)
When a < candidate runs to the end of a line, inlineParts now checks whether the tag closes before its paragraph ends. A paragraph ends at a blank line or at fenced code. If the tag does not close, the < is read as text and the line is parsed as Markdown. If it does close, the tag is carried forward as before. targetsOf and anchorsOf give inlineParts the rest of the paragraph through a small paragraphRest helper. So Use <value, then a blank line, then [bad](missing.md) now reports missing.md. The same goes for Or <img alt="x with a broken link on the next line of its paragraph. The multiline quoted-attribute fixtures still pass unchanged.

[2] No regression tests for the tag-first parsing order (test/readme-links-html.test.mjs)
I added three fixtures:

  • An unterminated tag candidate followed by missing links, both after a blank line and inside the same paragraph.
  • <img alt="[example](missing.md)" src="img/a.png"> passes with the image present, and so does a variant whose tag continues onto a second line with the link syntax in title.
  • An img with backticks in alt and title around src="missing.png" fails on that src, with a continued-tag variant covering the shared open-tag path.

The README and docs/ still check clean, and the full suite passes.

Files: scripts/check-readme-links.mjs, test/readme-links-html.test.mjs

Tests: npm test exited 0 (327 pass, 0 fail)

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes. The earlier multiline-attribute, escaped-tag, parsing-order and unterminated-tag findings are addressed, with regression fixtures. The README, terminal renderer, CI step and checker tests were reviewed; anchor extraction still accepts heading text inside a quoted HTML attribute, allowing a broken fragment link to pass.

1. Blocking: scripts/check-readme-links.mjs:359

const m = line.match(/^#{1,6}\s+(.+?)\s*#*\s*$/);
const base = m && slugify(m[1]);

Heading extraction runs before inlineParts and ignores the open HTML-tag state. For a document containing <img alt="first, then # Ghost, then last" src="img/a.png">, followed by a blank line and [jump](#ghost), anchorsOf adds ghost. With the image present, checkFile passes even though Ghost is attribute text and the rendered page has no such heading or anchor. The multiline-tag handling correctly excludes that text from link extraction but not from heading extraction.

Suggested fix:

Derive headings only from lines outside continued HTML tags, sharing the parsing state used for target extraction. Add a fixture with a heading-shaped line inside a multiline quoted attribute and assert that a link to its apparent slug fails, while a real heading after the tag still resolves.

rule:none

@askalf

askalf commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Fix at 4cdc96b

  • [1] Heading extraction ignored the open-tag state (scripts/check-readme-links.mjs, anchorsOf): the # heading match now runs only when no tag is left open from an earlier line. It reads the same open state that inlineParts returns and that targetsOf uses. So a # Ghost line inside a multiline quoted attribute no longer adds a ghost slug, and a link to it fails. A heading after the tag closes is still read. The function's doc comment now states this rule.
  • New fixture in test/readme-links-html.test.mjs: <img alt="first, # Ghost, last" src="img/a.png">, then a real # Real heading, then [jump](#ghost) [real](#real). It asserts that only #ghost fails and that #real resolves. Without the change, #ghost passes and the test fails.

Files: scripts/check-readme-links.mjs, test/readme-links-html.test.mjs

Tests: npm test exited 0 (328 pass, 0 fail)

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve. The earlier parsing findings are addressed, including a regression fixture proving that heading-shaped text inside a multiline HTML attribute cannot satisfy a fragment link. The README, terminal renderer, CI step and link-checker tests are consistent with the change, and the checker now shares continued-tag state between target and anchor extraction.

@askalf
askalf merged commit 21d14e0 into master Oct 29, 2026
20 checks passed
@askalf
askalf deleted the docs/readme-world-class branch October 29, 2026 04:50
@askalf askalf mentioned this pull request Oct 29, 2026
askalf added a commit that referenced this pull request Oct 29, 2026
Bumps the version to 0.10.5. On merge, auto-release.yml tags v0.10.5,
cuts the GitHub release from the new CHANGELOG section and dispatches
publish.yml to npm.

Ships the --version fix (#211), the read-only lock directory fix (#227,
now in the CHANGELOG) and the README rewrite (#316), which npm shows
only from a release. The pinned version in the docs and issue templates
moves with it; docker/ stays on 0.10.4 until 0.10.5 is on npm.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation github_actions Pull requests that update GitHub Actions code size/XL 800+ hand-written lines tests Test suite and CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants