Skip to content

Harden the documentation link parser - #3102

Merged
Chris0Jeky merged 13 commits into
mainfrom
fix/2762-doc-link-parser-hardening
Sep 17, 2026
Merged

Chris0Jeky merged 13 commits into
mainfrom
fix/2762-doc-link-parser-hardening

Conversation

@Chris0Jeky

@Chris0Jeky Chris0Jeky commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Outcome

Addresses the repository-actionable parser and masking residuals from GH-2762 without changing CI control-plane wiring or the working-tree scan policy.

The documentation checker now recognises additional local-link forms that the original single regular expression could silently miss:

  • reference-style link and image definitions
  • HTML <a href> and <img src> targets
  • both the outer destination and inner asset in a linked image
  • balanced parentheses in a destination
  • double-quoted, single-quoted, and parenthesized Markdown titles

It also replaces the broad code-mask regular expressions with a stateful mask:

  • balanced inline spans and fenced blocks still preserve exact offsets
  • an unmatched inline delimiter cannot mask links beyond a blank line
  • an unterminated fence remains masked to avoid illustrative-link false positives
  • malformed delimiters are reported separately as warnings rather than silently hiding content or turning historical prose defects into broken-link failures

TDD and verification

The tests were committed before the implementation and add focused cases for every new form plus separate warning/link-failure behavior.

Hosted exact-head qualification for dcf82d611065169b3f51e8d1b45edf84e22fafc2:

  • required CI run 34912930797: passed
  • Smart CI Self-Test run 34912930572: passed
  • Docs Governance, Linux and Windows frontend matrices, migration validation, backend/API unit and integration lanes, SAST, dependency checks, container images, and E2E smoke all passed
  • CI Extended run 34912940903 reported only the repository's unrelated Backend Solution Regression and PostgreSQL Testcontainers failures; neither failing job exercises these two JavaScript files

The implementation was also exercised in an isolated Node probe against the existing external-target, masking, title, missing-target, root-relative, percent-decoding, containment, case-exact, skip-list, and formatting behaviors, plus every new parser form and masking diagnostic.

Scope and residuals

  • Changed only scripts/check-doc-links.mjs and scripts/check-doc-links.test.mjs.
  • No .github/**, Smart CI, required-context, branch-protection, or scheduled-workflow change.
  • The checker still walks the working tree rather than git ls-files; that portability/reproducibility choice remains open.
  • Nightly wiring remains an R4/control-plane follow-up.
  • Existing anchor checking remains file-only by design.

Part of GH-2762.

@Chris0Jeky Chris0Jeky added docs Improvements or additions to documentation Priority III Valuable but unscheduled: residuals, tech-debt, performance, depth work. testing Test coverage, harnesses, regression prevention, and verification pipeline work. labels Sep 15, 2026 — with ChatGPT Codex Connector
@Chris0Jeky
Chris0Jeky marked this pull request as ready for review September 15, 2026 01:13
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-17T01:10:06.205993Z 14b21ee Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dcf82d6110

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/check-doc-links.mjs Outdated
Comment thread scripts/check-doc-links.mjs Outdated
Comment thread scripts/check-doc-links.mjs Outdated
Comment thread scripts/check-doc-links.mjs Outdated
Comment thread scripts/check-doc-links.mjs Outdated

@Chris0Jeky Chris0Jeky left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fresh-context post-fix review at exact head 063e23b480a51e77bd83e78eaebbef948a529a41 against current main 8082a602db24fc3060a63b956c252d510d2faec8: no confirmed CRITICAL/HIGH merge blocker.

The five review defects are closed and exercised through the checker: footnotes, continuation-line destinations, quoted > attributes, HTML comments, and unmatched-backtick recovery. Independent verification passed 30/30 parser tests, the 705-file repository check with zero broken links, Windows case-exact tests, and git diff --check.

Additional non-blocking parser hardening findings (literal < before a tag, attribute-like text inside quoted values, unterminated comments, pathological delimiter performance, and nested reference definitions) are tracked in #3119 rather than expanding this already-bounded fix round. Hosted PostgreSQL failure remains a separate shared baseline gate and must be green before merge.

@Chris0Jeky Chris0Jeky left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Limited review of the shared path resolver, not approval of the whole PR.

This head retains two defects also present on main: inside.startsWith('..') rejects legitimate internal names such as ..notes.md, and the early existsSync return reports wrong-case links as missing on Linux. PR #3128 supplies a path-only correction and eight real-filesystem regressions. Six of the new cases fail on main before the fix; all eight pass afterward, along with the 16 existing fixture cases.

When integrating, retain this PR's parser/masking improvements and apply only #3128's path-resolution hunks plus its new tests. Do not replace this branch's checker with the main-based file.

The PR body still names dcf82d6 as its exact-head qualification, while this review is anchored to 063e23b. Refresh the evidence for the actual final head before claiming merge readiness.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 14b21ee891

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/check-doc-links.mjs
Comment thread scripts/check-doc-links.mjs
Comment thread scripts/check-doc-links.mjs
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Current checkpoint: #3102 remains parked and unmergeable.

  • Head 14b21ee8916a8641891b0769c513311af07e333d is based on stale 92010fbd7c46974fb716d9303b2e0be91bd2d01c, not current main.
  • Visual Regression is red in run 35166527607; no baseline update is justified without an intentional UI change.
  • The three fresh P2 parser-coverage threads were triaged as valid MEDIUM/non-blocking follow-ups and resolved; no fix cascade or new review round is being opened while this bounded PR is red and stale.

A separately scoped GH-2762 follow-up should carry the remaining parser cases before any new merge qualification.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Current-base requalification is complete and remains merge-blocked.

  • Exact head: 1ffb53616c2836087d85349b6e811bfe58d1a901; base: 095e277f4825bfc6526d986a627d7eccc3e52511.
  • Run 35225054976: Load and Concurrency and Visual Regression are red. The load lane retains the cards.move HTTP 409 “Record was updated by another session” conflict; visual failures are broad unchanged archive/board/calendar/home snapshots. The API and browser smoke/cross-browser lanes are green.
  • The PR diff remains limited to documentation-link parser hardening and its tests; it does not touch the load, visual, API, or baseline seams.

These are real red checks, not called flaky, and no baseline update or PR-local code change is justified. The PR remains parked pending ownership/resolution of the shared CI seams.

Copy link
Copy Markdown
Owner Author

@codex address that feedback

Please implement and regression-test the three valid parser residuals already documented in review threads on the current PR branch:

  1. maskCodeWithDiagnostics must not let an unmatched <!-- inside fenced or inline code mask later real links. Comment detection must respect ranges already masked as code.
  2. HTML attribute extraction must not treat href=/src= text embedded inside another quoted attribute value as a real attribute. Replace the raw whole-tag regex with quote-aware attribute tokenization or equivalent bounded parsing.
  3. Reference definitions inside valid block containers must be collected, at minimum nested block quotes and ordinary unordered/ordered list-item prefixes, while preserving original line/index reporting.

Add focused Node regressions that fail on the current head and pass after the implementation. Also retain coverage proving empty inline labels, angle-bracket destinations, and file-only handling of explicit fragments so those already-supported cases are not misclassified as residuals. Keep the change limited to the documentation-link parser/tests; do not modify CI workflows or control-plane files.

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

Testing

  • node --test --test-name-pattern='comment markers inside code|reference definitions inside nested|HTML attribute-like text|empty inline labels' scripts/check-doc-links.test.mjs — 4 focused regressions passed; 28 tests were skipped by the filter.
  • node --check scripts/check-doc-links.mjs
  • node scripts/check-doc-links.mjs — checked 710 Markdown files with zero broken relative links; retained three existing malformed-code-delimiter warnings.
  • git diff --check
  • ⚠️ node --test scripts/check-doc-links.test.mjs — 31 passed and 1 unrelated existing path-resolver assertion failed because Linux reports missing where the test expects wrong case; that separately documented Fix cross-platform document path diagnostics #3128 residual was intentionally left outside this parser-only change.

View task →

Copy link
Copy Markdown
Owner Author

@codex please publish the completed parser residual fix to this PR's existing branch fix/2762-doc-link-parser-hardening. Your summary names local commit 14f77304, but GitHub cannot resolve that commit and the PR head remains 1ffb53616c2836087d85349b6e811bfe58d1a901. Push the exact tested two-file change to the existing branch; do not create a duplicate PR or alter any other file.

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

  • Recreated and committed the requested two-file parser residual fix as local commit 2c580344cbf179921688874f42c7d221a06d88b0 (Harden documentation link parser residuals).
  • Updated comment masking to inspect the already code-masked buffer, preventing <!-- inside inline or fenced code from concealing later real links. scripts/check-doc-links.mjsL135-L183
  • Added block-container parsing for reference definitions nested within block quotes and unordered or ordered list items while preserving original source offsets and line reporting. scripts/check-doc-links.mjsL331-L357 scripts/check-doc-links.mjsL416-L447
  • Replaced whole-tag attribute matching with quote-aware tokenization, preventing href= or src= text embedded in another quoted attribute from being interpreted as a link. scripts/check-doc-links.mjsL359-L386 scripts/check-doc-links.mjsL464-L490
  • Added focused regressions for code-contained comment markers, quoted attribute-like text, nested block-container references, and retained empty-label/angle-destination/fragment handling. scripts/check-doc-links.test.mjsL170-L177 scripts/check-doc-links.test.mjsL192-L223
  • No files other than scripts/check-doc-links.mjs and scripts/check-doc-links.test.mjs were changed.
  • Publication remains blocked: this checkout contained neither commit 14f77304 nor an authenticated GitHub remote. The push failed because no GitHub credentials are available. The remote branch consequently remains at 1ffb53616c2836087d85349b6e811bfe58d1a901; I did not force-push or modify another branch.

Testing

  • node --test --test-name-pattern='comment markers inside code|reference definitions inside nested|HTML attribute-like text|empty inline labels' scripts/check-doc-links.test.mjs — 4 passed, 28 skipped by the filter.
  • node --check scripts/check-doc-links.mjs
  • node scripts/check-doc-links.mjs — checked 710 Markdown files, zero broken relative links, and reported the three existing masking warnings.
  • git diff --check
  • ⚠️ node --test scripts/check-doc-links.test.mjs — 31 passed; the existing Linux path-resolver assertion failed because it receives missing instead of the expected wrong case, the separately documented Fix cross-platform document path diagnostics #3128 residual.
  • git commit -m "Harden documentation link parser residuals" — created commit 2c580344cbf179921688874f42c7d221a06d88b0.
  • git push origin HEAD:fix/2762-doc-link-parser-hardening — failed with could not read Username for 'https://github.com': No such device or address.
  • git ls-remote --heads https://github.com/Chris0Jeky/Taskdeck.git fix/2762-doc-link-parser-hardening — confirmed the GitHub branch still points to 1ffb53616c2836087d85349b6e811bfe58d1a901.

View task →

Copy link
Copy Markdown
Owner Author

@codex publication cannot use your local Git credentials, so please reply here with the exact unified diff for local commit 2c580344cbf179921688874f42c7d221a06d88b0 against PR head 1ffb53616c2836087d85349b6e811bfe58d1a901. Include both complete file patches with full context and no prose inside the fenced diff. I will publish it through the authenticated GitHub API.

@chatgpt-codex-connector

Copy link
Copy Markdown
diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs
index 6d1dbdc7b115b8bdce07c87263eee2473cd41af7..dc084d518c96b81b714430b2344e229230c0f74d 100644
--- a/scripts/check-doc-links.mjs
+++ b/scripts/check-doc-links.mjs
@@ -132,7 +132,6 @@ export function maskCodeWithDiagnostics(markdown) {
     })
   }
 
-  maskHtmlComments(buffer, markdown)
   const fenceMasked = buffer.join('')
   let cursor = 0
   while (cursor < fenceMasked.length) {
@@ -177,6 +176,8 @@ export function maskCodeWithDiagnostics(markdown) {
     cursor = end
   }
 
+  maskHtmlComments(buffer, buffer.join(''))
+
   return { masked: buffer.join(''), diagnostics }
 }
 
@@ -325,6 +326,60 @@ function parseReferenceDestination(text) {
   return text.slice(start, cursor)
 }
 
+function stripBlockContainerPrefix(line) {
+  let cursor = 0
+  while (cursor < line.length) {
+    const quote = /^[ \t]{0,3}>[ \t]?/.exec(line.slice(cursor))
+    if (quote) {
+      cursor += quote[0].length
+      continue
+    }
+
+    const listItem = /^[ \t]{0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+/.exec(line.slice(cursor))
+    if (listItem) {
+      cursor += listItem[0].length
+      continue
+    }
+    break
+  }
+  return { text: line.slice(cursor), offset: cursor }
+}
+
+function findHtmlAttribute(tag, name, start) {
+  let cursor = start
+  while (cursor < tag.length) {
+    while (cursor < tag.length && /\s/.test(tag[cursor])) cursor += 1
+    if (cursor >= tag.length || tag[cursor] === '>' || tag[cursor] === '/') break
+
+    const nameStart = cursor
+    while (cursor < tag.length && !/[\s=/>]/.test(tag[cursor])) cursor += 1
+    const attributeName = tag.slice(nameStart, cursor)
+    while (cursor < tag.length && /\s/.test(tag[cursor])) cursor += 1
+
+    let value = ''
+    let valueStart = cursor
+    if (tag[cursor] === '=') {
+      cursor += 1
+      while (cursor < tag.length && /\s/.test(tag[cursor])) cursor += 1
+      const quote = tag[cursor] === '"' || tag[cursor] === "'" ? tag[cursor] : null
+      if (quote) {
+        cursor += 1
+        valueStart = cursor
+        while (cursor < tag.length && tag[cursor] !== quote) cursor += 1
+        value = tag.slice(valueStart, cursor)
+        if (tag[cursor] === quote) cursor += 1
+      } else {
+        valueStart = cursor
+        while (cursor < tag.length && !/[\s>]/.test(tag[cursor])) cursor += 1
+        value = tag.slice(valueStart, cursor)
+      }
+    }
+
+    if (attributeName.toLowerCase() === name) return { value, valueStart }
+  }
+  return null
+}
+
 function extractLocalTargetsFromMasked(masked) {
   const found = []
   let sequence = 0
@@ -355,27 +410,45 @@ function extractLocalTargetsFromMasked(masked) {
 
   // A reference-style link's path lives in its definition, so validating every
   // local definition covers both links and images without resolving labels.
-  const definitionPattern = /^[ \t]{0,3}\[(?!\^)[^\]\n]+\]:[ \t]*(.*)$/gm
-  let definition
-  while ((definition = definitionPattern.exec(masked)) !== null) {
+  const definitionPattern = /^[ \t]{0,3}\[(?!\^)[^\]\n]+\]:[ \t]*(.*)$/
+  let lineStart = 0
+  while (lineStart < masked.length) {
+    const lineEnd = masked.indexOf('\n', lineStart)
+    const end = lineEnd === -1 ? masked.length : lineEnd
+    const container = stripBlockContainerPrefix(masked.slice(lineStart, end))
+    const definition = definitionPattern.exec(container.text)
+    if (!definition) {
+      if (lineEnd === -1) break
+      lineStart = lineEnd + 1
+      continue
+    }
+
     let destinationText = definition[1]
-    let destinationIndex = definition.index
+    let destinationIndex = lineStart + container.offset + definition.index
     if (destinationText.trim() === '') {
-      const lineBreak = masked.indexOf('\n', definition.index)
+      const lineBreak = masked.indexOf('\n', lineStart)
       if (lineBreak !== -1) {
         const nextLineStart = lineBreak + 1
         const nextLineEnd = masked.indexOf('\n', nextLineStart)
-        const continuation = /^[ \t]*(\S.*)$/.exec(
-          masked.slice(nextLineStart, nextLineEnd === -1 ? masked.length : nextLineEnd),
+        const nextLine = masked.slice(
+          nextLineStart,
+          nextLineEnd === -1 ? masked.length : nextLineEnd,
         )
+        const nextContainer = stripBlockContainerPrefix(nextLine)
+        const continuation = /^[ \t]*(\S.*)$/.exec(nextContainer.text)
         if (continuation) {
           destinationText = continuation[1]
-          destinationIndex = nextLineStart + continuation[0].indexOf(destinationText)
+          destinationIndex =
+            nextLineStart +
+            nextContainer.offset +
+            continuation[0].indexOf(destinationText)
         }
       }
     }
     const target = parseReferenceDestination(destinationText)
     if (target !== null) push(target, destinationIndex)
+    if (lineEnd === -1) break
+    lineStart = lineEnd + 1
   }
 
   const findTagEnd = (start) => {
@@ -412,18 +485,12 @@ function extractLocalTargetsFromMasked(masked) {
     }
 
     const attributeName = opening[1].toLowerCase() === 'a' ? 'href' : 'src'
-    const attributePattern = new RegExp(
-      `\\s${attributeName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
-      'i',
-    )
-    const attribute = attributePattern.exec(tag)
+    const attribute = findHtmlAttribute(tag, attributeName, opening[0].length)
     if (!attribute) {
       cursor = end + 1
       continue
     }
-    const target = attribute[1] ?? attribute[2] ?? attribute[3] ?? ''
-    const valueOffset = attribute[0].indexOf(target)
-    push(target, cursor + attribute.index + Math.max(0, valueOffset))
+    push(attribute.value, cursor + attribute.valueStart)
     cursor = end + 1
   }
 
diff --git a/scripts/check-doc-links.test.mjs b/scripts/check-doc-links.test.mjs
index 5ad2a52cfd3fd04f1c08ded20c64339fe0a1e11b..c8edf5bc0b1f49afedeb37ee5340ab66ecc21596 100644
--- a/scripts/check-doc-links.test.mjs
+++ b/scripts/check-doc-links.test.mjs
@@ -117,6 +117,23 @@ test('image links, angle-bracket targets and titles are all recognised', () => {
   )
 })
 
+test('empty inline labels, angle destinations and explicit fragments retain file targets', () => {
+  const markdown = [
+    '[](./empty-label.md)',
+    '[angle](<./angle destination.md>)',
+    '[fragment](./guide.md#details)',
+  ].join('\n')
+
+  assert.deepEqual(
+    extractLocalTargets(markdown).map(({ target, pathPart }) => ({ target, pathPart })),
+    [
+      { target: './empty-label.md', pathPart: './empty-label.md' },
+      { target: './angle destination.md', pathPart: './angle destination.md' },
+      { target: './guide.md#details', pathPart: './guide.md' },
+    ],
+  )
+})
+
 test('reference-style definitions contribute their local destinations', () => {
   const markdown = [
     '[guide][guide-ref]',
@@ -167,6 +184,20 @@ test('HTML comments do not contribute local destinations', () => {
   assert.deepEqual(extractLocalTargets(markdown), [])
 })
 
+test('comment markers inside code do not mask later real links', () => {
+  const inline = ['`<!--`', '[real](inline.md)'].join('\n')
+  const fenced = ['```html', '<!--', '```', '[real](fenced.md)'].join('\n')
+
+  assert.deepEqual(
+    extractLocalTargets(inline).map(({ pathPart }) => pathPart),
+    ['inline.md'],
+  )
+  assert.deepEqual(
+    extractLocalTargets(fenced).map(({ pathPart }) => pathPart),
+    ['fenced.md'],
+  )
+})
+
 test('HTML href and src attributes contribute local destinations', () => {
   const markdown = [
     '<a class="guide" href="./guide.md">Guide</a>',
@@ -180,6 +211,33 @@ test('HTML href and src attributes contribute local destinations', () => {
   )
 })
 
+test('HTML attribute-like text inside quoted values is ignored', () => {
+  const markdown = [
+    `<a title="fake href='./not-a-link.md'">No destination</a>`,
+    `<img alt='fake src="./not-an-image.svg"'>`,
+  ].join('\n')
+
+  assert.deepEqual(extractLocalTargets(markdown), [])
+})
+
+test('reference definitions inside nested block containers are recognised', () => {
+  const markdown = [
+    '> [quoted]: ./quoted.md',
+    '> - [unordered]: ./unordered.md',
+    '1. > [ordered]:',
+    '   >   ./ordered.md',
+  ].join('\n')
+
+  assert.deepEqual(
+    extractLocalTargets(markdown).map(({ pathPart, line }) => ({ pathPart, line })),
+    [
+      { pathPart: './quoted.md', line: 1 },
+      { pathPart: './unordered.md', line: 2 },
+      { pathPart: './ordered.md', line: 4 },
+    ],
+  )
+})
+
 test('a linked local image checks both the outer document and inner image', () => {
   const targets = extractLocalTargets('[![status](assets/status.svg)](docs/status.md)')

View task →

@Chris0Jeky
Chris0Jeky merged commit 49e7d14 into main Sep 17, 2026
40 checks passed
@Chris0Jeky
Chris0Jeky deleted the fix/2762-doc-link-parser-hardening branch September 17, 2026 21:10
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation Priority III Valuable but unscheduled: residuals, tech-debt, performance, depth work. testing Test coverage, harnesses, regression prevention, and verification pipeline work.

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant