fix: inline comments and disposition replies should not render raw html from claim text #20

Merged
jercik merged 5 commits from fix/escape-html-in-comments into main 2026-10-03 17:29:22 +00:00
Owner

Claim titles and bodies, disposition rationales and fix refs now go through neutralizeHtml, so tags in them show as text while code still shows verbatim. The module is a copy of the one in j4k/review#101. Importing it would have to wait for that PR and a release, so merging the two copies is a follow-up.

Markers now count only at the start of a comment. A <!-- review:claim:… --> quoted inside a claim body no longer takes over a thread or blocks a post.

The rationale now sits below the label as a quoted block, because a fence in it split the label's paragraph. Existing replies still match by marker, so none are reposted.

After merge, repin sourceCommit and its audit record in j4k/align src/review-wrapper-plan.ts, then re-render each consumer's review workflow.

Claim titles and bodies, disposition rationales and fix refs now go through `neutralizeHtml`, so tags in them show as text while code still shows verbatim. The module is a copy of the one in j4k/review#101. Importing it would have to wait for that PR and a release, so merging the two copies is a follow-up. Markers now count only at the start of a comment. A `<!-- review:claim:… -->` quoted inside a claim body no longer takes over a thread or blocks a post. The rationale now sits below the label as a quoted block, because a fence in it split the label's paragraph. Existing replies still match by marker, so none are reposted. After merge, repin `sourceCommit` and its audit record in j4k/align `src/review-wrapper-plan.ts`, then re-render each consumer's review workflow.
fix: neutralize html in inline comments and disposition replies
All checks were successful
commit-msg / commitlint (pull_request) Successful in 28s
Dedupe check / dedupe-check (pull_request) Successful in 43s
Checks / quality-checks (pull_request) Successful in 59s
Review / Review (pull_request_target) Successful in 22m25s
8eeb02f76e
Claim titles and bodies, disposition rationale and fix refs go through
neutralizeHtml, a copy of j4k/review's module. The rationale moves to its own
quoted block. Claim, superseded and disposition markers count only at the
start of a comment, so one quoted in untrusted text is ignored.

Review 01M41BXJNPMEM7K4YTGYH71DHG — head 41cd66132f7ce152dca1b7c1ab93c93be05eb674

Review — j4k-oss/review-wrapper @ 7d42be7032

Scope: diff against base tree 3c6708711f1c
Status: dispatched — coverage complete (3/3 slots terminal)
Facts: current review-wide projection

Computed under:

{
  "abandonment": "abandonment-v1",
  "anchor_recipe": 1,
  "batch_policy": "batch-v1",
  "coverage": "coverage-v3",
  "dispatch_policy": "dispatch-v2",
  "grounder_version": 1,
  "grounding_read_rule": "grounding-read-v1",
  "promotion_policy": "promotion-v1",
  "report": "report-v3",
  "tally": "tally-v1",
  "triage_settle": "triage-settle-v2"
}

Findings (8)

medium — A user-authored claim marker makes the projector modify a human thread

  • claim: 01M41CC43F61YQR67CV4RS6S2W
  • anchor: src/reconcile/conversation-markers.ts (snippet)
    const claimId = CLAIM_MARKER_PATTERN.exec(comment.body)?.groups?.claimId;
  • lens: general-bug · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

I traced claimIdsOf through the sweep in src/reconcile/dispositions.ts and the PullReviewComment type in src/forge/types.ts. The sweep reads comments from every review, while claimIdsOf accepts any comment beginning with a claim marker and PullReviewComment carries no author identity. A PR participant can therefore put at the start of their own inline review comment. When that ID is absent from the current claims, projectSuperseded treats the human conversation as a wrapper thread, posts a superseded reply, and, on instances with the resolution route, resolves its anchor. I reproduced the reply using the committed bundle with a fake forge returning one such comment and claims=[]: reconcile added to review 7. The reproduction used supportsResolution=false, so resolution of a human comment is a static trace rather than an observed API result. Checking the comment author before accepting ownership markers, as the summary reconciler already does, would prevent the spurious write; a forge guarantee that the list contains only wrapper comments would refute this, but listReviewComments is documented and used as the whole review comments.

low — 404 warning buries the reply-only fallback behind an unverified cause list

  • claim: 01M41C5ZFSF1GF37Y0XWEEZFED
  • anchor: src/reconcile/conversation-markers.ts (snippet)
    return `review-wrapper: conversation resolution answered HTTP 404 from ${endpoint}; the version probe reports a -j4k instance but not that this build serves the route, so the cause is that build, a comment deleted since the sweep, or a refused token — projecting replies only for the rest of this pass`;
  • lens: writing-quality · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

I read resolutionDegradationMessage, its use in resolve and unresolve, and the 404 cases in conversation-markers.test.ts and dispositions.test.ts. The emitted warning puts the implementation detail about a "-j4k" version probe ahead of the effect, then says "the cause is" one of three unverified possibilities. The code has only an HTTP status and endpoint; it cannot identify a cause. The operator must parse a long sentence to learn that the pass now posts replies without updating conversation resolution, and it gives no clear next check. The writing standard calls for verifiable claims and for leading with the action and its object. Say directly that conversation resolution returned 404 at the endpoint and that this pass will post replies only; then suggest checking route availability, whether the comment still exists, and token access. That retains the diagnostic facts and possibilities without presenting a guessed cause. I verified the message path statically, not against a live forge.

low — Supersession tests bypass the marker check with resolved anchors

  • claim: 01M41C4A6W41QHZB2XDGY8N38M
  • anchor: src/reconcile/dispositions.test.ts (snippet)
it("leaves an already superseded thread alone", async () => {
    const rec = recorder({
      comments: [
        comment({
          id: 1,
          body: "<!-- review:claim:01JQOLD0000000000000000000 -->",
          resolver: { id: 5 },
        }),
  • lens: test-trimming · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

I traced the two leaves ... superseded thread alone tests in dispositions.test.ts through createDispositionProjector and projectSuperseded. Both fixtures set the anchor's resolver to { id: 5 }. The projector computes open = conversation.anchor.resolver === null, posts the superseded reply only when open && !isSuperseded(conversation), and then returns when !open; the marker is never consulted for either fixture. If the projector stopped checking isSuperseded and posted a duplicate superseded reply to an open thread, both tests would remain green. The direct isSuperseded unit test checks marker parsing, but does not establish that the projector uses it. Give at least one already-marked anchor resolver: null and assert it receives neither a second reply nor an inappropriate resolution action; retain the earlier-review marker variant if cross-review behavior matters. This is a static code trace; I did not execute a mutation.

low — 404 degradation test misses repeated failed resolution attempts

  • claim: 01M41C76CWHMBSSAH5Q8CD7203
  • anchor: src/reconcile/dispositions.test.ts (snippet)
  it("degrades to replies only when the resolution route answers 404 mid-pass", async () => {
    const notFound = Object.assign(new Error("resolution route not found"), {
      status: 404,
      url: RESOLUTION_URL,
    });
  • lens: test-trimming · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

I traced the two-conversation 404 test through createDispositionProjector. The intended behavior is to latch pass.supported = false after the first failed resolveConversation, then project the remaining disposition as a reply without retrying the failed route. The recorder's resolveConversation rejects whenever resolveThrows is set, but rec.resolved records only successful calls; expect(rec.resolved).toStrictEqual([]) therefore cannot distinguish one attempt from two. expect(warn).toHaveBeenCalledWith(...) also accepts repeated identical warnings, and the two reply-count assertion stays true either way. If the pass.supported = false assignment were removed, the second conversation would trigger another 404 and another warning while this test remained green. Record resolution attempts or assert the mock and warning were called exactly once; retain the second conversation so the latch is exercised. No other changed test checks the retry count. This is a static trace; I did not execute a mutation.

low — Superseded-thread comment incorrectly says labelled open threads get no write

  • claim: 01M41C4N2CMVTGD5ZDSBAAZDQJ
  • anchor: src/reconcile/dispositions.ts (snippet)
  // Superseded is a label about head movement, never an adjudication: no anchor
  // correlation and no suppression of the new head's findings. A thread that is
  // already labelled or already closed gets nothing, so the label cannot
  // accumulate one reply per later review.
  • lens: writing-quality · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

I read projectSuperseded, its call from reconcile, and the superseded cases in dispositions.test.ts. The comment says a thread that is already labelled "gets nothing." The code skips a second reply for an open, labelled thread, but then still calls resolve(pass, conversation) when resolution is supported and the anchor is open. This state is possible after an earlier pass labelled a thread but could not use the resolution route. A maintainer relying on the comment could overlook a resolution write or misread a retry. Rewrite the last sentence to say that an open, unlabelled thread gets one label, while any open thread is resolved when the API is available; closed threads get no writes. This preserves the intended limit on duplicate labels. The control flow establishes the mismatch; I did not run a live forge request.

low — Head-file fetch test cannot distinguish a path-anchor fetch

  • claim: 01M41C56MFSZ8FXPYCJWDAS059
  • anchor: src/reconcile/inline.test.ts (snippet)
  it("fetches head file bytes only for snippet anchors", async () => {
    const fake = fakeForge();
    await createInlineReconciler(fake.forge).reconcile({
      ...INPUT,
      findings: findings([
        claim({ id: "01AAA" }),
        claim({
          id: "01BBB",
          anchor: {
            scope: "snippet",
            path: "src/app.ts",
  • lens: test-trimming · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

I traced fetches head file bytes only for snippet anchors through createInlineReconciler. The first fixture claim has the default path anchor for src/app.ts; the second has a snippet anchor for that same path. The test asserts fake.rawFiles equals ["src/app.ts"] and getRawFile was called with that path and SHA. The reconciler caches file text by path, so a regression that fetches for path anchors as well would fetch once for the first claim, reuse that cache entry for the snippet claim, and satisfy both assertions. Path-only findings would then make unnecessary forge file requests, and no other changed test asserts the absence of a fetch for a path-only input. Give the path and snippet anchors distinct paths, or run a path-only case and assert zero getRawFile calls, while retaining the snippet SHA assertion. This is a static trace; I did not execute a mutation.

low — Generated comment test ignores raw code tags from untrusted fields

  • claim: 01M41C69PS48WM3TGG6SQR0V4Z
  • anchor: src/reconcile/inline.test.ts (snippet)
    const bodies = (fake.posted[0] ?? []).map((comment) => comment.body);
    expect(bodies).toHaveLength(400);
    expect(htmlBeyondMarker(bodies)).toStrictEqual([]);
  • lens: test-trimming · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

I inspected the generated posted-comment test, its htmlBeyondMarker helper, and the commentBody formatter. The test promises no raw HTML beyond each leading marker, and the deterministic corpus includes <code> and </code> fragments (131 and 129 of the 400 seed-7 strings respectively, counted with generateMarkdown(400, 7)). Yet htmlBeyondMarker calls foreignHtml, whose EMITTED regex unconditionally filters raw <code> and </code> parser nodes by value. It has no information about whether those tags came from a safe code-span rewrite or escaped title/body text. A regression in comment formatting that lets an untrusted <code> through while still escaping other tags would leave this assertion green and let untrusted HTML change how a posted finding renders. The direct neutralizeHtml unit test checks the sanitizer's own <code> handling, but does not prove that every posted-comment path uses it correctly. Keep the generated case, but make the helper distinguish emitted wrappers from untrusted HTML or add an independent assertion that the input code tags are escaped in posted bodies. This is a static trace plus the corpus count; I did not run the Vitest suite or a mutation.

low — Leading-marker comment claims authorship that the code never verifies

  • claim: 01M41C79EYTYDA8PDAJQFSC2ZF
  • anchor: src/reconcile/inline.ts (snippet)
        // Only a leading marker is one this wrapper wrote; a quoted one is text.
  • lens: writing-quality · arm: default
  • verdicts: 1 valid / 0 invalid / 0 uncertain
  • disposition: none

I read existingClaimMarkers and createInlineReconciler in inline.ts, the marker rules in conversation-markers.ts, and the inline marker tests. existingClaimMarkers collects the body of every review comment and the skip condition checks only body.startsWith(marker); it never checks the author. The comment says a leading marker "is one this wrapper wrote," which claims provenance the code does not establish. A human or another task can write that prefix, so a future reader may mistake the check for an ownership safeguard when a matching comment would suppress posting the claim. Replace it with "Treat only a leading marker as an idempotency marker; a quoted marker is text. This check does not verify the author." That preserves the positional rule and makes the trust limit explicit. The static call path establishes the mismatch; I did not test whether a particular forge user can create the matching comment.

Other claims

  • grounding-pending (0)
  • ungrounded (0)
  • rejected (0)
  • duplicate-of (0)
  • unadjudicated (0)

Coverage

Coverage pass: 01M41BXJQ48JWWTWJF0WY54A4F
Accounting: complete
Slot health: healthy

lens part arm unit status runs loss
general-bug whole default claims-emitted 1 no
writing-quality whole default claims-emitted 1 no
test-trimming whole default claims-emitted 1 no
<!-- review:summary --> **Review** `01M41BXJNPMEM7K4YTGYH71DHG` — head `41cd66132f7ce152dca1b7c1ab93c93be05eb674` # Review — j4k-oss/review-wrapper @ 7d42be7032ab Scope: diff against base tree `3c6708711f1c` Status: dispatched — coverage complete (3/3 slots terminal) Facts: current review-wide projection Computed under: ```json { "abandonment": "abandonment-v1", "anchor_recipe": 1, "batch_policy": "batch-v1", "coverage": "coverage-v3", "dispatch_policy": "dispatch-v2", "grounder_version": 1, "grounding_read_rule": "grounding-read-v1", "promotion_policy": "promotion-v1", "report": "report-v3", "tally": "tally-v1", "triage_settle": "triage-settle-v2" } ``` ## Findings (8) ### medium — A user-authored claim marker makes the projector modify a human thread - claim: `01M41CC43F61YQR67CV4RS6S2W` - anchor: `src/reconcile/conversation-markers.ts` (snippet) ``` const claimId = CLAIM_MARKER_PATTERN.exec(comment.body)?.groups?.claimId; ``` - lens: general-bug · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > I traced claimIdsOf through the sweep in src/reconcile/dispositions.ts and the PullReviewComment type in src/forge/types.ts. The sweep reads comments from every review, while claimIdsOf accepts any comment beginning with a claim marker and PullReviewComment carries no author identity. A PR participant can therefore put <!-- review:claim:01OLD --> at the start of their own inline review comment. When that ID is absent from the current claims, projectSuperseded treats the human conversation as a wrapper thread, posts a superseded reply, and, on instances with the resolution route, resolves its anchor. I reproduced the reply using the committed bundle with a fake forge returning one such comment and claims=[]: reconcile added <!-- review:superseded:01NEW --> to review 7. The reproduction used supportsResolution=false, so resolution of a human comment is a static trace rather than an observed API result. Checking the comment author before accepting ownership markers, as the summary reconciler already does, would prevent the spurious write; a forge guarantee that the list contains only wrapper comments would refute this, but listReviewComments is documented and used as the whole review comments. ### low — 404 warning buries the reply-only fallback behind an unverified cause list - claim: `01M41C5ZFSF1GF37Y0XWEEZFED` - anchor: `src/reconcile/conversation-markers.ts` (snippet) ``` return `review-wrapper: conversation resolution answered HTTP 404 from ${endpoint}; the version probe reports a -j4k instance but not that this build serves the route, so the cause is that build, a comment deleted since the sweep, or a refused token — projecting replies only for the rest of this pass`; ``` - lens: writing-quality · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > I read resolutionDegradationMessage, its use in resolve and unresolve, and the 404 cases in conversation-markers.test.ts and dispositions.test.ts. The emitted warning puts the implementation detail about a "-j4k" version probe ahead of the effect, then says "the cause is" one of three unverified possibilities. The code has only an HTTP status and endpoint; it cannot identify a cause. The operator must parse a long sentence to learn that the pass now posts replies without updating conversation resolution, and it gives no clear next check. The writing standard calls for verifiable claims and for leading with the action and its object. Say directly that conversation resolution returned 404 at the endpoint and that this pass will post replies only; then suggest checking route availability, whether the comment still exists, and token access. That retains the diagnostic facts and possibilities without presenting a guessed cause. I verified the message path statically, not against a live forge. ### low — Supersession tests bypass the marker check with resolved anchors - claim: `01M41C4A6W41QHZB2XDGY8N38M` - anchor: `src/reconcile/dispositions.test.ts` (snippet) ``` it("leaves an already superseded thread alone", async () => { const rec = recorder({ comments: [ comment({ id: 1, body: "<!-- review:claim:01JQOLD0000000000000000000 -->", resolver: { id: 5 }, }), ``` - lens: test-trimming · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > I traced the two `leaves ... superseded thread alone` tests in `dispositions.test.ts` through `createDispositionProjector` and `projectSuperseded`. Both fixtures set the anchor's `resolver` to `{ id: 5 }`. The projector computes `open = conversation.anchor.resolver === null`, posts the superseded reply only when `open && !isSuperseded(conversation)`, and then returns when `!open`; the marker is never consulted for either fixture. If the projector stopped checking `isSuperseded` and posted a duplicate superseded reply to an open thread, both tests would remain green. The direct `isSuperseded` unit test checks marker parsing, but does not establish that the projector uses it. Give at least one already-marked anchor `resolver: null` and assert it receives neither a second reply nor an inappropriate resolution action; retain the earlier-review marker variant if cross-review behavior matters. This is a static code trace; I did not execute a mutation. ### low — 404 degradation test misses repeated failed resolution attempts - claim: `01M41C76CWHMBSSAH5Q8CD7203` - anchor: `src/reconcile/dispositions.test.ts` (snippet) ``` it("degrades to replies only when the resolution route answers 404 mid-pass", async () => { const notFound = Object.assign(new Error("resolution route not found"), { status: 404, url: RESOLUTION_URL, }); ``` - lens: test-trimming · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > I traced the two-conversation 404 test through `createDispositionProjector`. The intended behavior is to latch `pass.supported = false` after the first failed `resolveConversation`, then project the remaining disposition as a reply without retrying the failed route. The recorder's `resolveConversation` rejects whenever `resolveThrows` is set, but `rec.resolved` records only successful calls; `expect(rec.resolved).toStrictEqual([])` therefore cannot distinguish one attempt from two. `expect(warn).toHaveBeenCalledWith(...)` also accepts repeated identical warnings, and the two reply-count assertion stays true either way. If the `pass.supported = false` assignment were removed, the second conversation would trigger another 404 and another warning while this test remained green. Record resolution attempts or assert the mock and warning were called exactly once; retain the second conversation so the latch is exercised. No other changed test checks the retry count. This is a static trace; I did not execute a mutation. ### low — Superseded-thread comment incorrectly says labelled open threads get no write - claim: `01M41C4N2CMVTGD5ZDSBAAZDQJ` - anchor: `src/reconcile/dispositions.ts` (snippet) ``` // Superseded is a label about head movement, never an adjudication: no anchor // correlation and no suppression of the new head's findings. A thread that is // already labelled or already closed gets nothing, so the label cannot // accumulate one reply per later review. ``` - lens: writing-quality · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > I read projectSuperseded, its call from reconcile, and the superseded cases in dispositions.test.ts. The comment says a thread that is already labelled "gets nothing." The code skips a second reply for an open, labelled thread, but then still calls resolve(pass, conversation) when resolution is supported and the anchor is open. This state is possible after an earlier pass labelled a thread but could not use the resolution route. A maintainer relying on the comment could overlook a resolution write or misread a retry. Rewrite the last sentence to say that an open, unlabelled thread gets one label, while any open thread is resolved when the API is available; closed threads get no writes. This preserves the intended limit on duplicate labels. The control flow establishes the mismatch; I did not run a live forge request. ### low — Head-file fetch test cannot distinguish a path-anchor fetch - claim: `01M41C56MFSZ8FXPYCJWDAS059` - anchor: `src/reconcile/inline.test.ts` (snippet) ``` it("fetches head file bytes only for snippet anchors", async () => { const fake = fakeForge(); await createInlineReconciler(fake.forge).reconcile({ ...INPUT, findings: findings([ claim({ id: "01AAA" }), claim({ id: "01BBB", anchor: { scope: "snippet", path: "src/app.ts", ``` - lens: test-trimming · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > I traced `fetches head file bytes only for snippet anchors` through `createInlineReconciler`. The first fixture claim has the default path anchor for `src/app.ts`; the second has a snippet anchor for that same path. The test asserts `fake.rawFiles` equals `["src/app.ts"]` and `getRawFile` was called with that path and SHA. The reconciler caches file text by path, so a regression that fetches for path anchors as well would fetch once for the first claim, reuse that cache entry for the snippet claim, and satisfy both assertions. Path-only findings would then make unnecessary forge file requests, and no other changed test asserts the absence of a fetch for a path-only input. Give the path and snippet anchors distinct paths, or run a path-only case and assert zero `getRawFile` calls, while retaining the snippet SHA assertion. This is a static trace; I did not execute a mutation. ### low — Generated comment test ignores raw code tags from untrusted fields - claim: `01M41C69PS48WM3TGG6SQR0V4Z` - anchor: `src/reconcile/inline.test.ts` (snippet) ``` const bodies = (fake.posted[0] ?? []).map((comment) => comment.body); expect(bodies).toHaveLength(400); expect(htmlBeyondMarker(bodies)).toStrictEqual([]); ``` - lens: test-trimming · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > I inspected the generated posted-comment test, its `htmlBeyondMarker` helper, and the `commentBody` formatter. The test promises no raw HTML beyond each leading marker, and the deterministic corpus includes `<code>` and `</code>` fragments (131 and 129 of the 400 seed-7 strings respectively, counted with `generateMarkdown(400, 7)`). Yet `htmlBeyondMarker` calls `foreignHtml`, whose `EMITTED` regex unconditionally filters raw `<code>` and `</code>` parser nodes by value. It has no information about whether those tags came from a safe code-span rewrite or escaped title/body text. A regression in comment formatting that lets an untrusted `<code>` through while still escaping other tags would leave this assertion green and let untrusted HTML change how a posted finding renders. The direct `neutralizeHtml` unit test checks the sanitizer's own `<code>` handling, but does not prove that every posted-comment path uses it correctly. Keep the generated case, but make the helper distinguish emitted wrappers from untrusted HTML or add an independent assertion that the input code tags are escaped in posted bodies. This is a static trace plus the corpus count; I did not run the Vitest suite or a mutation. ### low — Leading-marker comment claims authorship that the code never verifies - claim: `01M41C79EYTYDA8PDAJQFSC2ZF` - anchor: `src/reconcile/inline.ts` (snippet) ``` // Only a leading marker is one this wrapper wrote; a quoted one is text. ``` - lens: writing-quality · arm: default - verdicts: 1 valid / 0 invalid / 0 uncertain - disposition: none > I read existingClaimMarkers and createInlineReconciler in inline.ts, the marker rules in conversation-markers.ts, and the inline marker tests. existingClaimMarkers collects the body of every review comment and the skip condition checks only body.startsWith(marker); it never checks the author. The comment says a leading marker "is one this wrapper wrote," which claims provenance the code does not establish. A human or another task can write that prefix, so a future reader may mistake the check for an ownership safeguard when a matching comment would suppress posting the claim. Replace it with "Treat only a leading marker as an idempotency marker; a quoted marker is text. This check does not verify the author." That preserves the positional rule and makes the trust limit explicit. The static call path establishes the mismatch; I did not test whether a particular forge user can create the matching comment. ## Other claims - grounding-pending (0) - ungrounded (0) - rejected (0) - duplicate-of (0) - unadjudicated (0) ## Coverage Coverage pass: 01M41BXJQ48JWWTWJF0WY54A4F Accounting: complete Slot health: healthy | lens | part | arm | unit status | runs | loss | | --- | --- | --- | --- | --- | --- | | general-bug | whole | default | claims-emitted | 1 | no | | writing-quality | whole | default | claims-emitted | 1 | no | | test-trimming | whole | default | claims-emitted | 1 | no |
@ -0,0 +73,4 @@
it("leaves code without a live `<` as markdown", () => {
expect(neutralizeHtml("`a<b` and ``a ` <b``", "block")).toBe("`a<b` and ``a ` <b``");
expect(neutralizeHtml("```ts\nconst a = 1;\n```", "block")).toBe("```ts\nconst a = 1;\n```");

low — Fence identity example never reaches the code-without-a-live-tag path
lens test-trimming · arm default · tally 1 valid / 0 invalid / 0 uncertain

Examined neutralizeHtml / neutralizeSegments in src/markdown/neutralize-html.ts and the test "leaves code without a live < as markdown" in src/markdown/neutralize-html.test.ts. Confirmed with a small script that the companion span input `a<b` anda <b`` `` contains<characters that do not matchTAG_START, and that the fenced example contains no <` at all.

neutralizeSegments returns the input unchanged when !text.includes("<"), before parseLiteralRanges runs. Literal ranges are rewritten only when a TAG_START falls inside them; a range with no tag start is left as markdown (if (next === inside) continue).

The test's second example is a TypeScript fence whose body is const a = 1;. That string has no <, so it takes the early return and never tokenizes the fence. A regression that started re-emitting every fenced block as <pre><code>…</code></pre> whenever a fence was parsed would leave this example green, because the fence is never parsed. Other fence tests in this file (re-emits a fenced block on one line, treats an indented block as code, the reported-bypass fences) all place a live tag (<div>, <b>, <img>) inside the fence, so they check the rewrite path rather than "leave this fence as markdown".

The first assertion in the same test does exercise the skip-rewrite path for code spans: those inputs include < that is not a tag start, so parsing runs and the spans stay backticks. The fence line needs the same kind of input. A fence whose content includes a non-tag < — for example ```ts\nconst a = 1 < 2;\n``` — would still expect the markdown fence back, and would fail if that fence were rewritten to HTML.

I did not run neutralizeHtml on these strings (micromark is not installed in the sandbox). The early-return read and the includes("<") check on the fixture are from the source and from printing the fixture string.

claim 01M3Z88X5FJMGY3P8HZST0JVNP of review 01M3Z7F0GWMNWZE1D6BB856D1P

<!-- review:claim:01M3Z88X5FJMGY3P8HZST0JVNP --> **low** — Fence identity example never reaches the code-without-a-live-tag path lens `test-trimming` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > Examined `neutralizeHtml` / `neutralizeSegments` in `src/markdown/neutralize-html.ts` and the test "leaves code without a live `<` as markdown" in `src/markdown/neutralize-html.test.ts`. Confirmed with a small script that the companion span input `` `a<b` and ``a ` <b`` `` contains `<` characters that do not match `TAG_START`, and that the fenced example contains no `<` at all. > > `neutralizeSegments` returns the input unchanged when `!text.includes("<")`, before `parseLiteralRanges` runs. Literal ranges are rewritten only when a `TAG_START` falls inside them; a range with no tag start is left as markdown (`if (next === inside) continue`). > > The test's second example is a TypeScript fence whose body is `const a = 1;`. That string has no `<`, so it takes the early return and never tokenizes the fence. A regression that started re-emitting every fenced block as `<pre><code>…</code></pre>` whenever a fence was parsed would leave this example green, because the fence is never parsed. Other fence tests in this file (`re-emits a fenced block on one line`, `treats an indented block as code`, the reported-bypass fences) all place a live tag (`<div>`, `<b>`, `<img>`) inside the fence, so they check the rewrite path rather than "leave this fence as markdown". > > The first assertion in the same test does exercise the skip-rewrite path for code spans: those inputs include `<` that is not a tag start, so parsing runs and the spans stay backticks. The fence line needs the same kind of input. A fence whose content includes a non-tag `<` — for example ` ```ts\nconst a = 1 < 2;\n``` ` — would still expect the markdown fence back, and would fail if that fence were rewritten to HTML. > > I did not run `neutralizeHtml` on these strings (micromark is not installed in the sandbox). The early-return read and the `includes("<")` check on the fixture are from the source and from printing the fixture string. claim `01M3Z88X5FJMGY3P8HZST0JVNP` of review `01M3Z7F0GWMNWZE1D6BB856D1P`
Author
Owner

Fixed in 483cf33: the fence example now holds a non-tag <, so it passes the early return and parses the fence.

<!-- gh-feedback:reply-to:104683 --> Fixed in 483cf33: the fence example now holds a non-tag `<`, so it passes the early return and parses the fence.
jercik marked this conversation as resolved
Lines 154-155
@ -0,0 +151,5 @@
});
});
// Each case reproduced a bypass of the hand-rolled tokenizer through Forgejo's renderer.
describe("neutralizeHtml closes the reported bypasses", () => {

low — Tests name a past bypass incident instead of the invariant they guard
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

Examined src/markdown/neutralize-html.test.ts (the describe block, the later Forgejo-dialect describe, and it("parses the reported bypasses without raw HTML")), plus the only other homes of the same phrase: src/markdown/markdown-pieces.fixture.ts ("The pieces the reported bypasses and their near misses combine.") and src/markdown/markdown-corpus.fixture.ts ("Deterministic random markdown built from the pieces the reported bypasses combine."). writing-for-agents, State Durable Truth: durable docs use present tense and leave one run's discovery to the pull request; do not describe a rule by contrasting it with the wording it replaced. This repo's AGENTS.md says comments must not reference the current fix ("handles the case from issue #123").

The comment and describe title are incident history: a hand-rolled tokenizer, a report, a reproduction. None of that is in this tree. A reader looking for "the reported bypasses" as a catalog finds only these tests and PIECES. The later comment "each input put a live tag on Forgejo while CommonMark saw it as code" is the actual invariant, written in past tense as if it were the bug write-up.

The cost is that an agent extending the suite or PIECES searches for an incident record instead of the current rule: constructs where a naive tokenizer's code boundary is not Forgejo's, so a tag would render. Proposed replacements that keep every case and the PIECES generator:

// Constructs whose code boundaries a naive tokenizer gets wrong relative to Forgejo.
describe("neutralizeHtml matches Forgejo on constructs a naive tokenizer misreads", () => {

and for the fixtures:

/** Markdown fragments that move CommonMark or Forgejo code boundaries, plus the tags they can expose. */
/** Deterministic random markdown built from those fragments. */

Rename it("parses the reported bypasses without raw HTML") to name the same inputs as the describe, e.g. parses the naive-tokenizer constructs without raw HTML.

Refute by pointing to an in-tree catalog of "reported bypasses" these names refer to. The unified diff was truncated to empty; this file is added in the subject, so the wording is the file's own.

claim 01M3Z7S16FGF6V295AQHZX0WVF of review 01M3Z7F0GWMNWZE1D6BB856D1P

<!-- review:claim:01M3Z7S16FGF6V295AQHZX0WVF --> **low** — Tests name a past bypass incident instead of the invariant they guard lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > Examined `src/markdown/neutralize-html.test.ts` (the describe block, the later Forgejo-dialect describe, and `it("parses the reported bypasses without raw HTML")`), plus the only other homes of the same phrase: `src/markdown/markdown-pieces.fixture.ts` (`"The pieces the reported bypasses and their near misses combine."`) and `src/markdown/markdown-corpus.fixture.ts` (`"Deterministic random markdown built from the pieces the reported bypasses combine."`). writing-for-agents, State Durable Truth: durable docs use present tense and leave one run's discovery to the pull request; do not describe a rule by contrasting it with the wording it replaced. This repo's AGENTS.md says comments must not reference the current fix ("handles the case from issue #123"). > > The comment and describe title are incident history: a hand-rolled tokenizer, a report, a reproduction. None of that is in this tree. A reader looking for "the reported bypasses" as a catalog finds only these tests and `PIECES`. The later comment "each input put a live tag on Forgejo while CommonMark saw it as code" is the actual invariant, written in past tense as if it were the bug write-up. > > The cost is that an agent extending the suite or `PIECES` searches for an incident record instead of the current rule: constructs where a naive tokenizer's code boundary is not Forgejo's, so a tag would render. Proposed replacements that keep every case and the PIECES generator: > > ``` > // Constructs whose code boundaries a naive tokenizer gets wrong relative to Forgejo. > describe("neutralizeHtml matches Forgejo on constructs a naive tokenizer misreads", () => { > ``` > > and for the fixtures: > > ``` > /** Markdown fragments that move CommonMark or Forgejo code boundaries, plus the tags they can expose. */ > ``` > > ``` > /** Deterministic random markdown built from those fragments. */ > ``` > > Rename `it("parses the reported bypasses without raw HTML")` to name the same inputs as the describe, e.g. `parses the naive-tokenizer constructs without raw HTML`. > > Refute by pointing to an in-tree catalog of "reported bypasses" these names refer to. The unified diff was truncated to empty; this file is added in the subject, so the wording is the file's own. claim `01M3Z7S16FGF6V295AQHZX0WVF` of review `01M3Z7F0GWMNWZE1D6BB856D1P`
Author
Owner

Fixed in 483cf33: describe, test, and fixture comments now state the invariant instead of the incident.

<!-- gh-feedback:reply-to:104682 --> Fixed in 483cf33: describe, test, and fixture comments now state the invariant instead of the incident.
jercik marked this conversation as resolved
Lines 10-11
@ -0,0 +7,5 @@
* run of blocks (a quoted body). */
export type MarkdownContext = "inline" | "block";
// Emphasis cannot move a code or destination boundary, and micromark resolves it in
// quadratic time: a 64 KiB body of `*a_` took 8.7 s with it and 15 ms without.

low — Emphasis-disable comment stores one-run timings instead of the durable rule
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

Examined NO_EMPHASIS in src/markdown/parse-literal-ranges.ts and the matching comment in src/markdown/neutralize-html.test.ts (// micromark resolves emphasis in quadratic time; a field this size took 8.7 s with it. above the 64 KiB cost test). writing-for-agents, State Durable Truth: keep causal facts that help apply a rule; leave one run's proof to the pull request. The Bad/Good pair in that section is exactly this shape (a measured incident versus the standing cap).

The load-bearing facts are durable: emphasis cannot move a code or destination boundary, and micromark's attention tokenizer is quadratic. The 8.7 s / 15 ms pair is one machine's proof. It will rot, and it duplicates the cost test, which already requires neutralization of "*a_".repeat(21_845) in under one second. An agent re-enabling attention to "fix" parsing would still have the quadratic fact; they do not need the stale timings to decide.

Proposed comment, same constraint:

// Emphasis cannot move a code or destination boundary, and micromark resolves it in quadratic time.

Drop the timing sentence from the test; keep the assertion. Refute if the numbers are a published budget this repo must not regress (they are not: the test budget is 1000 ms, not 15 ms). File is added in the subject tree.

claim 01M3Z7S74DRHM6CQEEN51A555G of review 01M3Z7F0GWMNWZE1D6BB856D1P

<!-- review:claim:01M3Z7S74DRHM6CQEEN51A555G --> **low** — Emphasis-disable comment stores one-run timings instead of the durable rule lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > Examined `NO_EMPHASIS` in `src/markdown/parse-literal-ranges.ts` and the matching comment in `src/markdown/neutralize-html.test.ts` (`// micromark resolves emphasis in quadratic time; a field this size took 8.7 s with it.` above the 64 KiB cost test). writing-for-agents, State Durable Truth: keep causal facts that help apply a rule; leave one run's proof to the pull request. The Bad/Good pair in that section is exactly this shape (a measured incident versus the standing cap). > > The load-bearing facts are durable: emphasis cannot move a code or destination boundary, and micromark's attention tokenizer is quadratic. The `8.7 s` / `15 ms` pair is one machine's proof. It will rot, and it duplicates the cost test, which already requires neutralization of `"*a_".repeat(21_845)` in under one second. An agent re-enabling attention to "fix" parsing would still have the quadratic fact; they do not need the stale timings to decide. > > Proposed comment, same constraint: > > ``` > // Emphasis cannot move a code or destination boundary, and micromark resolves it in quadratic time. > ``` > > Drop the timing sentence from the test; keep the assertion. Refute if the numbers are a published budget this repo must not regress (they are not: the test budget is 1000 ms, not 15 ms). File is added in the subject tree. claim `01M3Z7S74DRHM6CQEEN51A555G` of review `01M3Z7F0GWMNWZE1D6BB856D1P`
Author
Owner

Fixed in 483cf33: the comment states the durable rule; timings removed here and from the cost test.

<!-- gh-feedback:reply-to:104684 --> Fixed in 483cf33: the comment states the durable rule; timings removed here and from the cost test.
jercik marked this conversation as resolved
Lines 49-50
@ -0,0 +46,5 @@
["definitionDestinationLiteral", "destination"],
]);
/** Source CommonMark + GFM tables keep literal: a code span or block with the text it
* displays, or an angle-bracket link destination with the text between its brackets. */

low — LiteralRange JSDoc is ungrammatical so the type's meaning is unclear
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

Examined the LiteralRange interface and its consumers: parseLiteralRanges in the same file, rewrite / neutralizeSegments in src/markdown/neutralize-html.ts. writing-for-agents, Use Precise Language: make claims verifiable and define project-local terms on first use.

"Source CommonMark + GFM tables keep literal" does not parse. It is missing the noun (ranges of source) that the type is. A reader has to guess whether LiteralRange is a slice of source, the displayed text, or a parser event. The rest of the comment does name the three kinds and that value is displayed text / text between brackets; that part should stay.

Proposed replacement:

/** A source range CommonMark plus GFM tables treat as literal: a code span or block (with
 *  the text it displays) or an angle-bracket destination (the text between the brackets). */

That keeps kind/value semantics and makes start/end obviously offsets into source. Refute by showing a house convention where this telegraphic style is used for type docs; surrounding JSDoc in this file is written in full sentences. File is added in the subject tree.

claim 01M3Z7SCVHVKW4S416QPBTS37C of review 01M3Z7F0GWMNWZE1D6BB856D1P

<!-- review:claim:01M3Z7SCVHVKW4S416QPBTS37C --> **low** — LiteralRange JSDoc is ungrammatical so the type's meaning is unclear lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > Examined the `LiteralRange` interface and its consumers: `parseLiteralRanges` in the same file, `rewrite` / `neutralizeSegments` in `src/markdown/neutralize-html.ts`. writing-for-agents, Use Precise Language: make claims verifiable and define project-local terms on first use. > > "Source CommonMark + GFM tables keep literal" does not parse. It is missing the noun (ranges of source) that the type is. A reader has to guess whether `LiteralRange` is a slice of source, the displayed text, or a parser event. The rest of the comment *does* name the three kinds and that `value` is displayed text / text between brackets; that part should stay. > > Proposed replacement: > > ``` > /** A source range CommonMark plus GFM tables treat as literal: a code span or block (with > * the text it displays) or an angle-bracket destination (the text between the brackets). */ > ``` > > That keeps kind/value semantics and makes `start`/`end` obviously offsets into source. Refute by showing a house convention where this telegraphic style is used for type docs; surrounding JSDoc in this file is written in full sentences. File is added in the subject tree. claim `01M3Z7SCVHVKW4S416QPBTS37C` of review `01M3Z7F0GWMNWZE1D6BB856D1P`
Author
Owner

Fixed in 483cf33: LiteralRange JSDoc rewritten as a full sentence.

<!-- gh-feedback:reply-to:104685 --> Fixed in 483cf33: LiteralRange JSDoc rewritten as a full sentence.
jercik marked this conversation as resolved
Lines 39-40
@ -0,0 +36,5 @@
return rawHtmlNodes(markdown).filter((value) => !EMITTED.test(value));
}
/** Inputs whose neutralized form still holds raw HTML a parser sees, or any `<` that could
* open a tag outside the HTML `neutralizeHtml` reports it emitted. */

low — rawHtmlViolations JSDoc describes inputs but the function returns diagnostics
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

Examined rawHtmlViolations in src/markdown/raw-html.fixture.ts and its callers in src/markdown/neutralize-html.test.ts, which assert toStrictEqual([]). writing-for-agents, Use Precise Language: make claims verifiable. The function body pushes strings such as ${JSON.stringify(input)} parses to raw HTML ${JSON.stringify(foreign)} and ${JSON.stringify(input)} keeps a live < in ${JSON.stringify(output)}.

The JSDoc reads as if the return value is the violating inputs. It is a list of diagnostic sentences. An agent logging or slicing the result as original markdown (to feed back into neutralizeHtml, or to extend PIECES) will not get those inputs. The two check kinds (mdast raw-HTML nodes versus leftover tag-opening < outside emitted HTML) are also collapsed into one "or" without saying each entry names which check failed.

Proposed replacement that keeps both checks:

/** Diagnostic strings for inputs whose neutralized form still has raw HTML, or a tag-opening
 *  `<` outside HTML this module marked as emitted. */

Refute if a house convention treats "Inputs whose…" as naming the filter rather than the array element type; htmlBeyondMarker in src/markdown/comment-html.fixture.ts documents the actual returned values ("Raw HTML a parser finds…"). File is added in the subject tree.

claim 01M3Z7T32BAEG6A7R8WM1XSBFX of review 01M3Z7F0GWMNWZE1D6BB856D1P

<!-- review:claim:01M3Z7T32BAEG6A7R8WM1XSBFX --> **low** — rawHtmlViolations JSDoc describes inputs but the function returns diagnostics lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > Examined `rawHtmlViolations` in `src/markdown/raw-html.fixture.ts` and its callers in `src/markdown/neutralize-html.test.ts`, which assert `toStrictEqual([])`. writing-for-agents, Use Precise Language: make claims verifiable. The function body pushes strings such as `${JSON.stringify(input)} parses to raw HTML ${JSON.stringify(foreign)}` and `${JSON.stringify(input)} keeps a live < in ${JSON.stringify(output)}`. > > The JSDoc reads as if the return value *is* the violating inputs. It is a list of diagnostic sentences. An agent logging or slicing the result as original markdown (to feed back into `neutralizeHtml`, or to extend `PIECES`) will not get those inputs. The two check kinds (mdast raw-HTML nodes versus leftover tag-opening `<` outside emitted HTML) are also collapsed into one "or" without saying each entry names which check failed. > > Proposed replacement that keeps both checks: > > ``` > /** Diagnostic strings for inputs whose neutralized form still has raw HTML, or a tag-opening > * `<` outside HTML this module marked as emitted. */ > ``` > > Refute if a house convention treats "Inputs whose…" as naming the filter rather than the array element type; `htmlBeyondMarker` in `src/markdown/comment-html.fixture.ts` documents the actual returned values ("Raw HTML a parser finds…"). File is added in the subject tree. claim `01M3Z7T32BAEG6A7R8WM1XSBFX` of review `01M3Z7F0GWMNWZE1D6BB856D1P`
Author
Owner

Fixed in 483cf33: rawHtmlViolations JSDoc now says it returns diagnostic strings.

<!-- gh-feedback:reply-to:104686 --> Fixed in 483cf33: rawHtmlViolations JSDoc now says it returns diagnostic strings.
jercik marked this conversation as resolved
docs: reword comments and tighten fence test per review
All checks were successful
commit-msg / commitlint (pull_request) Successful in 18s
Dedupe check / dedupe-check (pull_request) Successful in 26s
Checks / quality-checks (pull_request) Successful in 33s
Review / Review (pull_request_target) Successful in 8m10s
483cf33e03
Lines 6-10
@ -0,0 +3,8 @@
import { parseLiteralRanges } from "./parse-literal-ranges.ts";
/** A `<` that can open raw HTML in a CommonMark renderer: `</`, `<!`, `<?`, or a tag name
* followed by whatever may continue a tag (whitespace, `/`, `>`, end of text). Every other
* `<` — `a < b`, `<https://x>`, `<user@example.com>` — is text in every dialect, so
* autolinks survive untouched. Names take any letter, not just ASCII: goldmark matches
* `<script` case-insensitively, and Go folds `ſ` to `s`. `\s` is the Unicode class, a
* superset of what any renderer counts as whitespace. */

low — HTML sanitizer comment claims guarantees for every Markdown dialect
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

I read the TAG_START comment, parseLiteralRanges, and the neutralizeHtml tests. The implementation uses a CommonMark parser with GFM tables and handles named Forgejo extensions; the tests exercise those cases. The comment then says every other “<” is text “in every dialect” and that JavaScript whitespace covers what “any renderer” counts. Those universal claims go beyond the renderer behavior established by the code and tests, so a maintainer may assume this sanitizer has been vetted for an unrelated Markdown dialect. The writing standard asks for verifiable, precisely scoped claims. Say that the listed examples are text or autolinks under the supported CommonMark/Forgejo rendering paths, and describe the whitespace choice as conservative for those paths; retain the useful explanation of why URL and email autolinks are left alone. I did not test other dialects, so this finding is about the unsupported breadth of the documentation, not a demonstrated escaping failure.

claim 01M3ZADYYX9WVKTC33RDTWW47C of review 01M3ZA5YDVGQRGVDHSD059P8MZ

<!-- review:claim:01M3ZADYYX9WVKTC33RDTWW47C --> **low** — HTML sanitizer comment claims guarantees for every Markdown dialect lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I read the TAG_START comment, parseLiteralRanges, and the neutralizeHtml tests. The implementation uses a CommonMark parser with GFM tables and handles named Forgejo extensions; the tests exercise those cases. The comment then says every other “<” is text “in every dialect” and that JavaScript whitespace covers what “any renderer” counts. Those universal claims go beyond the renderer behavior established by the code and tests, so a maintainer may assume this sanitizer has been vetted for an unrelated Markdown dialect. The writing standard asks for verifiable, precisely scoped claims. Say that the listed examples are text or autolinks under the supported CommonMark/Forgejo rendering paths, and describe the whitespace choice as conservative for those paths; retain the useful explanation of why URL and email autolinks are left alone. I did not test other dialects, so this finding is about the unsupported breadth of the documentation, not a demonstrated escaping failure. claim `01M3ZADYYX9WVKTC33RDTWW47C` of review `01M3ZA5YDVGQRGVDHSD059P8MZ`
Author
Owner

Fixed in ebf66cc: the TAG_START comment now scopes its claims to CommonMark and Forgejo's renderer.

<!-- gh-feedback:reply-to:105044 --> Fixed in ebf66cc: the TAG_START comment now scopes its claims to CommonMark and Forgejo's renderer.
jercik marked this conversation as resolved
@ -18,3 +17,3 @@
claimMarker(claim.id),
`**${claim.severity}** — ${claim.title}`,
`**${claim.severity}** — ${neutralizeHtml(claim.title, "inline")}`,
`lens \`${claim.lens}\` · arm \`${claim.arm}\` · ${tally}${wavering}`,

low — Inline findings expose triage metadata without explaining its terms
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

I read commentBody and createInlineReconciler in inline.ts, the expected posted comment in inline.test.ts, and the local README. Every PR finding comment emits a line such as “lens correctness · arm arm-a · tally 2 valid / 1 invalid / 0 uncertain — wavering,” with no explanation of what an arm is, what the counts measure, or what “wavering” means for the finding. The README does not define those terms for a PR reader. That leaves readers unable to interpret the apparent confidence signal beside the severity and title. The writing standard says to define project-local terms on first use and make wording operational. Replace the bare labels with a short explanation of what the tally counts and what “wavering” means; explain or omit the arm identifier in the reader-facing line, while retaining the claim/review identifiers for traceability. The test establishes the exact published text. I did not inspect the external review-service package that assigns these fields, so the correct definitions must come from that contract.

claim 01M3ZACZTYPD0RTXVP0KE71NJQ of review 01M3ZA5YDVGQRGVDHSD059P8MZ

<!-- review:claim:01M3ZACZTYPD0RTXVP0KE71NJQ --> **low** — Inline findings expose triage metadata without explaining its terms lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I read commentBody and createInlineReconciler in inline.ts, the expected posted comment in inline.test.ts, and the local README. Every PR finding comment emits a line such as “lens `correctness` · arm `arm-a` · tally 2 valid / 1 invalid / 0 uncertain — wavering,” with no explanation of what an arm is, what the counts measure, or what “wavering” means for the finding. The README does not define those terms for a PR reader. That leaves readers unable to interpret the apparent confidence signal beside the severity and title. The writing standard says to define project-local terms on first use and make wording operational. Replace the bare labels with a short explanation of what the tally counts and what “wavering” means; explain or omit the arm identifier in the reader-facing line, while retaining the claim/review identifiers for traceability. The test establishes the exact published text. I did not inspect the external review-service package that assigns these fields, so the correct definitions must come from that contract. claim `01M3ZACZTYPD0RTXVP0KE71NJQ` of review `01M3ZA5YDVGQRGVDHSD059P8MZ`
Author
Owner

This line is not part of this PR: the lens/arm/tally/wavering line is identical on main (src/reconcile/inline.ts:11-20); the PR only changes how the title and body are neutralized. The terms come from the external review service's contract, which the finding says it did not inspect, and the published comment format is parsed by downstream tooling, so redefining it belongs in its own change.

<!-- gh-feedback:reply-to:105045 --> This line is not part of this PR: the lens/arm/tally/wavering line is identical on main (src/reconcile/inline.ts:11-20); the PR only changes how the title and body are neutralized. The terms come from the external review service's contract, which the finding says it did not inspect, and the published comment format is parsed by downstream tooling, so redefining it belongs in its own change.

superseded by review 01M3ZAP3N3GZTRF3T09RWJKY6E for head ebf66cc54af2a7ca4a5434ad472adf6a5c93f80b

<!-- review:superseded:01M3ZAP3N3GZTRF3T09RWJKY6E --> superseded by review `01M3ZAP3N3GZTRF3T09RWJKY6E` for head `ebf66cc54af2a7ca4a5434ad472adf6a5c93f80b`
docs: scope the tag-start comment to the supported renderers
All checks were successful
commit-msg / commitlint (pull_request) Successful in 27s
Dedupe check / dedupe-check (pull_request) Successful in 45s
Checks / quality-checks (pull_request) Successful in 48s
Review / Review (pull_request_target) Successful in 9m10s
ebf66cc54a
@ -0,0 +1,21 @@
import { PIECES } from "./markdown-pieces.fixture.ts";
/** Deterministic random markdown built from those fragments. */

low — generateMarkdown docstring refers to "those fragments" with no antecedent in its file
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

Examined: src/markdown/markdown-corpus.fixture.ts in full (it imports PIECES from ./markdown-pieces.fixture.ts and defines generateMarkdown) and its callers in neutralize-html.test.ts, conversation-markers.test.ts, and inline.test.ts.

What the passage says: the only doc comment in the file is "Deterministic random markdown built from those fragments." Nothing earlier in the file names any fragments. The phrase only makes sense to a reader who has just come from markdown-pieces.fixture.ts, whose comment reads "Markdown fragments that move CommonMark or Forgejo code boundaries, plus the tags they can expose."

What goes wrong for the reader: an editor hovering generateMarkdown in a test, or an agent reading this file alone, gets a pointer to nothing, and the docstring also leaves out the parameters' meaning. The writing-for-agents skill says to write from context the reader already has; a doc comment is read on its own, out of file order.

Proposed correction: "count deterministic markdown strings of 1–24 random PIECES fragments each, reproducible from seed." This names the source, keeps "deterministic", and adds the length range that is otherwise only visible in 1 + draw(24).

claim 01M3ZATH019SDZY72NDBJT1WKX of review 01M3ZAP3N3GZTRF3T09RWJKY6E

<!-- review:claim:01M3ZATH019SDZY72NDBJT1WKX --> **low** — `generateMarkdown` docstring refers to "those fragments" with no antecedent in its file lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > Examined: src/markdown/markdown-corpus.fixture.ts in full (it imports `PIECES` from ./markdown-pieces.fixture.ts and defines `generateMarkdown`) and its callers in neutralize-html.test.ts, conversation-markers.test.ts, and inline.test.ts. > > What the passage says: the only doc comment in the file is "Deterministic random markdown built from those fragments." Nothing earlier in the file names any fragments. The phrase only makes sense to a reader who has just come from markdown-pieces.fixture.ts, whose comment reads "Markdown fragments that move CommonMark or Forgejo code boundaries, plus the tags they can expose." > > What goes wrong for the reader: an editor hovering `generateMarkdown` in a test, or an agent reading this file alone, gets a pointer to nothing, and the docstring also leaves out the parameters' meaning. The writing-for-agents skill says to write from context the reader already has; a doc comment is read on its own, out of file order. > > Proposed correction: "`count` deterministic markdown strings of 1–24 random `PIECES` fragments each, reproducible from `seed`." This names the source, keeps "deterministic", and adds the length range that is otherwise only visible in `1 + draw(24)`. claim `01M3ZATH019SDZY72NDBJT1WKX` of review `01M3ZAP3N3GZTRF3T09RWJKY6E`
Author
Owner

Fixed in f71add8: the docstring now names PIECES, the length range, and the seed.

<!-- gh-feedback:reply-to:105106 --> Fixed in f71add8: the docstring now names PIECES, the length range, and the seed.
jercik marked this conversation as resolved
Lines 239-240
@ -0,0 +238,5 @@
});
});
// Forgejo's math, definition lists, and heading attributes read code boundaries differently
// from CommonMark; each input put a live tag on Forgejo while CommonMark saw it as code.

low — Test-group comment narrates past incidents ("each input put a live tag on Forgejo") and repeats the neutralizeHtml docstring
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

Examined: the comment above describe("neutralizeHtml holds where Forgejo reads code differently", …) in src/markdown/neutralize-html.test.ts, the four tests under it, and the neutralizeHtml docstring in neutralize-html.ts, which already says "Forgejo's math, definition lists, and heading attributes draw code boundaries where CommonMark does not."

What the passage says: "Forgejo's math, definition lists, and heading attributes read code boundaries differently from CommonMark; each input put a live tag on Forgejo while CommonMark saw it as code."

What goes wrong for the reader: the second clause is past-tense discovery history. It describes what these inputs did at some earlier point, presumably before this module existed, rather than what the test checks now. A reader can't tell whether "put a live tag on Forgejo" is about the raw input or about some earlier neutralizer's output. The first clause restates the source docstring and the describe title word for word in substance. The writing-for-agents skill's "State Durable Truth" section says to describe the current system in timeless present tense and leave discovery to commits. Its "One Idea, One Place" section says not to repeat what a nearby sentence already states.

Proposed correction: delete the comment, since the describe title and the docstring already carry the first clause. If the case's origin matters, use one present-tense line: "Unescaped, each input renders a live tag on Forgejo where CommonMark sees code." That keeps the reason the cases exist and drops the narrative.

The sibling comment above the "naive tokenizer" group ("// Constructs whose code boundaries a naive tokenizer gets wrong relative to Forgejo.") likewise only restates its describe title and could go too.

claim 01M3ZAV0S8D8ASCR4CGW9KKDP7 of review 01M3ZAP3N3GZTRF3T09RWJKY6E

<!-- review:claim:01M3ZAV0S8D8ASCR4CGW9KKDP7 --> **low** — Test-group comment narrates past incidents ("each input put a live tag on Forgejo") and repeats the `neutralizeHtml` docstring lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > Examined: the comment above `describe("neutralizeHtml holds where Forgejo reads code differently", …)` in src/markdown/neutralize-html.test.ts, the four tests under it, and the `neutralizeHtml` docstring in neutralize-html.ts, which already says "Forgejo's math, definition lists, and heading attributes draw code boundaries where CommonMark does not." > > What the passage says: "Forgejo's math, definition lists, and heading attributes read code boundaries differently from CommonMark; each input put a live tag on Forgejo while CommonMark saw it as code." > > What goes wrong for the reader: the second clause is past-tense discovery history. It describes what these inputs did at some earlier point, presumably before this module existed, rather than what the test checks now. A reader can't tell whether "put a live tag on Forgejo" is about the raw input or about some earlier neutralizer's output. The first clause restates the source docstring and the `describe` title word for word in substance. The writing-for-agents skill's "State Durable Truth" section says to describe the current system in timeless present tense and leave discovery to commits. Its "One Idea, One Place" section says not to repeat what a nearby sentence already states. > > Proposed correction: delete the comment, since the `describe` title and the docstring already carry the first clause. If the case's origin matters, use one present-tense line: "Unescaped, each input renders a live tag on Forgejo where CommonMark sees code." That keeps the reason the cases exist and drops the narrative. > > The sibling comment above the "naive tokenizer" group ("// Constructs whose code boundaries a naive tokenizer gets wrong relative to Forgejo.") likewise only restates its `describe` title and could go too. claim `01M3ZAV0S8D8ASCR4CGW9KKDP7` of review `01M3ZAP3N3GZTRF3T09RWJKY6E`
Author
Owner

Fixed in f71add8: removed both restating comments; the describe titles carry the meaning.

<!-- gh-feedback:reply-to:105107 --> Fixed in f71add8: removed both restating comments; the describe titles carry the meaning.
jercik marked this conversation as resolved
Lines 56-58
@ -0,0 +54,6 @@
/** A literal range that would carry a live `<`, rewritten so it no longer does. Code is
* re-emitted as HTML with its content escaped, so it still shows verbatim but stays text
* however the renderer reads the surrounding markdown. A block is one line: an HTML block
* opened by `<pre` ends at the line holding `</pre>`, so no container prefix or blank line
* inside it can matter. */

low — rewrite docstring says "A block is one line" as if describing the input, when it means the re-emitted <pre> is written on one line
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

Examined: the rewrite function in src/markdown/neutralize-html.ts, its block branch (escapeText(range.value).replaceAll("\n", "&#10;") wrapped in <pre><code>…</code></pre>), and the tests "re-emits a fenced block on one line" and "keeps an indented block inside its list item".

What the passage says: "A block is one line: an HTML block opened by <pre ends at the line holding </pre>, so no container prefix or blank line inside it can matter."

What goes wrong for the reader: the subject "A block" most naturally means the input code block, which is usually several lines, so the sentence reads as false. The intended fact is about the output. The function puts the whole block on a single line by turning each newline into &#10;, and that is why the CommonMark HTML-block rule (which ends at the line holding </pre>) cannot be cut short by a > prefix or blank line inside the content. The mechanism that makes this true, the &#10; replacement, goes unmentioned, so a maintainer who "tidies" the output back to real newlines gets no warning from this comment. The writing-for-agents skill asks to lead with the action and its object and to keep the reason that helps a future editor apply the rule.

Proposed correction: "A code block is re-emitted on a single line, its newlines written as &#10;: an HTML block opened by <pre ends at the line holding </pre>, so no container prefix or blank line can fall inside it." This keeps the CommonMark rationale and names the step the rationale depends on.

claim 01M3ZAV0CBZ8B0Y0XW09D6KYV3 of review 01M3ZAP3N3GZTRF3T09RWJKY6E

<!-- review:claim:01M3ZAV0CBZ8B0Y0XW09D6KYV3 --> **low** — `rewrite` docstring says "A block is one line" as if describing the input, when it means the re-emitted `<pre>` is written on one line lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > Examined: the `rewrite` function in src/markdown/neutralize-html.ts, its block branch (`escapeText(range.value).replaceAll("\n", "&#10;")` wrapped in `<pre><code>…</code></pre>`), and the tests "re-emits a fenced block on one line" and "keeps an indented block inside its list item". > > What the passage says: "A block is one line: an HTML block opened by `<pre` ends at the line holding `</pre>`, so no container prefix or blank line inside it can matter." > > What goes wrong for the reader: the subject "A block" most naturally means the input code block, which is usually several lines, so the sentence reads as false. The intended fact is about the output. The function puts the whole block on a single line by turning each newline into `&#10;`, and that is why the CommonMark HTML-block rule (which ends at the line holding `</pre>`) cannot be cut short by a `> ` prefix or blank line inside the content. The mechanism that makes this true, the `&#10;` replacement, goes unmentioned, so a maintainer who "tidies" the output back to real newlines gets no warning from this comment. The writing-for-agents skill asks to lead with the action and its object and to keep the reason that helps a future editor apply the rule. > > Proposed correction: "A code block is re-emitted on a single line, its newlines written as `&#10;`: an HTML block opened by `<pre` ends at the line holding `</pre>`, so no container prefix or blank line can fall inside it." This keeps the CommonMark rationale and names the step the rationale depends on. claim `01M3ZAV0CBZ8B0Y0XW09D6KYV3` of review `01M3ZAP3N3GZTRF3T09RWJKY6E`
Author
Owner

Fixed in f71add8: the docstring says the block is re-emitted on one line with newlines written as .

<!-- gh-feedback:reply-to:105109 --> Fixed in f71add8: the docstring says the block is re-emitted on one line with newlines written as &#10;.
jercik marked this conversation as resolved
Lines 132-135
@ -0,0 +130,7 @@
return segments;
}
/** Untrusted markdown with no `<` left that could open raw HTML, in prose, code, or link
* destinations, so it holds whatever the renderer decides is code: Forgejo's math,
* definition lists, and heading attributes draw code boundaries where CommonMark does not.
* The parser only keeps code and destinations intact where the two agree. */

low — neutralizeHtml docstring states its guarantee through an unresolved "it holds" and credits "the parser" with a decision it does not make
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

Examined: the exported neutralizeHtml docstring in src/markdown/neutralize-html.ts, its implementation (neutralizeSegments, rewrite, escapeProse), and the test group "neutralizeHtml holds where Forgejo reads code differently" in neutralize-html.test.ts.

What the passage says: "Untrusted markdown with no < left that could open raw HTML, in prose, code, or link destinations, so it holds whatever the renderer decides is code ... The parser only keeps code and destinations intact where the two agree."

What goes wrong for the reader: this is the public contract that callers (quoteUntrusted, dispositionBody, commentBody in inline.ts) rely on, but its key clause is hard to parse. "it holds" has no antecedent noun — the reader must infer that "it" means the no-live-< guarantee, not the markdown. The last sentence attributes the outcome to "the parser", but micromark only reports CommonMark ranges; it cannot know where Forgejo agrees. What the code actually does is escape every tag-opening < regardless of where it sits, so the safety guarantee is unconditional, while verbatim display of code/destinations is only preserved where Forgejo and CommonMark draw the same boundaries (the tests show $$ bold `` becoming $<code>&#36; ...</code>, i.e. not verbatim). The writing-for-agents skill asks for verifiable claims and one term per concept; here the safety guarantee and the fidelity caveat are fused into one sentence with an ambiguous subject.

Proposed correction: "Untrusted markdown rewritten so no < can open raw HTML, whether it sits in prose, code, or a link destination. The guarantee holds wherever the renderer draws code boundaries, including where Forgejo's math, definition lists, and heading attributes diverge from CommonMark; code and destinations display verbatim only where the two agree." This keeps all three facts (scope of escaping, Forgejo divergences, fidelity caveat) and separates the unconditional guarantee from the conditional one.

Refuted if the intended meaning of "the parser only keeps" is something other than the verbatim-display caveat; the tests support the reading above.

claim 01M3ZASQ0PX4YGYW51T8KD0EXG of review 01M3ZAP3N3GZTRF3T09RWJKY6E

<!-- review:claim:01M3ZASQ0PX4YGYW51T8KD0EXG --> **low** — `neutralizeHtml` docstring states its guarantee through an unresolved "it holds" and credits "the parser" with a decision it does not make lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > Examined: the exported `neutralizeHtml` docstring in src/markdown/neutralize-html.ts, its implementation (`neutralizeSegments`, `rewrite`, `escapeProse`), and the test group "neutralizeHtml holds where Forgejo reads code differently" in neutralize-html.test.ts. > > What the passage says: "Untrusted markdown with no `<` left that could open raw HTML, in prose, code, or link destinations, so it holds whatever the renderer decides is code ... The parser only keeps code and destinations intact where the two agree." > > What goes wrong for the reader: this is the public contract that callers (`quoteUntrusted`, `dispositionBody`, `commentBody` in inline.ts) rely on, but its key clause is hard to parse. "it holds" has no antecedent noun — the reader must infer that "it" means the no-live-`<` guarantee, not the markdown. The last sentence attributes the outcome to "the parser", but micromark only reports CommonMark ranges; it cannot know where Forgejo agrees. What the code actually does is escape every tag-opening `<` regardless of where it sits, so the safety guarantee is unconditional, while verbatim display of code/destinations is only preserved where Forgejo and CommonMark draw the same boundaries (the tests show `$`$ <b>bold</b> `` becoming `$<code>&#36; ...</code>`, i.e. not verbatim). The writing-for-agents skill asks for verifiable claims and one term per concept; here the safety guarantee and the fidelity caveat are fused into one sentence with an ambiguous subject. > > Proposed correction: "Untrusted markdown rewritten so no `<` can open raw HTML, whether it sits in prose, code, or a link destination. The guarantee holds wherever the renderer draws code boundaries, including where Forgejo's math, definition lists, and heading attributes diverge from CommonMark; code and destinations display verbatim only where the two agree." This keeps all three facts (scope of escaping, Forgejo divergences, fidelity caveat) and separates the unconditional guarantee from the conditional one. > > Refuted if the intended meaning of "the parser only keeps" is something other than the verbatim-display caveat; the tests support the reading above. claim `01M3ZASQ0PX4YGYW51T8KD0EXG` of review `01M3ZAP3N3GZTRF3T09RWJKY6E`
Author
Owner

Fixed in f71add8: the docstring separates the unconditional escaping guarantee from the conditional verbatim display.

<!-- gh-feedback:reply-to:105108 --> Fixed in f71add8: the docstring separates the unconditional escaping guarantee from the conditional verbatim display.
jercik marked this conversation as resolved
@ -0,0 +6,4 @@
import { neutralizeSegments } from "./neutralize-html.ts";
/** The only HTML `neutralizeHtml` emits: a re-emitted code span or one-line code block. */
// The parser already decided the block's indentation stays under four columns.

low — EMITTED comment about indentation never says it explains the [ \t]* allowance or which parser it means
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

Examined: the EMITTED regex in src/markdown/raw-html.fixture.ts (/^(?:<code>|<\/code>|[ \t]*<pre><code>[^<]*<\/code><\/pre>)$/u), its JSDoc ("The only HTML neutralizeHtml emits: a re-emitted code span or one-line code block."), and the test "strips fence indentation from block content" in neutralize-html.test.ts, which expects output " <pre><code>…" with the fence's leading spaces kept.

What the passage says: a line comment between the JSDoc and the declaration: "The parser already decided the block's indentation stays under four columns."

What goes wrong for the reader: the comment never says which part of the regex it explains, or why under four columns matters. The connection only shows up after reading the test above: rewrite keeps the fence's own indentation before <pre>, so the fixture has to accept leading blanks. That's safe only because a fence indented four or more columns would have parsed as indented code, so the emitted line still opens an HTML block. "The parser" is also ambiguous in a fixture that runs two parsers (micromark through parseLiteralRanges, and mdast fromMarkdown here). The writing-for-agents skill asks to attach a condition to the thing it governs and to keep the rationale that lets a maintainer adapt the rule. This comment has the rationale but not the link to the rule.

Proposed correction: fold it into the JSDoc as "…or one-line code block, after the fence's own indentation; parseLiteralRanges only reports a fence indented under four columns, so that line still opens an HTML block." This keeps the safety argument, names the parser, and ties it to the [ \t]* it justifies.

claim 01M3ZAVCTEWM80GKWRKAGWX3X6 of review 01M3ZAP3N3GZTRF3T09RWJKY6E

<!-- review:claim:01M3ZAVCTEWM80GKWRKAGWX3X6 --> **low** — `EMITTED` comment about indentation never says it explains the `[ \t]*` allowance or which parser it means lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > Examined: the `EMITTED` regex in src/markdown/raw-html.fixture.ts (`/^(?:<code>|<\/code>|[ \t]*<pre><code>[^<]*<\/code><\/pre>)$/u`), its JSDoc ("The only HTML `neutralizeHtml` emits: a re-emitted code span or one-line code block."), and the test "strips fence indentation from block content" in neutralize-html.test.ts, which expects output `" <pre><code>…"` with the fence's leading spaces kept. > > What the passage says: a line comment between the JSDoc and the declaration: "The parser already decided the block's indentation stays under four columns." > > What goes wrong for the reader: the comment never says which part of the regex it explains, or why under four columns matters. The connection only shows up after reading the test above: `rewrite` keeps the fence's own indentation before `<pre>`, so the fixture has to accept leading blanks. That's safe only because a fence indented four or more columns would have parsed as indented code, so the emitted line still opens an HTML block. "The parser" is also ambiguous in a fixture that runs two parsers (micromark through `parseLiteralRanges`, and mdast `fromMarkdown` here). The writing-for-agents skill asks to attach a condition to the thing it governs and to keep the rationale that lets a maintainer adapt the rule. This comment has the rationale but not the link to the rule. > > Proposed correction: fold it into the JSDoc as "…or one-line code block, after the fence's own indentation; `parseLiteralRanges` only reports a fence indented under four columns, so that line still opens an HTML block." This keeps the safety argument, names the parser, and ties it to the `[ \t]*` it justifies. claim `01M3ZAVCTEWM80GKWRKAGWX3X6` of review `01M3ZAP3N3GZTRF3T09RWJKY6E`
Author
Owner

Fixed in f71add8: the indentation rationale is folded into the EMITTED JSDoc and names parseLiteralRanges.

<!-- gh-feedback:reply-to:105110 --> Fixed in f71add8: the indentation rationale is folded into the EMITTED JSDoc and names parseLiteralRanges.
jercik marked this conversation as resolved
Lines 11-15
@ -9,4 +11,5 @@
// The claim ids carried by claimMarker(); a conversation with no match is a
// Markers count only where the wrapper writes them, at the start of a comment:
// one quoted further down a body is text, not a claim on the thread.
// The claim id carried by claimMarker(); a conversation with no match is a
// human thread and stays untouched. Two claims can share one conversation when
// their anchors map to the same display line, so every marker counts.
const CLAIM_MARKER_PATTERN = /<!-- review:claim:(?<claimId>\S+) -->/gu;
// their anchors map to the same display line, so every comment's marker counts.

low — Marker comment above CLAIM_MARKER_PATTERN runs a file-wide rule into the claim-pattern note, then says "every comment's marker counts" right after "Markers count only ... at the start"
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

Examined: the comment block preceding CLAIM_MARKER_PATTERN and SUPERSEDED_MARKER_PATTERN in src/reconcile/conversation-markers.ts, plus claimIdsOf, hasMarker, and isSuperseded, which all now match only at the start of a comment body (^ anchors and startsWith), and the tests "reads a claim marker only at the start of a comment" / "reads a superseded marker only at the start of a comment".

What the passage does: five lines run three separate notes together with no break. (1) A rule that governs every marker in the file: "Markers count only where the wrapper writes them, at the start of a comment". (2) A noun-phrase fragment describing the claim pattern: "The claim id carried by claimMarker(); a conversation with no match is a human thread". (3) A multi-claim note ending "so every comment's marker counts."

What goes wrong for the reader: the file-wide rule sits inside the claim pattern's comment, so it reads as applying only to claims, though SUPERSEDED_MARKER_PATTERN and hasMarker follow it too. And "Markers count only where ..." followed three lines later by "every comment's marker counts" reads as a contradiction until the reader works out that the first is about position within a body and the second is about which comments in a conversation are scanned. The writing-for-agents skill's "group by concept" and "one idea, one place" guidance applies: a rule should sit with the thing it governs, and two sentences that use "count" in different senses should not sit side by side.

Proposed correction: put the rule in the file header or its own paragraph ("A marker counts only at the start of a comment, where the wrapper writes it; one quoted further down a body is text."). Keep the claim-pattern comment specific: "The claim id claimMarker() writes. Every comment in a conversation is scanned, because two claims whose anchors map to the same display line share one conversation; a conversation with no match is a human thread and stays untouched." Nothing is lost, and the scan scope no longer reads like it overrides the position rule.

Proof gap: no diff text was served for this review, so I cannot confirm which of these lines are new. The block reads as a new first sentence stacked onto an older comment.

claim 01M3ZAT2KCRXSVKH3FYY9X7J0V of review 01M3ZAP3N3GZTRF3T09RWJKY6E

<!-- review:claim:01M3ZAT2KCRXSVKH3FYY9X7J0V --> **low** — Marker comment above `CLAIM_MARKER_PATTERN` runs a file-wide rule into the claim-pattern note, then says "every comment's marker counts" right after "Markers count only ... at the start" lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > Examined: the comment block preceding `CLAIM_MARKER_PATTERN` and `SUPERSEDED_MARKER_PATTERN` in src/reconcile/conversation-markers.ts, plus `claimIdsOf`, `hasMarker`, and `isSuperseded`, which all now match only at the start of a comment body (`^` anchors and `startsWith`), and the tests "reads a claim marker only at the start of a comment" / "reads a superseded marker only at the start of a comment". > > What the passage does: five lines run three separate notes together with no break. (1) A rule that governs every marker in the file: "Markers count only where the wrapper writes them, at the start of a comment". (2) A noun-phrase fragment describing the claim pattern: "The claim id carried by claimMarker(); a conversation with no match is a human thread". (3) A multi-claim note ending "so every comment's marker counts." > > What goes wrong for the reader: the file-wide rule sits inside the claim pattern's comment, so it reads as applying only to claims, though `SUPERSEDED_MARKER_PATTERN` and `hasMarker` follow it too. And "Markers count only where ..." followed three lines later by "every comment's marker counts" reads as a contradiction until the reader works out that the first is about position within a body and the second is about which comments in a conversation are scanned. The writing-for-agents skill's "group by concept" and "one idea, one place" guidance applies: a rule should sit with the thing it governs, and two sentences that use "count" in different senses should not sit side by side. > > Proposed correction: put the rule in the file header or its own paragraph ("A marker counts only at the start of a comment, where the wrapper writes it; one quoted further down a body is text."). Keep the claim-pattern comment specific: "The claim id `claimMarker()` writes. Every comment in a conversation is scanned, because two claims whose anchors map to the same display line share one conversation; a conversation with no match is a human thread and stays untouched." Nothing is lost, and the scan scope no longer reads like it overrides the position rule. > > Proof gap: no diff text was served for this review, so I cannot confirm which of these lines are new. The block reads as a new first sentence stacked onto an older comment. claim `01M3ZAT2KCRXSVKH3FYY9X7J0V` of review `01M3ZAP3N3GZTRF3T09RWJKY6E`
Author
Owner

Fixed in f71add8: the position rule and the conversation-scan scope are now separate paragraphs.

<!-- gh-feedback:reply-to:105111 --> Fixed in f71add8: the position rule and the conversation-scan scope are now separate paragraphs.
jercik marked this conversation as resolved
@ -11,2 +15,2 @@
// their anchors map to the same display line, so every marker counts.
const CLAIM_MARKER_PATTERN = /<!-- review:claim:(?<claimId>\S+) -->/gu;
// their anchors map to the same display line, so every comment's marker counts.
const CLAIM_MARKER_PATTERN = /^<!-- review:claim:(?<claimId>\S+) -->/u;

medium — Claim markers are trusted from any comment author, so a forged leading marker makes the wrapper reply to and resolve other people's conversations
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

What I examined: claimIdsOf, hasMarker and isSuperseded in src/reconcile/conversation-markers.ts, their only caller createDispositionProjector in src/reconcile/dispositions.ts, deriveConversations in src/forge/conversations.ts, and the PullReviewComment type in src/forge/types.ts. For comparison I read src/reconcile/summary.ts.

What the code does: the new comment says "Markers count only where the wrapper writes them, at the start of a comment". The only check, though, is CLAIM_MARKER_PATTERN = /^<!-- review:claim:(?<claimId>\S+) -->/u, run against every comment in the conversation. Nothing checks who wrote the comment, and PullReviewComment has no user field to check against. Any participant can start a comment with <!-- review:claim:X -->, and the HTML comment doesn't show when rendered. In reconcile, a conversation where claimIdsOf returns an id that isn't one of this review's claims goes to projectSuperseded. That function posts a "superseded" reply and calls forge.resolveConversation on the conversation's anchor, which is the earliest comment and can be someone else's. summary.ts handles the same problem the other way: findOurComment requires comment.user.id === ACTIONS_USER_ID because "a pre-posted marker would hijack the summary". The inline-thread markers have no such check.

Reproduction (observed): I ran src/reconcile/dispositions.ts under Node with a fake forge. Review 77 had a maintainer comment ("please don't merge until the migration is reverted") and a later reply whose body was "\nok will do". I called reconcile with claims: []. The projector called addReviewComment(5, 77, {body: "<!-- review:superseded:R2 -->..."}) and then resolveConversation(5, 77, 1), which resolves the maintainer's comment 1.

Impact: anyone who can comment on the PR can get the wrapper's token to resolve another reviewer's conversation and post bot replies into human threads. A forged marker naming a current claim id (claim ids appear in posted bodies as "claim id of review") also makes the wrapper post that claim's disposition into the human thread and resolve or reopen it. The same startsWith(marker) check without an author check is used for dedup in createInlineReconciler (inline.ts), so a pre-posted marker can stop a finding from being posted inline.

Fix: add the author (user.id) to PullReviewComment and count markers only on comments written by the wrapper's identity, as summary.ts does.

Proof gap: the diff text I was given was empty, so I can't say whether the base version also skipped the author check. This change did rewrite this matching logic, and its comment says the restriction to wrapper-written markers holds when it doesn't.

claim 01M3ZB05PTP3APSD99F4SWWTMH of review 01M3ZAP3N3GZTRF3T09RWJKY6E

<!-- review:claim:01M3ZB05PTP3APSD99F4SWWTMH --> **medium** — Claim markers are trusted from any comment author, so a forged leading marker makes the wrapper reply to and resolve other people's conversations lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > What I examined: `claimIdsOf`, `hasMarker` and `isSuperseded` in src/reconcile/conversation-markers.ts, their only caller `createDispositionProjector` in src/reconcile/dispositions.ts, `deriveConversations` in src/forge/conversations.ts, and the `PullReviewComment` type in src/forge/types.ts. For comparison I read src/reconcile/summary.ts. > > What the code does: the new comment says "Markers count only where the wrapper writes them, at the start of a comment". The only check, though, is `CLAIM_MARKER_PATTERN = /^<!-- review:claim:(?<claimId>\S+) -->/u`, run against every comment in the conversation. Nothing checks who wrote the comment, and `PullReviewComment` has no `user` field to check against. Any participant can start a comment with `<!-- review:claim:X -->`, and the HTML comment doesn't show when rendered. In `reconcile`, a conversation where `claimIdsOf` returns an id that isn't one of this review's claims goes to `projectSuperseded`. That function posts a "superseded" reply and calls `forge.resolveConversation` on the conversation's anchor, which is the earliest comment and can be someone else's. summary.ts handles the same problem the other way: `findOurComment` requires `comment.user.id === ACTIONS_USER_ID` because "a pre-posted marker would hijack the summary". The inline-thread markers have no such check. > > Reproduction (observed): I ran src/reconcile/dispositions.ts under Node with a fake forge. Review 77 had a maintainer comment ("please don't merge until the migration is reverted") and a later reply whose body was "<!-- review:claim:01ANYTHING -->\nok will do". I called `reconcile` with `claims: []`. The projector called `addReviewComment(5, 77, {body: "<!-- review:superseded:R2 -->..."})` and then `resolveConversation(5, 77, 1)`, which resolves the maintainer's comment 1. > > Impact: anyone who can comment on the PR can get the wrapper's token to resolve another reviewer's conversation and post bot replies into human threads. A forged marker naming a current claim id (claim ids appear in posted bodies as "claim `id` of review") also makes the wrapper post that claim's disposition into the human thread and resolve or reopen it. The same `startsWith(marker)` check without an author check is used for dedup in `createInlineReconciler` (inline.ts), so a pre-posted marker can stop a finding from being posted inline. > > Fix: add the author (`user.id`) to `PullReviewComment` and count markers only on comments written by the wrapper's identity, as summary.ts does. > > Proof gap: the diff text I was given was empty, so I can't say whether the base version also skipped the author check. This change did rewrite this matching logic, and its comment says the restriction to wrapper-written markers holds when it doesn't. > claim `01M3ZB05PTP3APSD99F4SWWTMH` of review `01M3ZAP3N3GZTRF3T09RWJKY6E`
jercik marked this conversation as resolved
docs: tighten comments and docstrings per review
All checks were successful
commit-msg / commitlint (pull_request) Successful in 22s
Dedupe check / dedupe-check (pull_request) Successful in 35s
Checks / quality-checks (pull_request) Successful in 47s
Review / Review (pull_request_target) Successful in 9m38s
f71add834e
Author
Owner

Replying to review comment #105105

The gap is real, but this PR did not introduce it and the fix is outside this PR's scope. On main, claimIdsOf and hasMarker matched a marker anywhere in any comment, with no author check; this PR only narrows that to a leading marker. The fix is separate security hardening: add user to PullReviewComment (src/forge/types.ts), parse it in the forge client, and count claim, superseded, and disposition markers in src/reconcile/conversation-markers.ts and the dedup in src/reconcile/inline.ts only when user.id is the actions identity, as src/reconcile/summary.ts does. Deferred to a follow-up PR; in f71add8 I reworded the comment above CLAIM_MARKER_PATTERN so it no longer implies an authorship check.

> Replying to review comment #105105 The gap is real, but this PR did not introduce it and the fix is outside this PR's scope. On main, `claimIdsOf` and `hasMarker` matched a marker anywhere in any comment, with no author check; this PR only narrows that to a leading marker. The fix is separate security hardening: add `user` to `PullReviewComment` (src/forge/types.ts), parse it in the forge client, and count claim, superseded, and disposition markers in src/reconcile/conversation-markers.ts and the dedup in src/reconcile/inline.ts only when `user.id` is the actions identity, as src/reconcile/summary.ts does. Deferred to a follow-up PR; in f71add8 I reworded the comment above `CLAIM_MARKER_PATTERN` so it no longer implies an authorship check.
Lines 133-134
@ -0,0 +130,5 @@
return segments;
}
/** Untrusted markdown rewritten so no `<` can open raw HTML, whether it sits in prose, code,
* or a link destination. The guarantee holds wherever the renderer draws code boundaries,

low — The sanitizer docstring overstates its no-HTML guarantee
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

I read neutralizeHtml and its rewrite helper in this file, plus the “re-emits a code span” and fenced-block cases in src/markdown/neutralize-html.test.ts. The docstring says the rewritten markdown has no < that can open raw HTML, but rewrite intentionally returns live <code> and <pre><code> tags; for example, a code span containing <b> becomes <code>&lt;b&gt;</code>. A caller or security reviewer could take the guarantee literally and assume the output contains no raw HTML, then apply the wrong rendering or validation rule. The writing standard calls for verifiable, scoped claims and for keeping a rule’s caveat beside it. Say “No < from the untrusted input can open raw HTML; the function emits its own <code> and <pre><code> elements for rewritten code.” This preserves the actual security guarantee while naming the deliberate exception. The static return values and existing test expectations establish the mismatch; a rendering contract under which these emitted tags are not raw HTML would refute it.

claim 01M3ZBG61TKJZF7CRP3KM9ENYR of review 01M3ZB9GA1SK1R2DV04KBDND50

<!-- review:claim:01M3ZBG61TKJZF7CRP3KM9ENYR --> **low** — The sanitizer docstring overstates its no-HTML guarantee lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I read neutralizeHtml and its rewrite helper in this file, plus the “re-emits a code span” and fenced-block cases in src/markdown/neutralize-html.test.ts. The docstring says the rewritten markdown has no `<` that can open raw HTML, but rewrite intentionally returns live `<code>` and `<pre><code>` tags; for example, a code span containing `<b>` becomes `<code>&lt;b&gt;</code>`. A caller or security reviewer could take the guarantee literally and assume the output contains no raw HTML, then apply the wrong rendering or validation rule. The writing standard calls for verifiable, scoped claims and for keeping a rule’s caveat beside it. Say “No `<` from the untrusted input can open raw HTML; the function emits its own `<code>` and `<pre><code>` elements for rewritten code.” This preserves the actual security guarantee while naming the deliberate exception. The static return values and existing test expectations establish the mismatch; a rendering contract under which these emitted tags are not raw HTML would refute it. claim `01M3ZBG61TKJZF7CRP3KM9ENYR` of review `01M3ZB9GA1SK1R2DV04KBDND50`
jercik marked this conversation as resolved
@ -162,0 +203,4 @@
});
const bodies = (fake.posted[0] ?? []).map((comment) => comment.body);
expect(bodies).toHaveLength(400);
expect(htmlBeyondMarker(bodies)).toStrictEqual([]);

low — Generated comment test ignores raw code tags from claim text
lens test-trimming · arm default · tally 1 valid / 0 invalid / 0 uncertain

I traced this generated-comment assertion through htmlBeyondMarker and foreignHtml, then ran node --input-type=module -e 'import {generateMarkdown} from "./src/markdown/markdown-corpus.fixture.ts"; const xs=generateMarkdown(400,7); console.log(xs.filter(x=>x.includes("<code>")||x.includes("</code>")).length)', which printed 175 inputs containing literal code tags. The fixture defines EMITTED = /^(?:<code>|<\/code>|[ \t]*<pre><code>[^<]*<\/code><\/pre>)$/u and applies rawHtmlNodes(markdown).filter((value) => !EMITTED.test(value)). That filter has no provenance check: it treats a raw <code> or </code> from claim text exactly like markup deliberately emitted for a code span. The production commentBody puts neutralizeHtml(claim.title, "inline") and quoteUntrusted(claim.body) into posted comments. A compositor regression that decoded only &lt;code> and &lt;/code> after these calls would let user-provided HTML change the rendered comment, yet this generated assertion would remain empty; the explicit inline test uses <img>, <script>, <i>, and <div> source tags rather than literal <code> source tags, while the direct neutralizeHtml test would not exercise such a compositor regression. This is a code trace plus a corpus count, not a full Vitest run; dependencies are absent from the snapshot. Add a posted-comment assertion that literal source <code> and </code> remain escaped, or make the parser oracle distinguish emitted code markup from user-origin tags. The same htmlBeyondMarker blind spot applies to the generated disposition-body test.

claim 01M3ZBG07KMHRHGZKYNA49ZZB3 of review 01M3ZB9GA1SK1R2DV04KBDND50

<!-- review:claim:01M3ZBG07KMHRHGZKYNA49ZZB3 --> **low** — Generated comment test ignores raw code tags from claim text lens `test-trimming` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I traced this generated-comment assertion through `htmlBeyondMarker` and `foreignHtml`, then ran `node --input-type=module -e 'import {generateMarkdown} from "./src/markdown/markdown-corpus.fixture.ts"; const xs=generateMarkdown(400,7); console.log(xs.filter(x=>x.includes("<code>")||x.includes("</code>")).length)'`, which printed 175 inputs containing literal code tags. The fixture defines `EMITTED = /^(?:<code>|<\/code>|[ \t]*<pre><code>[^<]*<\/code><\/pre>)$/u` and applies `rawHtmlNodes(markdown).filter((value) => !EMITTED.test(value))`. That filter has no provenance check: it treats a raw `<code>` or `</code>` from claim text exactly like markup deliberately emitted for a code span. The production `commentBody` puts `neutralizeHtml(claim.title, "inline")` and `quoteUntrusted(claim.body)` into posted comments. A compositor regression that decoded only `&lt;code>` and `&lt;/code>` after these calls would let user-provided HTML change the rendered comment, yet this generated assertion would remain empty; the explicit inline test uses `<img>`, `<script>`, `<i>`, and `<div>` source tags rather than literal `<code>` source tags, while the direct `neutralizeHtml` test would not exercise such a compositor regression. This is a code trace plus a corpus count, not a full Vitest run; dependencies are absent from the snapshot. Add a posted-comment assertion that literal source `<code>` and `</code>` remain escaped, or make the parser oracle distinguish emitted code markup from user-origin tags. The same `htmlBeyondMarker` blind spot applies to the generated disposition-body test. claim `01M3ZBG07KMHRHGZKYNA49ZZB3` of review `01M3ZB9GA1SK1R2DV04KBDND50`
jercik marked this conversation as resolved
@ -18,3 +17,3 @@
claimMarker(claim.id),
`**${claim.severity}** — ${claim.title}`,
`**${claim.severity}** — ${neutralizeHtml(claim.title, "inline")}`,
`lens \`${claim.lens}\` · arm \`${claim.arm}\` · ${tally}${wavering}`,

medium — Lens and arm strings can break out of code spans into raw HTML
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

I traced commentBody and the ProjectedClaim wire schema in the bundled @j4k/review code. The schema accepts lens and arm as unrestricted strings, but commentBody inserts them between single backticks without escaping. Using the committed bundle code with a claim whose lens is x<img src=x> produces lens x<img src=x>; parsing the full comment with its bundled CommonMark/GFM tokenizer yields an htmlText token for . Thus the newly neutralized title and body do not ensure posted inline comments are free of injected raw HTML when these service fields contain Markdown delimiters. I observed this locally by exporting commentBody and the bundled parser from a temporary copy of dist/index.mjs; I did not verify Forgejo sanitizer behavior, so the established impact is raw HTML entering the posted comment. Escape or safely encode lens and arm for their code-span context.

claim 01M3ZBKT44A4QA14168MAH16D3 of review 01M3ZB9GA1SK1R2DV04KBDND50

<!-- review:claim:01M3ZBKT44A4QA14168MAH16D3 --> **medium** — Lens and arm strings can break out of code spans into raw HTML lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I traced commentBody and the ProjectedClaim wire schema in the bundled @j4k/review code. The schema accepts lens and arm as unrestricted strings, but commentBody inserts them between single backticks without escaping. Using the committed bundle code with a claim whose lens is x`<img src=x> produces lens `x`<img src=x>`; parsing the full comment with its bundled CommonMark/GFM tokenizer yields an htmlText token for <img src=x>. Thus the newly neutralized title and body do not ensure posted inline comments are free of injected raw HTML when these service fields contain Markdown delimiters. I observed this locally by exporting commentBody and the bundled parser from a temporary copy of dist/index.mjs; I did not verify Forgejo sanitizer behavior, so the established impact is raw HTML entering the posted comment. Escape or safely encode lens and arm for their code-span context. claim `01M3ZBKT44A4QA14168MAH16D3` of review `01M3ZB9GA1SK1R2DV04KBDND50`
jercik marked this conversation as resolved
Author
Owner

Replying to review comment #105151

Acknowledged without a fix: this is round 4 of review on this PR, so only clear, severe, demonstrably real bugs are fixed now. claim.lens and claim.arm are lens and arm identifiers assigned by the review service, not text from the PR author or the reviewed code, so I found no path for untrusted input to reach them (src/reconcile/inline.ts:19, unchanged from main). Escaping them is reasonable defense in depth against a misconfigured or compromised service. It is deferred to a follow-up PR: in commentBody (src/reconcile/inline.ts), pass claim.lens and claim.arm through the same neutralization or reject values containing a backtick, with a test. Verified head: f71add8.

> Replying to review comment #105151 Acknowledged without a fix: this is round 4 of review on this PR, so only clear, severe, demonstrably real bugs are fixed now. `claim.lens` and `claim.arm` are lens and arm identifiers assigned by the review service, not text from the PR author or the reviewed code, so I found no path for untrusted input to reach them (src/reconcile/inline.ts:19, unchanged from main). Escaping them is reasonable defense in depth against a misconfigured or compromised service. It is deferred to a follow-up PR: in `commentBody` (src/reconcile/inline.ts), pass `claim.lens` and `claim.arm` through the same neutralization or reject values containing a backtick, with a test. Verified head: f71add8.
Author
Owner

Replying to review comment #105152

Real but small, and deliberately deferred to a follow-up PR: round 4 gates one-line docstring changes. The exact fix, in src/markdown/neutralize-html.ts on the neutralizeHtml docstring, is to say that no < from the input can open raw HTML while the function emits its own <code> and <pre><code> elements for rewritten code. The same sentence belongs in j4k/review src/report/neutralize-html.ts. Behavior and safety are unchanged.

> Replying to review comment #105152 Real but small, and deliberately deferred to a follow-up PR: round 4 gates one-line docstring changes. The exact fix, in src/markdown/neutralize-html.ts on the `neutralizeHtml` docstring, is to say that no `<` from the input can open raw HTML while the function emits its own `<code>` and `<pre><code>` elements for rewritten code. The same sentence belongs in j4k/review src/report/neutralize-html.ts. Behavior and safety are unchanged.
Author
Owner

Replying to review comment #105153

Real but small, and deliberately deferred to a follow-up PR: it changes test coverage only, and round 4 gates that. The exact fix, in src/reconcile/inline.test.ts (and the matching disposition-body test in src/reconcile/dispositions.test.ts): add a posted-comment assertion that a literal source <code> or </code> in the title or body stays escaped, or make the htmlBeyondMarker oracle in src/markdown/comment-html.fixture.ts distinguish emitted code markup from source tags.

> Replying to review comment #105153 Real but small, and deliberately deferred to a follow-up PR: it changes test coverage only, and round 4 gates that. The exact fix, in src/reconcile/inline.test.ts (and the matching disposition-body test in src/reconcile/dispositions.test.ts): add a posted-comment assertion that a literal source `<code>` or `</code>` in the title or body stays escaped, or make the `htmlBeyondMarker` oracle in src/markdown/comment-html.fixture.ts distinguish emitted code markup from source tags.
fix: skip markdown parse for oversized fields
All checks were successful
commit-msg / commitlint (pull_request) Successful in 17s
Dedupe check / dedupe-check (pull_request) Successful in 36s
Checks / quality-checks (pull_request) Successful in 50s
Review / Review (pull_request_target) Successful in 10m23s
6093b7cc93
Lines 133-136
@ -0,0 +137,7 @@
return segments;
}
/** Untrusted markdown rewritten so no `<` can open raw HTML, whether it sits in prose, code,
* or a link destination. The guarantee holds wherever the renderer draws code boundaries,
* including where Forgejo's math, definition lists, and heading attributes diverge from
* CommonMark; code and destinations display verbatim only where the two agree. */

medium — Document that neutralizeHtml emits its own raw HTML
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

I read neutralizeHtml, its rewrite helper, the raw-html fixture, and the tests. This API comment promises that no < can open raw HTML, but rewrite deliberately returns literal <code>...</code> and <pre><code>...</code></pre>; the code-span test expects <code>&lt;i&gt;</code>. The fixture explicitly exempts that generated HTML when checking output. A maintainer relying on the public function comment could treat its result as HTML-free, despite those live tags. The writing standard calls for verifiable claims and one term per concept. Say that tag-opening < from the untrusted input is neutralized, while code containing such text is represented with trusted <code>/<pre><code> HTML. This keeps the intended safety guarantee and tells readers what the output actually contains. The implementation and test establish the contradiction; I did not verify a caller that requires HTML-free output.

claim 01M3ZD3JQC065APZPKYQTMD7D7 of review 01M3ZCZEQ4SFS7J2EQ4X9F6YC8

<!-- review:claim:01M3ZD3JQC065APZPKYQTMD7D7 --> **medium** — Document that neutralizeHtml emits its own raw HTML lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I read neutralizeHtml, its rewrite helper, the raw-html fixture, and the tests. This API comment promises that no `<` can open raw HTML, but rewrite deliberately returns literal `<code>...</code>` and `<pre><code>...</code></pre>`; the code-span test expects `<code>&lt;i&gt;</code>`. The fixture explicitly exempts that generated HTML when checking output. A maintainer relying on the public function comment could treat its result as HTML-free, despite those live tags. The writing standard calls for verifiable claims and one term per concept. Say that tag-opening `<` from the untrusted input is neutralized, while code containing such text is represented with trusted `<code>`/`<pre><code>` HTML. This keeps the intended safety guarantee and tells readers what the output actually contains. The implementation and test establish the contradiction; I did not verify a caller that requires HTML-free output. claim `01M3ZD3JQC065APZPKYQTMD7D7` of review `01M3ZCZEQ4SFS7J2EQ4X9F6YC8`
jercik marked this conversation as resolved
@ -17,3 +16,3 @@
return [
claimMarker(claim.id),
`**${claim.severity}** — ${claim.title}`,
`**${claim.severity}** — ${neutralizeHtml(claim.title, "inline")}`,

low — An unmatched title backtick changes the inline comment metadata
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

I examined commentBody in src/reconcile/inline.ts, neutralizeHtml in src/markdown/neutralize-html.ts, and the inline comment tests. The title is placed directly before the lens line, and neutralizeHtml escapes raw HTML but leaves Markdown backticks. I reproduced this with the parser bundled in dist/index.mjs: for a claim title ending in one backtick, commentBody emits **high** — Unclosed followed immediately by lens correctness ...; the parser reports a codeText span containing the title backtick, newline, and lens , then another span around · arm. Thus a valid claim title changes the rendering of the wrapper's provenance fields. Escaping Markdown delimiters in the title or separating the fields into distinct blocks would keep the template intact. This was observed with the bundled CommonMark parser; direct Forgejo rendering and any upstream title character restriction were not available to verify.

claim 01M3ZDCXDZHA984WZGK270VF0Q of review 01M3ZCZEQ4SFS7J2EQ4X9F6YC8

<!-- review:claim:01M3ZDCXDZHA984WZGK270VF0Q --> **low** — An unmatched title backtick changes the inline comment metadata lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I examined commentBody in src/reconcile/inline.ts, neutralizeHtml in src/markdown/neutralize-html.ts, and the inline comment tests. The title is placed directly before the `lens` line, and neutralizeHtml escapes raw HTML but leaves Markdown backticks. I reproduced this with the parser bundled in dist/index.mjs: for a claim title ending in one backtick, commentBody emits `**high** — Unclosed ` followed immediately by `lens `correctness` ...`; the parser reports a codeText span containing the title backtick, newline, and `lens `, then another span around ` · arm `. Thus a valid claim title changes the rendering of the wrapper's provenance fields. Escaping Markdown delimiters in the title or separating the fields into distinct blocks would keep the template intact. This was observed with the bundled CommonMark parser; direct Forgejo rendering and any upstream title character restriction were not available to verify. claim `01M3ZDCXDZHA984WZGK270VF0Q` of review `01M3ZCZEQ4SFS7J2EQ4X9F6YC8`
jercik marked this conversation as resolved
Author
Owner

Replying to review comment #105226

Duplicate of #105152, real but small, deliberately deferred to a follow-up PR (round 5 fixes only clear, severe bugs). The exact fix, in src/markdown/neutralize-html.ts on the neutralizeHtml docstring: say that tag-opening < from the input is neutralized while rewritten code is emitted as the function's own <code> and <pre><code> elements. The same sentence belongs in j4k/review src/report/neutralize-html.ts. No behavior or safety change.

> Replying to review comment #105226 Duplicate of #105152, real but small, deliberately deferred to a follow-up PR (round 5 fixes only clear, severe bugs). The exact fix, in src/markdown/neutralize-html.ts on the `neutralizeHtml` docstring: say that tag-opening `<` from the input is neutralized while rewritten code is emitted as the function's own `<code>` and `<pre><code>` elements. The same sentence belongs in j4k/review src/report/neutralize-html.ts. No behavior or safety change.
Author
Owner

Replying to review comment #105227

Real but cosmetic, so acknowledged without a fix at round 5: an unmatched backtick in a title can pair with backticks in the metadata line below it, which changes only how the provenance line renders. It cannot introduce raw HTML, since the title is neutralized and the code span path is escaped. The title and metadata line layout is unchanged from main. Deferred to a follow-up PR: in commentBody (src/reconcile/inline.ts), put a blank line between the title and the lens line, or escape backticks in the title, with a test. Verified head: 6093b7c.

> Replying to review comment #105227 Real but cosmetic, so acknowledged without a fix at round 5: an unmatched backtick in a title can pair with backticks in the metadata line below it, which changes only how the provenance line renders. It cannot introduce raw HTML, since the title is neutralized and the code span path is escaped. The title and metadata line layout is unchanged from main. Deferred to a follow-up PR: in `commentBody` (src/reconcile/inline.ts), put a blank line between the title and the lens line, or escape backticks in the title, with a test. Verified head: 6093b7c.
jercik force-pushed fix/escape-html-in-comments from 6093b7cc93
All checks were successful
commit-msg / commitlint (pull_request) Successful in 17s
Dedupe check / dedupe-check (pull_request) Successful in 36s
Checks / quality-checks (pull_request) Successful in 50s
Review / Review (pull_request_target) Successful in 10m23s
to 41cd66132f
All checks were successful
commit-msg / commitlint (pull_request) Successful in 29s
Dedupe check / dedupe-check (pull_request) Successful in 46s
Checks / quality-checks (pull_request) Successful in 1m1s
Review / Review (pull_request_target) Successful in 12m25s
2026-10-03 17:10:15 +00:00
Compare
@ -24,3 +27,1 @@
if (claimId !== undefined && !ids.includes(claimId)) {
ids.push(claimId);
}
const claimId = CLAIM_MARKER_PATTERN.exec(comment.body)?.groups?.claimId;

medium — A user-authored claim marker makes the projector modify a human thread
lens general-bug · arm default · tally 1 valid / 0 invalid / 0 uncertain

I traced claimIdsOf through the sweep in src/reconcile/dispositions.ts and the PullReviewComment type in src/forge/types.ts. The sweep reads comments from every review, while claimIdsOf accepts any comment beginning with a claim marker and PullReviewComment carries no author identity. A PR participant can therefore put at the start of their own inline review comment. When that ID is absent from the current claims, projectSuperseded treats the human conversation as a wrapper thread, posts a superseded reply, and, on instances with the resolution route, resolves its anchor. I reproduced the reply using the committed bundle with a fake forge returning one such comment and claims=[]: reconcile added to review 7. The reproduction used supportsResolution=false, so resolution of a human comment is a static trace rather than an observed API result. Checking the comment author before accepting ownership markers, as the summary reconciler already does, would prevent the spurious write; a forge guarantee that the list contains only wrapper comments would refute this, but listReviewComments is documented and used as the whole review comments.

claim 01M41CC43F61YQR67CV4RS6S2W of review 01M41BXJNPMEM7K4YTGYH71DHG

<!-- review:claim:01M41CC43F61YQR67CV4RS6S2W --> **medium** — A user-authored claim marker makes the projector modify a human thread lens `general-bug` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I traced claimIdsOf through the sweep in src/reconcile/dispositions.ts and the PullReviewComment type in src/forge/types.ts. The sweep reads comments from every review, while claimIdsOf accepts any comment beginning with a claim marker and PullReviewComment carries no author identity. A PR participant can therefore put <!-- review:claim:01OLD --> at the start of their own inline review comment. When that ID is absent from the current claims, projectSuperseded treats the human conversation as a wrapper thread, posts a superseded reply, and, on instances with the resolution route, resolves its anchor. I reproduced the reply using the committed bundle with a fake forge returning one such comment and claims=[]: reconcile added <!-- review:superseded:01NEW --> to review 7. The reproduction used supportsResolution=false, so resolution of a human comment is a static trace rather than an observed API result. Checking the comment author before accepting ownership markers, as the summary reconciler already does, would prevent the spurious write; a forge guarantee that the list contains only wrapper comments would refute this, but listReviewComments is documented and used as the whole review comments. claim `01M41CC43F61YQR67CV4RS6S2W` of review `01M41BXJNPMEM7K4YTGYH71DHG`
jercik marked this conversation as resolved
Lines 204-206
@ -162,0 +201,6 @@
),
),
});
const bodies = (fake.posted[0] ?? []).map((comment) => comment.body);
expect(bodies).toHaveLength(400);
expect(htmlBeyondMarker(bodies)).toStrictEqual([]);

low — Generated comment test ignores raw code tags from untrusted fields
lens test-trimming · arm default · tally 1 valid / 0 invalid / 0 uncertain

I inspected the generated posted-comment test, its htmlBeyondMarker helper, and the commentBody formatter. The test promises no raw HTML beyond each leading marker, and the deterministic corpus includes <code> and </code> fragments (131 and 129 of the 400 seed-7 strings respectively, counted with generateMarkdown(400, 7)). Yet htmlBeyondMarker calls foreignHtml, whose EMITTED regex unconditionally filters raw <code> and </code> parser nodes by value. It has no information about whether those tags came from a safe code-span rewrite or escaped title/body text. A regression in comment formatting that lets an untrusted <code> through while still escaping other tags would leave this assertion green and let untrusted HTML change how a posted finding renders. The direct neutralizeHtml unit test checks the sanitizer's own <code> handling, but does not prove that every posted-comment path uses it correctly. Keep the generated case, but make the helper distinguish emitted wrappers from untrusted HTML or add an independent assertion that the input code tags are escaped in posted bodies. This is a static trace plus the corpus count; I did not run the Vitest suite or a mutation.

claim 01M41C69PS48WM3TGG6SQR0V4Z of review 01M41BXJNPMEM7K4YTGYH71DHG

<!-- review:claim:01M41C69PS48WM3TGG6SQR0V4Z --> **low** — Generated comment test ignores raw code tags from untrusted fields lens `test-trimming` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I inspected the generated posted-comment test, its `htmlBeyondMarker` helper, and the `commentBody` formatter. The test promises no raw HTML beyond each leading marker, and the deterministic corpus includes `<code>` and `</code>` fragments (131 and 129 of the 400 seed-7 strings respectively, counted with `generateMarkdown(400, 7)`). Yet `htmlBeyondMarker` calls `foreignHtml`, whose `EMITTED` regex unconditionally filters raw `<code>` and `</code>` parser nodes by value. It has no information about whether those tags came from a safe code-span rewrite or escaped title/body text. A regression in comment formatting that lets an untrusted `<code>` through while still escaping other tags would leave this assertion green and let untrusted HTML change how a posted finding renders. The direct `neutralizeHtml` unit test checks the sanitizer's own `<code>` handling, but does not prove that every posted-comment path uses it correctly. Keep the generated case, but make the helper distinguish emitted wrappers from untrusted HTML or add an independent assertion that the input code tags are escaped in posted bodies. This is a static trace plus the corpus count; I did not run the Vitest suite or a mutation. claim `01M41C69PS48WM3TGG6SQR0V4Z` of review `01M41BXJNPMEM7K4YTGYH71DHG`
jercik marked this conversation as resolved
@ -48,3 +47,3 @@
const { claim } = item;
const marker = claimMarker(claim.id);
if (bodies.some((body) => body.includes(marker))) {
// Only a leading marker is one this wrapper wrote; a quoted one is text.

low — Leading-marker comment claims authorship that the code never verifies
lens writing-quality · arm default · tally 1 valid / 0 invalid / 0 uncertain

I read existingClaimMarkers and createInlineReconciler in inline.ts, the marker rules in conversation-markers.ts, and the inline marker tests. existingClaimMarkers collects the body of every review comment and the skip condition checks only body.startsWith(marker); it never checks the author. The comment says a leading marker "is one this wrapper wrote," which claims provenance the code does not establish. A human or another task can write that prefix, so a future reader may mistake the check for an ownership safeguard when a matching comment would suppress posting the claim. Replace it with "Treat only a leading marker as an idempotency marker; a quoted marker is text. This check does not verify the author." That preserves the positional rule and makes the trust limit explicit. The static call path establishes the mismatch; I did not test whether a particular forge user can create the matching comment.

claim 01M41C79EYTYDA8PDAJQFSC2ZF of review 01M41BXJNPMEM7K4YTGYH71DHG

<!-- review:claim:01M41C79EYTYDA8PDAJQFSC2ZF --> **low** — Leading-marker comment claims authorship that the code never verifies lens `writing-quality` · arm `default` · tally 1 valid / 0 invalid / 0 uncertain > I read existingClaimMarkers and createInlineReconciler in inline.ts, the marker rules in conversation-markers.ts, and the inline marker tests. existingClaimMarkers collects the body of every review comment and the skip condition checks only body.startsWith(marker); it never checks the author. The comment says a leading marker "is one this wrapper wrote," which claims provenance the code does not establish. A human or another task can write that prefix, so a future reader may mistake the check for an ownership safeguard when a matching comment would suppress posting the claim. Replace it with "Treat only a leading marker as an idempotency marker; a quoted marker is text. This check does not verify the author." That preserves the positional rule and makes the trust limit explicit. The static call path establishes the mismatch; I did not test whether a particular forge user can create the matching comment. claim `01M41C79EYTYDA8PDAJQFSC2ZF` of review `01M41BXJNPMEM7K4YTGYH71DHG`
jercik marked this conversation as resolved
Author
Owner

Replying to review comment #108648

Repeats #105105, which was acknowledged and deferred to a follow-up PR; see the reply in #105129. The marker-author check still belongs in that separate hardening change, not in this PR.

> Replying to review comment #108648 Repeats #105105, which was acknowledged and deferred to a follow-up PR; see the reply in [#105129](https://code.j4k.dev/j4k-oss/review-wrapper/pulls/20#issuecomment-105129). The marker-author check still belongs in that separate hardening change, not in this PR.
Author
Owner

Replying to review comment #108649

Repeats #105153, which was acknowledged and deferred to a follow-up PR; see the reply in #105157. The extra <code> assertion belongs in that follow-up, not in this PR.

> Replying to review comment #108649 Repeats #105153, which was acknowledged and deferred to a follow-up PR; see the reply in [#105157](https://code.j4k.dev/j4k-oss/review-wrapper/pulls/20#issuecomment-105157). The extra `<code>` assertion belongs in that follow-up, not in this PR.
Author
Owner

Replying to review comment #108650

Valid, and deferred to a follow-up. The fix is to reword the comment at src/reconcile/inline.ts:49 so it no longer claims authorship; it is listed in #24.

> Replying to review comment #108650 Valid, and deferred to a follow-up. The fix is to reword the comment at `src/reconcile/inline.ts:49` so it no longer claims authorship; it is listed in [#24](https://code.j4k.dev/j4k-oss/review-wrapper/issues/24).
jercik merged commit a79cfbe103 into main 2026-10-03 17:29:22 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
j4k-oss/review-wrapper!20
No description provided.