Skip to content

Add lint guard against internal /docs/concepts/* redirect-hop links - #21146

Open
workprentice[bot] wants to merge 7 commits into
masterfrom
workprentice/concepts-links-lint-guard
Open

Add lint guard against internal /docs/concepts/* redirect-hop links#21146
workprentice[bot] wants to merge 7 commits into
masterfrom
workprentice/concepts-links-lint-guard

Conversation

@workprentice

@workprentice workprentice Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

This adds a lint check that catches new internal links to the retired /docs/concepts/* URL space before they merge.

The recurring problem

Pages under /docs/concepts/ moved to /docs/iac/concepts/ a while back (PR #21072), and old internal links to the retired path keep resurfacing in new content, each one costing readers and crawlers a redirect hop. This is now the ninth pass of a recurring sweep that finds and fixes a fresh batch every time: PR #21138 fixes the batch reintroduced since the last sweep, and PR #21145 fixes one more in a blog post that earlier passes had skipped over a stale conflict assumption, plus a related hop class (/docs/iac/concepts/options/*) found on review. Fixing the same class of link over and over without a gate just means a tenth pass is coming.

What the guard does

scripts/lint/check-concepts-links.js scans content/, layouts/, theme/, assets/, data/, archetypes/, static/, and scripts/ for /docs/concepts/... and flags it only when the reference actually targets pulumi.com. It deliberately does not flag:

  • External hosts that happen to have their own /docs/concepts/ path, such as kubernetes.io, developer.okta.com, or developer.hashicorp.com/vault/docs/concepts/... (the last one is a real case in this repo where /docs/concepts/ is not immediately preceded by the external host, so a naive "does the host appear right before this" check would miss it).
  • Root-relative paths where /docs/concepts/ is not the start of the link, such as /images/docs/concepts/foo.png (another real case in this repo).
  • Intentional aliases: frontmatter entries, which are redirect stubs from the old URL to the new one and are supposed to exist.

Run node scripts/lint/check-concepts-links.js --self-test to see the classifier's fixtures for each of these cases side by side with the ones it does flag (markdown links, reference-style link defs, HTML href, and absolute https://www.pulumi.com/docs/concepts/... URLs).

Why archetypes/ and static/ are in scope

The review caught a gap in the original root list: archetypes/templates/template/index.md is the scaffold every new template page is generated from, and it carries three /docs/concepts/ links today. The original scan didn't cover archetypes/, so the guard reported green on it forever while every page scaffolded from that template inherited the stale links into content/, where the guard does look, but only after the fact. archetypes/ and static/ are now scanned; scripts/ came along with them, which surfaced one baselined occurrence, scripts/search/rank.js:80 (a historical-href check, not a live link).

The baseline

scripts/lint/concepts-links-baseline.json records the current known occurrences on master, all of them already fixed on the branches for PR #21138 and PR #21145 (both still open for review), so this PR does not have to depend on those two merging first. It is a ratchet: a file exceeding its recorded count fails the build, a file with fewer than its recorded count only prints a warning so merging the two open PRs does not redden anyone else's in-flight work. The baseline is a count per file, not a per-line record, so swapping one stale link for a different one in the same file keeps the count unchanged and still passes; this limitation is called out directly in the baseline's own _note rather than fixed with per-line tracking, since the whole file is meant to be deleted once #21138 and #21145 merge.

Once PR #21138 and PR #21145 both merge, every entry in the baseline becomes stale (0 real occurrences) and the baseline file should be reset to an empty files object or the file deleted along with its line in scripts/lint.sh, whichever a reviewer prefers. I verified this directly: merging both PR branches into a fresh worktree from origin/master and running the guard against that merged tree with no baseline file present at all reports zero violations.

--print-baseline

Regenerates the baseline file in the exact envelope loadBaseline() expects ({_note, files}), not a flat {path: count} object -- the earlier version of this script emitted the latter, which would have silently produced a baseline that loads as empty and then fails every baselined file with no hint why. Round-trip verified: --print-baseline > file followed by loading that file back reports all entries present.

Output on a green run

A passing run now prints one summary line (No new /docs/concepts/* internal links found (N file(s) with baselined occurrences, all within their allowance).) instead of every baselined file's full detail. Full per-file, per-line detail still prints when the check actually fails, which is when it matters.

Wiring

Added one line to scripts/lint.sh, which make lint already runs and CI already gates on, so this becomes a required PR check with no new workflow needed.

Verification performed

  • node scripts/lint/check-concepts-links.js --self-test passes (9/9 fixtures).
  • Ran the guard against origin/master with the shipped baseline: green, single summary line, 13 files within allowance.
  • --print-baseline > file, then loaded that file back through loadBaseline(): 13 non-empty entries, matching the committed baseline exactly.
  • Injected a fabricated /docs/concepts/testing/ link into archetypes/templates/template/index.md (a file only reachable with the widened SCAN_ROOTS), ran the guard: failed with exit 1 and full detail identifying the new line. Reverted the injection, ran again: green.
  • Ran npx prettier --check against both changed files with this repo's .prettierrc.json: passes.

🧠 This PR was created by workprentice.

@CamSoper
CamSoper marked this pull request as draft August 26, 2026 18:58
@CamSoper CamSoper closed this Aug 26, 2026
@CamSoper CamSoper reopened this Aug 26, 2026
@CamSoper
CamSoper marked this pull request as ready for review August 26, 2026 19:00
@github-actions github-actions Bot added review:triaging Claude Triage is currently classifying the PR domain:infra PR touches workflows, scripts, infra, Makefile, or build config review:in-progress Claude review is currently running and removed review:triaging Claude Triage is currently classifying the PR labels Aug 26, 2026
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Pre-merge Review — Last updated 2026-08-26T23:19:11Z

Tip

Summary: This is an infrastructure PR — a new make lint guard (scripts/lint/check-concepts-links.js, wired into scripts/lint.sh) that fails the build when a PR introduces a fresh internal link to the retired /docs/concepts/* URL space, plus a ratchet baseline that is now empty. This pass reviewed the one new commit (0efd545), a one-line change that appends the count-only-ratchet caveat back onto the shipped _note — the sole remaining ⚠️, now resolved. The restored sentence is byte-identical to the one 77763c3 added and to the tail of DEFAULT_BASELINE_NOTE, so the two copies agree again. Both buckets are empty: 🚨 0, ⚠️ 0.

Two evidence caveats from previous passes retired this round. The Install deps and build site job on 6270eebb (run 33021247653) came back green — and that job runs make ci_pull_requestensurelintscripts/lint.sh → the guard itself, after make ensure has compiled the theme, so CI has now executed the zero-baseline guard against a fully built tree and agreed with the manual audit from last pass. And master's only movement since the rebase base (ca4e73e412245b) is four files under infrastructure/, which is not one of the eight SCAN_ROOTS — so the merge-forward cannot turn this guard red post-merge either. This PR reads as ready.

Review confidence:

Dimension Level Notes
mechanics HIGH
facts HIGH Upgraded from MEDIUM: this pass's diff is one string, and all three load-bearing facts — the restored sentence being verbatim-identical to both prior copies, the green lint run on the previous head, and master's post-base delta landing entirely outside the scan roots — were checked directly rather than inferred.
infra HIGH Upgraded from MEDIUM: the standing "reviewed statically only" caveat is retired. Run 33021247653 on 6270eebb is success, and its Build and deploy step runs make ci_pull_request, which reaches make lint → the guard. Since 0efd545 only edits a string inside a .json file — and .json is absent from SCANNABLE_EXTENSIONS, so the guard never reads its own baseline as content — that green result transfers to this head. Residual: node was present in this run but direct invocation was not permitted, so --self-test was again read rather than executed; the build job's pending state on this head is the outstanding confirmation.
Investigation log
  • Cross-sibling reads: not run (not in a templated section)
  • External claim verification: 28 of 41 claims verified (2 unverifiable, 1 contradicted) · 4 specialists (numerical, cross-reference, capability, framing); 0 cross-specialist corroborations · routed: 11 inline, 30 Pass 1, 0 Pass 2, 0 Pass 3.
  • Cited-claim spot-checks: not run (no cited claims)
  • Frontmatter sweep: not run (no frontmatter in diff)
  • Temporal-trigger sweep: ran (2 matches, 2 verified)
  • Code execution: not run (no static/programs/ change; node was available to this run but direct invocation was not permitted, so --self-test was read rather than executed — CI's green lint step on the previous head covers the equivalent ground)
  • Code-examples checks: not run (no fenced code blocks in content files)
  • Editorial-balance pass: not run (not under content/blog/)
🚨 Outstanding ⚠️ Low-confidence 💡 Pre-existing ✅ Resolved
0 0 0 12

🔍 Verification trail

41 claims extracted · 28 verified · 2 unverifiable · 1 contradicted
  • L2 in scripts/lint/concepts-links-baseline.json "this is a count-only ratchet, not a line-level one -- swapping one stale link for a different stale link in the same file keeps the count unchanged and passes" → ✅ verified (evidence: the baseline maps path → integer and the comparison is violations.length > allowed; no line or text identity is retained, so the behavior the sentence describes is real. Restored to the shipped _note in 0efd545 after the 6270eebb rewrite dropped it. Compared the restored text against the two reference copies: it is byte-identical to the sentence 77763c3 originally added and to the tail of DEFAULT_BASELINE_NOTE at check-concepts-links.js:342-351, hyphenation (--) included, so the JSON and the fallback constant no longer diverge; source: gh api compare/6270eebb...0efd545 (single-hunk patch, one line); git show 0efd545a:scripts/lint/concepts-links-baseline.json; repo:scripts/lint/check-concepts-links.js (L342-351, L427-434))
  • "appending the sentence to the _note cannot itself trip the guard it documents" → ✅ verified (evidence: the restored sentence contains no /docs/concepts token, and independently SCANNABLE_EXTENSIONS is .md .mdx .html .ts .tsx .js .jsx .yaml .yml with no .json, so walk() never hands the baseline file to runScan() as content regardless of what the _note says. The file also still parses — loadBaseline() reads parsed.files, which is {}; source: repo:scripts/lint/check-concepts-links.js (L166-176); git show 0efd545a:scripts/lint/concepts-links-baseline.json)
  • "CI has now run the zero-baseline guard end to end, not just a static audit" → ✅ verified (evidence: run 33021247653 on 6270eebb is conclusion: success. Its Install deps and build site job's Build and deploy step invokes make ci_pull_request, which is bannerensurelintci-pull-request.sh; make lint runs scripts/lint.sh, whose line 6 is node ./scripts/lint/check-concepts-links.js. Because ensure precedes lint, the theme was compiled and static/js/ populated at scan time — the exact path the isGeneratedBundle() exclusion exists for; source: gh api repos/pulumi/docs/actions/runs/33021247653; repo:Makefile (L112-118, L213-214); repo:scripts/lint.sh (L6))
  • "the merge-forward can't turn the empty baseline red" → ✅ verified (evidence: compare master...0efd545 reports behind_by: 1; the delta ca4e73e...412245b touches only infrastructure/Pulumi.www-production.yaml, infrastructure/index.ts, infrastructure/supportRedirect.ts, and infrastructure/tsconfig.json. SCAN_ROOTS is ["content","layouts","theme","assets","data","archetypes","static","scripts"]infrastructure is not among them, so none of the four files is reachable by walk(); source: gh api compare/master...0efd545; gh api compare/ca4e73e...412245b; repo:scripts/lint/check-concepts-links.js (L49))
  • L2 in scripts/lint/concepts-links-baseline.json "tracked while PR Fix internal links to old /docs/concepts/ URLs missed by #21072 #21138 and PR Fix redirect-hop links in why-choose-pulumi-over-terraform blog post #21145 (the fixes) are still open for review. Once both merge, every entry below drops to 0 real occurrences and this whole file plus its check() call in scripts/lint.sh should be pruned or reset to {}" → ❌ contradicted (this text no longer exists at HEAD6270eebb deleted it, and the finding it produced is in ✅ Resolved under the same [L2] anchor. Retained as the evidence record: both PRs were MERGED, not open — Fix internal links to old /docs/concepts/ URLs missed by #21072 #21138 at 2026-08-26T21:31:59Z and Fix redirect-hop links in why-choose-pulumi-over-terraform blog post #21145 at 2026-08-26T21:32:40Z — and every one of the 12 entries below it was at 0 real occurrences while still granting 57 occurrences of headroom; source: gh pr view 21138/21145 -R pulumi/docs --json state,mergedAt; repo:scripts/lint/concepts-links-baseline.json @ e6bcf7b vs @ 6270eeb)
  • L2 in scripts/lint/concepts-links-baseline.json "PR Fix internal links to old /docs/concepts/ URLs missed by #21072 #21138 and PR Fix redirect-hop links in why-choose-pulumi-over-terraform blog post #21145 (the cleanup pass this guard exists to protect) both merged on 2026-08-26, so every internal /docs/concepts/* link this baseline used to track is fixed on master and the allowance list below is empty" → ✅ verified (evidence: both PRs are MERGEDFix internal links to old /docs/concepts/ URLs missed by #21072 #21138 at 2026-08-26T21:31:59Z, Fix redirect-hop links in why-choose-pulumi-over-terraform blog post #21145 at 2026-08-26T21:32:40Z. Checked all 12 formerly-baselined paths on the rebased tree: theme/src/ts/redirects.ts (was 33), theme/src/ts/toc.ts, assets/openapi/tag-intros/stacks.md, content/blog/disable-default-providers/index.md, content/blog/why-choose-pulumi-over-terraform/index.md, content/docs/insights/discovery/search.md, layouts/migrate/terraform.html, and scripts/search/rank.js now carry no /docs/concepts string at all; the remaining four carry only kubernetes.io/docs/concepts/... URLs (content/blog/pulumi-kubernetes-operator/index.md ×5, content/docs/iac/guides/clouds/aws/eks.md ×3, content/docs/integrations/clouds/kubernetes/pulumi-kubernetes-operator/_index.md ×1) plus the single aliases: entry at content/docs/iac/concepts/resources/_index.md:17 — both classes the guard excludes by design. All 12 are at 0 real occurrences, matching the author's count exactly; source: gh pr view 21138/21145 -R pulumi/docs --json state,mergedAt; grep over the 12 paths in the 6270eebb checkout; repo:scripts/lint/check-concepts-links.js (L201-223, L253-296))
  • "the branch is rebased onto current master, so resetting the baseline doesn't turn this PR's own CI red on pre-existing content" → ✅ verified (evidence: compare master...6270eebb returned status: ahead, ahead_by: 6, behind_by: 0, and the file list is exactly the three script files — scripts/lint.sh, check-concepts-links.js, concepts-links-baseline.json. Master's tip at the time was ca4e73e (2026-08-26T22:12:44Z), which postdates both merges. The archetypes/templates/template/index.md hunk correctly dropped out of the PR: master already carried the identical three lines from Fix internal links to old /docs/concepts/ URLs missed by #21072 #21138, so the rebase resolved it to a no-op; source: gh api repos/pulumi/docs/compare/master...6270eeb; gh api repos/pulumi/docs/commits/master)
  • "with the baseline at {} the whole rebased tree still scans clean, so the guard is a true zero-tolerance gate rather than a red build" → ✅ verified (evidence: re-implemented isInternalReference/findInternalOccurrencesInLine/scanFileContent and audited all eight SCAN_ROOTS. Across layouts, theme, assets, data, archetypes, static, and scripts the only file containing the literal /docs/concepts is scripts/lint/check-concepts-links.js itself (28 occurrences), which EXCLUDED_FILES skips on both entry points. In content, every one of the 211 raw occurrences falls into an excluded class: external hosts (kubernetes.io, developer.hashicorp.com, developer.okta.com, open-cluster-management.io), the image path content/docs/iac/concepts/config.md:771 (/images/docs/concepts/...), or a frontmatter aliases: entry — spot-checked content/docs/reference/glossary.md:12, content/docs/iac/comparisons/k8s-yaml-dsls.md:21, content/docs/iac/operations/stack-management/update-plans.md:19-20, and the indented-list form at content/docs/iac/concepts/inputs-outputs/all.md:15-16, all inside aliases: blocks the scanner skips. Real internal occurrences: 0. Confirmed independently this pass by CI — run 33021247653 on 6270eebb is green, and its Build and deploy step reaches make lint → the guard, post-ensure; source: grep over all eight scan roots in the checkout; gh api repos/pulumi/docs/actions/runs/33021247653; repo:Makefile (L112-118, L213-214))
  • L35-39 in scripts/lint/check-concepts-links.js "PR Fix internal links to old /docs/concepts/ URLs missed by #21072 #21138 and PR Fix redirect-hop links in why-choose-pulumi-over-terraform blog post #21145 both merged, so it is empty today ({}) -- any new entry is a fresh, justified exception, not leftover cleanup debt" → ✅ verified (evidence: the header comment's Exceptions: paragraph no longer describes the two PRs as open, and the baseline it points at is {"_note": …, "files": {}}. Line 6's surviving reference — "See PR Fix internal links to old /docs/concepts/ URLs missed by #21072 #21138 and PR Fix redirect-hop links in why-choose-pulumi-over-terraform blog post #21145 for the most recent cleanup pass" — is a citation of a completed cleanup, not a claim about their state; source: repo:scripts/lint/check-concepts-links.js (L1-40); repo:scripts/lint/concepts-links-baseline.json)
  • L2 in scripts/lint/concepts-links-baseline.json "A file's count going lower than what is recorded here only prints a warning, never fails; going higher fails the build" → ✅ verified (evidence: the over-limit loop sets failed = true only when violations.length > allowed, and the stale loop only pushes a message to staleFiles, which is printed without touching failed. As of 0efd545 the sentence immediately following this one in the _note is the count-only-ratchet caveat again, so the shipped note documents both halves of the ratchet's behavior; source: repo:scripts/lint/check-concepts-links.js (L427-467, L342-351); git show 0efd545a:scripts/lint/concepts-links-baseline.json)
  • L2 in scripts/lint/check-concepts-links.js "* Guards against new internal links to the retired /docs/concepts/* URL" → ✅ verified (evidence: PR Fix internal links to old /docs/concepts/ URLs (redirect-hop cleanup) #21072 (merged, cited in the script's own header) confirms: "Fixes internal links that still point at the pre-restructure /docs/concepts/... URL space. Those URLs now 301 redirect ... to their current homes under…; source: gh pr view 21072 -R pulumi/docs; repo:content/docs/iac/concepts/programming-model.md; repo:scripts/lint/check-concepts-links.js)
  • L3 in scripts/lint/check-concepts-links.js "* space. Those pages moved to /docs/iac/concepts/* a while back (see" → ➖ not-a-claim — a code comment in the author's own lint script explaining its own rationale; PR Fix internal links to old /docs/concepts/ URLs (redirect-hop cleanup) #21072 is real and matches the description (source: repo:scripts/lint/check-concepts-links.js; gh pr view 21072 -R pulumi/docs)
  • L10-11 in scripts/lint/check-concepts-links.js "* references to /docs/concepts/... and fails when it finds one that is not an intentional aliases: redirect stub" → ✅ verified (evidence: scanFileContent() tracks inFrontmatter/inAliasesBlock and continues over both the aliases: key line (inline-array form included) and every - list item beneath it, ending the block on the next frontmatter key; re-confirmed against both the flush form (content/docs/reference/glossary.md:11-13) and the indented form (content/docs/iac/concepts/inputs-outputs/all.md:14-16), which trimmed normalizes; source: repo:scripts/lint/check-concepts-links.js (L253-296))
  • L13 in scripts/lint/check-concepts-links.js "* pulumi.com itself, never when /docs/concepts/ merely happens to appear" → ✅ verified (evidence: isInternalReference() resolves the token's scheme and host and returns true only for INTERNAL_HOSTS; confirmed across the whole tree — every external /docs/concepts occurrence in content/ sits behind kubernetes.io, developer.hashicorp.com, developer.okta.com, or open-cluster-management.io, and all are correctly excluded; source: repo:scripts/lint/check-concepts-links.js (L201-223))
  • L15 in scripts/lint/check-concepts-links.js "* have their own unrelated /docs/concepts/ paths) or inside an unrelated" → ➖ not-a-claim — a comment explaining the script's own path-matching logic (source: repo:scripts/lint/check-concepts-links.js)
  • L16-22 in scripts/lint/check-concepts-links.js "It also skips webpack's own generated JS bundles under static/js/ … but only when git itself confirms the specific path is ignored build output, not merely by filename pattern" → ✅ verified (evidence: isGeneratedBundle() gates on three conditions in order — GENERATED_BUNDLE_DIRS.has(path.posix.dirname(rel)), then the filename pattern, then checkIgnore(fullPath) — so neither a pattern match outside static/js nor a pattern match on a non-ignored path can reach a skip; source: repo:scripts/lint/check-concepts-links.js (L108, L126-133))
  • L23-25 in scripts/lint/check-concepts-links.js "(Generated CSS isn't in scope either way: SCANNABLE_EXTENSIONS below has no .css entry, so walk() never reaches a stylesheet, generated or hand-authored.)" → ✅ verified (evidence: SCANNABLE_EXTENSIONS is exactly .md .mdx .html .ts .tsx .js .jsx .yaml .yml — no .css — and walk() is the only producer of the default file list, so no stylesheet ever enters runScan(); source: repo:scripts/lint/check-concepts-links.js (L166-176, L314-330))
  • L59-69 in scripts/lint/check-concepts-links.js "Scoped to this one file rather than all of scripts/lint/, so lint-markdown.js and any future script in that directory that emits a genuine doc URL are still scanned" → ✅ verified (evidence: path.join("scripts","lint") is gone from EXCLUDED_DIR_SEGMENTS and EXCLUDED_FILES holds the single path scripts/lint/check-concepts-links.js (L74). Re-checked on the rebased tree: scripts/lint/ still contains only check-concepts-links.js, concepts-links-baseline.json, lint-markdown.js, and frontmatter-title-case.py, none of which carries a /docs/concepts occurrence, so the widened scan introduces nothing at zero allowance; source: repo:scripts/lint/check-concepts-links.js (L48-74, L166-176); ls scripts/lint/; grep -rl "/docs/concepts" scripts/)
  • L64-69 in scripts/lint/check-concepts-links.js "checked in runScan() (both the repo-walk and the explicit-file entry points) rather than shouldSkipDir()/walk(), so node check-concepts-links.js scripts/lint/check-concepts-links.js agrees with a full-repo run" → ✅ verified (evidence: runScan() has exactly one call site (L410), and main() funnels both explicitFiles and collectDefaultFiles() into it (L403-410); the EXCLUDED_FILES.has(rel) check sits at the top of runScan()'s loop (L380), so it is unconditionally on both paths; source: repo:scripts/lint/check-concepts-links.js (L375-395, L395-411))
  • L70-73 in scripts/lint/check-concepts-links.js "relativePath() always joins with "/" regardless of platform, so this set uses a literal posix-style path rather than path.join (which would emit "\\" on Windows and never match)" → ✅ verified (evidence: relativePath() is path.relative(REPO_ROOT, filePath).split(path.sep).join("/") — an explicit separator normalization, so the posix literal in EXCLUDED_FILES matches on every platform; source: repo:scripts/lint/check-concepts-links.js (L371-373))
  • L88-93 in scripts/lint/check-concepts-links.js ".gitignore also lists a styles.*.css bundle under static/css/, but SCANNABLE_EXTENSIONS below has no .css entry … so that pattern and a static/css directory entry would both be unreachable dead code here" → ✅ verified (evidence: .gitignore:60 is static/css/styles.*.css, so the premise is real; .css is absent from SCANNABLE_EXTENSIONS, so the conclusion holds. Both the /^styles\.[0-9a-f]+\.css$/ pattern and the "static/css" set entry are absent from the code, and the only surviving mentions of CSS in the file are these explanatory comments; source: repo:.gitignore (L58-69); repo:scripts/lint/check-concepts-links.js (L23-25, L88-93, L108-118, L166-176))
  • L79-90 in scripts/lint/check-concepts-links.js "static/js/consent-manager.cbf13435.js, …e2d01ae6.js, marketing-homepage.31d6cfe0.js, and …46f9fbff.js are all git-tracked on master today … a name match only skips the file when git check-ignore also treats the path as build output" → ✅ verified (evidence: all four are present in the checkout, and .gitignore:66-68 matches both name families — yet git check-ignore -q -- static/js/consent-manager.cbf13435.js exits 1, because check-ignore consults the index by default and reports a tracked path as not ignored. An untracked static/js/bundle.deadbeef.js exits 0 under .gitignore:62. None of the four carries a /docs/concepts occurrence, so scanning them at zero allowance is safe; source: ls static/js; repo:.gitignore (L58-69); git check-ignore -q -- (both paths, in this checkout))
  • L149-159 in scripts/lint/check-concepts-links.js "Two distinct failures land here and are deliberately not told apart by return value … Both fail closed to false … but an atypical failure still deserves a one-line signal on stderr" → ✅ verified (evidence: the branch is if (!(typeof e.status === "number" && e.status === 1)) { console.warn(...) } followed by an unconditional return false — the two arms no longer both return, so the if is live and the comment describes what the code does. console.warn writes to stderr in Node, as the comment states; source: repo:scripts/lint/check-concepts-links.js (L143-163))
  • L108 in scripts/lint/check-concepts-links.js "The directory restriction additionally keeps a loose pattern like /^chunk-.*.js$/ from reaching outside static/js into the other seven scan roots" → ✅ verified (evidence: GENERATED_BUNDLE_DIRS is the single-entry set {"static/js"} and isGeneratedBundle() tests it first and returns early, before both the pattern test and the git subprocess; the arithmetic "other seven" also matches SCAN_ROOTS' eight entries; source: repo:scripts/lint/check-concepts-links.js (L108, L126-133); repo:scripts/lint/check-concepts-links.js L49)
  • L97 in scripts/lint/check-concepts-links.js "const TARGET = '/docs/concepts';" → ➖ not-a-claim — a JS constant, not a falsifiable assertion (source: repo:scripts/lint/check-concepts-links.js)
  • L197-199 in scripts/lint/check-concepts-links.js "* Decides whether the /docs/concepts occurrence at index in line is an" → ➖ not-a-claim — a JSDoc comment describing the function's own purpose (source: repo:scripts/lint/check-concepts-links.js)
  • L206 in scripts/lint/check-concepts-links.js "// reference like (/docs/concepts/stacks/) or href='/docs/concepts/x/'." → ➖ not-a-claim — a comment describing the pattern the code matches (source: repo:scripts/lint/check-concepts-links.js)
  • L215-217 in scripts/lint/check-concepts-links.js "// /images/docs/concepts/foo.png. That is not our /docs/concepts/*" → ➖ not-a-claim — explains the script's own exclusion logic, confirmed both by the matching self-test fixture and by the live case at content/docs/iac/concepts/config.md:771 (source: repo:scripts/lint/check-concepts-links.js)
  • L226 in scripts/lint/check-concepts-links.js "* Finds every /docs/concepts occurrence in line, returning the internal" → ➖ not-a-claim — describes the behavior of a function in the PR's own script (source: repo:scripts/lint/check-concepts-links.js)
  • L228 in scripts/lint/check-concepts-links.js "* line, or a small set of terminators, so /docs/concepts-overview (a" → ➖ not-a-claim — a faithful description of the author's own boundary logic (source: repo:scripts/lint/check-concepts-links.js)
  • L283 in scripts/lint/check-concepts-links.js "// Inline array form: aliases: [/docs/concepts/x, ...]" → ➖ not-a-claim — a comment illustrating YAML syntax the script handles (source: repo:scripts/lint/check-concepts-links.js)
  • L22-24 in scripts/lint/check-concepts-links.js "--print-baseline # print a fresh baseline (in the same {_note, files} envelope loadBaseline() reads)" → ✅ verified (evidence: the printer emits JSON.stringify({ _note: loadBaselineNote(), files: counts }, null, 4) and loadBaseline() reads parsed.files || {} — the emitted envelope is exactly the shape the reader consumes, and matches the {_note, files: {}} now committed; source: repo:scripts/lint/check-concepts-links.js)
  • L410-419 in scripts/lint/check-concepts-links.js "--print-baseline output reflects the same exclusions a scan applies" → ✅ verified (evidence: --print-baseline builds counts from the same byFile that runScan() returned, so the EXCLUDED_FILES skip and the explicit-file isGeneratedBundle() filter both apply to a regenerated baseline; on the rebased tree byFile is empty, which is why the regenerated envelope is files: {} rather than self-baselined. Note that with 0efd545 the round-trip is also now lossless for the caveat: loadBaselineNote() carries the shipped _note forward verbatim, so regenerating no longer risks silently swapping it for the fallback constant's differently-worded text; source: repo:scripts/lint/check-concepts-links.js (L403-419); repo:scripts/lint/concepts-links-baseline.json)
  • L387 in scripts/lint/check-concepts-links.js "'\nFound new internal link(s) to the retired /docs/concepts/* URL space.\n' +" → ➖ not-a-claim — an error string emitted by the author's own script describing its own detection behavior (source: repo:scripts/lint/check-concepts-links.js)
  • L388-389 in scripts/lint/check-concepts-links.js "'Fix: point the link at the current /docs/iac/concepts/... path instead ' +" → ✅ verified (evidence: the pulumi/docs repo confirms /docs/iac/concepts/... is the current, actively-used path space (content/docs/iac/concepts/_index.md, config.md, stacks.md, projects/_index.md); source: gh search code --owner pulumi "docs/iac/concepts" -R pulumi/docs; ls content/docs/iac/concepts/)
  • L449 in scripts/lint/check-concepts-links.js "No new /docs/concepts/* internal links found (${byFile.size} file(s) with baselined occurrences, all within their allowance)." → ✅ verified (evidence: on the !failed branch every entry in byFile has violations.length <= allowed by construction of the accumulation loop, so the summary's "all within their allowance" is accurate and byFile.size is the count it names — which on the rebased tree is now 0; source: repo:scripts/lint/check-concepts-links.js (L427-450))
  • L435-441 in scripts/lint/check-concepts-links.js "the stale-baseline warning fires for any baselined file whose count dropped, including to zero" → ✅ verified (evidence: the stale loop iterates Object.entries(baseline) rather than byFile, and computes foundCount = found ? found.length : 0. With files: {} the loop body never executes, so the twelve warning lines an earlier pass predicted on merge will not print — the reset removed the condition rather than deferring it; source: repo:scripts/lint/check-concepts-links.js (L435-441, L459-467))
  • L54 in archetypes/templates/template/index.md "project" → ✅ verified (evidence: content/docs/iac/concepts/projects/_index.md exists, so the URL is a direct 200 with no redirect hop. As of 6270eebb this line is no longer in the PR's diff — the rebase collapsed the hunk to a no-op against what Fix internal links to old /docs/concepts/ URLs missed by #21072 #21138 already landed — but the target is unchanged on the rebased tree; source: ls content/docs/iac/concepts/projects/)
  • L74 in archetypes/templates/template/index.md "stack output" → ✅ verified (evidence: content/docs/iac/concepts/stacks.md:210 is ## Stack outputs {#outputs} — an explicit anchor override, so the #outputs fragment resolves exactly. Note the path also corrects the old singular stack/ to stacks/. Same rebase no-op caveat as L54; source: repo:content/docs/iac/concepts/stacks.md (L210))
  • L94 in archetypes/templates/template/index.md "configuration" → ✅ verified (evidence: content/docs/iac/concepts/config.md exists, so the URL is a direct 200 with no redirect hop. Same rebase no-op caveat as L54; source: ls content/docs/iac/concepts/)
  • L54, L74, L94 in archetypes/templates/template/index.md "the three fixed links match what master already shipped" → ✅ verified (evidence: master (ca4e73e) already carries the identical three lines from Fix internal links to old /docs/concepts/ URLs missed by #21072 #21138. An earlier pass predicted this hunk would be a clean no-op on merge; 6270eebb's rebase confirms it — the file has dropped out of the PR's changed-file list entirely, leaving scripts/lint.sh, check-concepts-links.js, and concepts-links-baseline.json; source: gh api repos/pulumi/docs/compare/master...6270eeb; gh pr view 21146 --json files)
  • L482-563 in scripts/lint/check-concepts-links.js "the self-test covers the classifier's behavior" → 🤷 unverifiable (evidence: the fixture set is unchanged by this commit and still covers isInternalReference, the terminator boundary, the image-path case, and the aliases: block; it has no fixture for EXCLUDED_FILES, --print-baseline, or the green/failing output split. node v24.18.0 was present in this run but direct invocation was not permitted, so --self-test was again read rather than executed — CI's green lint step on 6270eebb exercises the scanner end to end, though not the self-test path specifically. Whether the fixture residue warrants filling on a guard whose baseline is now empty remains a maintainer judgment; source: repo:scripts/lint/check-concepts-links.js (L482-563); gh api repos/pulumi/docs/actions/runs/33021247653)
  • L59-63 in scripts/lint/check-concepts-links.js "scanning it would be 100% self-referential noise, not a real link anyone will ever click" → 🤷 unverifiable (evidence: the 28 occurrences in this file are doc comments, usage examples, and --self-test fixtures, all of which do read as deliberate; but "100%" is a judgment about the file's future contents rather than a checkable fact. Low stakes — the set holds exactly one path and is trivially auditable; source: repo:scripts/lint/check-concepts-links.js (L59-74, L375-382))

🚨 Outstanding in this PR

No outstanding findings.

⚠️ Low-confidence

No low-confidence findings.

📋 Triaged verifier findings

I double-checked these and realized they weren't real findings — click to expand
  • The restored caveat now duplicates DEFAULT_BASELINE_NOTE, so the two will drift again — the same sentence living in two places is the usual setup for a divergence, and this PR already demonstrated one. But the duplication is structural, not accidental: DEFAULT_BASELINE_NOTE is only reached when the JSON is missing or unparseable, and loadBaselineNote() carries the shipped _note forward on --print-baseline, so a regeneration preserves the file's own text rather than overwriting it with the constant. Collapsing the two would mean the fallback couldn't describe the ratchet at all. Not a finding.

  • The _note is now long enough that the caveat gets skimmed past — the shipped string runs ~640 characters on one line. Real, but it is a JSON string value; there is no wrapping mechanism available without switching to an array-of-lines shape, which loadBaselineNote() and the --print-baseline printer would both need to learn. Not worth it for a file whose files map is {}.

  • The reset leaves the guard with no cushion, so any generated file make ensure writes into a scan root turns lint red — with files: {} there is zero headroom anywhere, and assets/ is a scan root whose assets/js/ output is gitignored build product that isGeneratedBundle() does not cover (it is scoped to static/js). But assets/js/ doesn't exist in a pre-build checkout, its filenames are stable and unhashed (so baselineable if they ever did carry an occurrence), and it carried none before the reset either — the previous baseline had no assets/js entry, so a violation there would have failed the build just as hard. Independently settled this pass: the green run on 6270eebb ran lint after ensure, so a fully built tree scanned clean.

  • The webpack bundle regenerated by make ensure will carry the 33 occurrences from redirects.tsstatic/js/bundle.<hash>.js is compiled from theme/src/ts/*.ts, which is where those 33 lived. Two independent reasons this is moot: theme/src/ts/redirects.ts and toc.ts are both clean on the rebased tree (grep over theme/src/ts returns nothing), and the generated bundle is gitignored, so isGeneratedBundle() skips it regardless.

  • The archetype fix vanished from the diff during the rebasearchetypes/templates/template/index.md was in the PR's file list two passes ago and isn't now, which reads like a dropped fix. Master already carried the identical three lines from Fix internal links to old /docs/concepts/ URLs missed by #21072 #21138, so the rebase correctly collapsed the hunk to a no-op. Confirmed the three targets are still right on the rebased tree: /docs/iac/concepts/projects/, /docs/iac/concepts/stacks/#outputs, and /docs/iac/concepts/config/.

  • git check-ignore doesn't know about tracked files, so the four checked-in bundles are still skipped — ignore rules are usually described as a property of .gitignore alone, and .gitignore:66-68 do match all four filenames. But check-ignore consults the index by default (that's what --no-index exists to turn off), so a tracked path is reported not ignored regardless of the patterns. Verified in this checkout: git check-ignore -q -- static/js/consent-manager.cbf13435.js exits 1 while an untracked static/js/bundle.deadbeef.js exits 0.

  • Shelling out per file makes the walk slowexecFileSync inside the walk looked like one git process per scanned file across eight scan roots. isGeneratedBundle() early-returns on the directory check before the pattern test and before checkIgnore, so the subprocess only fires for files sitting directly in static/js that already match a bundle pattern — a handful per run, and zero in a tree that hasn't been built.

  • make lint runs before the bundles exist, so the exclusion is mootci_pull_request is ensurelint → build, which suggested static/js/ would be empty at lint time. It isn't: make ensure compiles the theme, which is why an earlier CI run reported 34 violations in static/js/bundle.b15c1961.js. The exclusion is live on exactly the path CI takes.

  • .gitignore lists two generated paths the new patterns don't cover/static/js/components.js and /static/js/components/ are gitignored Stencil output, are .js, and match no GENERATED_BUNDLE_PATTERNS entry. But theme/stencil/stencil.config.ts targets ./dist and ./www, both already in EXCLUDED_DIR_SEGMENTS, and Stencil is only wired into theme/package.json's start (watch) script, not into make build. Those two .gitignore lines are stale; nothing writes them during a CI build.

💡 Pre-existing issues in touched files (optional)

No pre-existing issues in touched files.

✅ Resolved since last review

  • [L2] scripts/lint/concepts-links-baseline.json — the rewritten _note had dropped the sentence documenting the count-only ratchet's substitution gap, leaving the caveat alive only in DEFAULT_BASELINE_NOTE, a constant nobody reads until the JSON is gone. Resolved in 0efd545 by exactly the fix the finding sketched: the sentence is appended back to the end of the _note string, and nothing else in the file changed (the patch is a single line). Verified rather than assumed — the restored text is byte-identical to the sentence 77763c3 originally added and to the tail of DEFAULT_BASELINE_NOTE at check-concepts-links.js:342-351, hyphenation included, so the two copies agree again and a --print-baseline regeneration (which carries the shipped _note forward via loadBaselineNote()) now round-trips the caveat instead of losing it. The change can't affect the guard's own behavior: .json isn't in SCANNABLE_EXTENSIONS, so the baseline file is never scanned as content, and the file still parses with files: {}.

  • [L2] scripts/lint/concepts-links-baseline.json — the baseline was entirely dead and shipped as a 57-occurrence hole in the guard: Fix internal links to old /docs/concepts/ URLs missed by #21072 #21138 and Fix redirect-hop links in why-choose-pulumi-over-terraform blog post #21145 had both merged, so all 12 entries were at 0 real occurrences while still granting headroom a future PR could spend silently under the count-only ratchet. Resolved in 6270eebb, in the order the finding called for: the branch was rebased onto current master (ca4e73e) firstcompare master...6270eebb reports behind_by: 0 — and only then was files reset to {}. Verified independently rather than taken from the mention: all 12 formerly-baselined paths are at 0 real occurrences on the rebased tree (the only survivors are kubernetes.io URLs and one aliases: entry, both excluded by design), and a full re-implementation of the classifier across all eight scan roots finds 0 real occurrences anywhere — a result CI has since confirmed with a green lint step on that head (run 33021247653). The _note and the script's Exceptions: header comment were both rewritten to stop describing the two PRs as open, and the archetypes/ hunk correctly dropped out of the diff as a rebase no-op.

  • [L128-140] scripts/lint/check-concepts-links.jsgitCheckIgnore()'s catch branched on e.status === 1 and then returned false from both arms, collapsing "git says not ignored" and "git is unusable" into one silent answer. Resolved in e6bcf7b via the second option the finding offered: the branch is inverted to if (!(typeof e.status === "number" && e.status === 1)) and warns once per occurrence on the atypical path before the unconditional return false. Fail-closed behavior is unchanged, the if is now live rather than dead, and a git-less environment gets a stderr line naming the real cause instead of ~30 phantom stale-link violations.

  • [L56-69] scripts/lint/check-concepts-links.js — the styles.*.css pattern could never fire and the "static/css" entry in GENERATED_BUNDLE_DIRS was equally unreachable, both because .css isn't in SCANNABLE_EXTENSIONS. Resolved in e6bcf7b by taking the first option: both are deleted, GENERATED_BUNDLE_DIRS is now the single-entry {"static/js"}, and the header comment's scope claim narrows from "static/js/ and static/css/" to "static/js/". The reasoning is preserved as prose in two places (L23-25 and L88-93) with an explicit pointer to SCANNABLE_EXTENSIONS should CSS scanning ever be added.

  • [L4] scripts/lint/concepts-links-baseline.json — baselining archetypes/templates/template/index.md at 3 left the next person to run hugo new -k template with three violations against their own brand-new file and no clue the archetype wrote them. Resolved in e6bcf7b via the stronger of the two options: the three links were fixed in place rather than documented, and the baseline entry dropped. All three targets independently confirmed as direct 200s — /docs/iac/concepts/projects/, /docs/iac/concepts/stacks/#outputs (stacks.md:210 is ## Stack outputs {#outputs}, an explicit anchor override, and the path also corrects the old singular stack/), and /docs/iac/concepts/config/. The rebase in 6270eebb then collapsed the hunk to a no-op, since Fix internal links to old /docs/concepts/ URLs missed by #21072 #21138 had landed the identical lines on master.

  • [L49-54] scripts/lint/check-concepts-links.js — the self-reference exclusion was directory-wide (scripts/lint) and consulted only from walk(), so it over-excluded siblings and didn't hold when the guard was pointed at a file directly. Resolved in e6bcf7b, exactly along the line the finding sketched: EXCLUDED_DIR_SEGMENTS loses the directory and a new EXCLUDED_FILES set holding the single path scripts/lint/check-concepts-links.js is tested against relativePath(file) at the top of runScan()'s loop. Verified both halves — runScan() has one call site and main() funnels the explicit-file and repo-walk paths through it, so the exclusion is unconditional on both entry points; and the widened scan is safe because no sibling in scripts/lint/ carries an occurrence.

  • [L70-79] scripts/lint/check-concepts-links.jsisGeneratedBundle() matched on the bare basename, so the exclusion was unanchored in two directions (four checked-in static/js/ files silently unscanned; /^chunk-.*\.js$/ reaching into all eight scan roots). Resolved in 3fd1d42 on both counts, and by a stronger mechanism than the one suggested: the path must sit directly under the generated-bundle directory (GENERATED_BUNDLE_DIRS, checked first) and be confirmed ignored by git check-ignore.

  • [L246] scripts/lint/check-concepts-links.js — the bundle exclusion lived on the walk-only side of the seam, so node check-concepts-links.js static/js/bundle.<hash>.js disagreed with a full-repo run. Resolved in 3fd1d42: main() resolves explicit arguments to absolute paths and filters them through isGeneratedBundle() before scanning. The self-exclusion half of the same seam is now closed too, in e6bcf7b — see [L49-54] above.

  • [L36] scripts/lint/check-concepts-links.jsSCAN_ROOTS omitted archetypes/, static/, and scripts/. Resolved in 77763c3: all three roots added. Re-scanned on the rebased tree — with all eight roots in scope and no baseline cushion, the scan still finds nothing.

  • [L276] scripts/lint/check-concepts-links.js--print-baseline emitted a flat {path: count} object its own loadBaseline() couldn't read. Resolved in 77763c3: the printer emits { _note, files }, builds counts from [...byFile.keys()].sort(), carries the existing baseline's _note forward via loadBaselineNote() (falling back to DEFAULT_BASELINE_NOTE when the file is gone), and --print-baseline is now in the Usage block.

  • [L287] scripts/lint/check-concepts-links.js — every green run printed every baselined occurrence. Resolved in 77763c3: the per-file detail moved into overLimitReport and prints only on the failing branch; a passing run emits one summary line. With the baseline now empty, a green run's stale-baseline loop never executes either, so the output is a single line.

  • [L2] scripts/lint/concepts-links-baseline.json — the count-only ratchet's substitution gap was undocumented. Resolved in 77763c3 by adding the caveat to both the baseline's _note and the matching DEFAULT_BASELINE_NOTE constant. The _note copy was lost in the 6270eebb reset and restored in 0efd545 — see the first entry in this list.

📜 Review history

  • 2026-08-26T19:02:39Z — Two blockers: SCAN_ROOTS misses archetypes/ (three live stale links in the template scaffold) and --print-baseline emits a shape loadBaseline() can't read; two low-confidence notes on output noise and the count-only ratchet. (0bcce5a)
  • 2026-08-26T20:34:15Z — re-reviewed after fix push (1 new commit, 77763c3). All four previous findings resolved and moved to ✅; 🚨 is now empty. Three new low-confidence notes from the new code: the archetype's interim baselined state trips an unrelated hugo new -k template author, the scripts/lint self-exclusion is directory-wide and walk-only, and runSelfTest() still has no fixture for the main() behavior that broke last round. (77763c3)
  • 2026-08-26T21:02:05Z — re-reviewed after fix push (1 new commit, 110c61d) at @workprentice[bot]'s request. The push fixes a self-inflicted CI break rather than a review finding, so 🚨 stays empty and nothing moved to ✅. The core rationale checks out independently: the 34 occurrences in the excluded bundle are exactly the 34 in theme/src/ts/redirects.ts + toc.ts, both of which the guard scans directly and both of which are baselined, so the exclusion drops a duplicate signal rather than a distinct one; and the pattern list covers every entry and chunk name theme/webpack.config.js actually emits. Two new low-confidence notes on the exclusion's breadth and one noting it shares the walk-only gap already filed as [L49-54]. Three candidate findings were investigated and dropped — see 📋 Triaged. One caveat on the evidence: the Install deps and build site check was still pending at review time. (110c61d)
  • 2026-08-26T21:52:24Z — re-reviewed after fix push (1 new commit, 3fd1d42) at @workprentice[bot]'s request, who reported all five low-confidence findings addressed. Two of the five resolved and moved to ✅ — [L70-79] and [L246]. The load-bearing mechanism was verified against this checkout rather than taken from the mention: git check-ignore consults the index by default, so the tracked bundles are scanned again while an untracked one is skipped. Three stayed in ⚠️: [L56-69], [L4], and [L49-54]. One new low-confidence note on gitCheckIgnore()'s dead branch. Two evidence caveats: node unavailable, and the lint job still in_progress. (3fd1d42)
  • 2026-08-26T22:34:15Z — re-reviewed after fix push (1 new commit, e6bcf7b). All four outstanding low-confidence findings resolved and moved to ✅. ⚠️ empty for the first time on this PR, and the standing "reviewed statically only" caveat retired on a green Install deps and build site (run 33018667732). One new blocker, surfaced by checking the state that green run depends on: Fix internal links to old /docs/concepts/ URLs missed by #21072 #21138 and Fix redirect-hop links in why-choose-pulumi-over-terraform blog post #21145 both merged at 21:32Z, ~40 minutes before this head was pushed, so the baseline's own stated exit condition had fired — leaving a 57-occurrence allowance (33 of it on theme/src/ts/redirects.ts) that a future PR could spend silently under the count-only ratchet. The branch base predated both merges, which is exactly why CI was green there and why the baseline had to be reset after master was brought forward. (e6bcf7b)
  • 2026-08-26T22:56:10Z — re-reviewed after fix push (1 new commit, 6270eeb) at @workprentice[bot]'s request, who reported the branch rebased and the baseline reset. History was rewritten (the rebase), so compare e6bcf7b...6270eebb returns diverged and this pass re-reviewed the full PR diff against HEAD rather than a range. The blocker is resolved and moved to ✅ — and the order the finding insisted on was actually followed: compare master...6270eebb reports behind_by: 0 against ca4e73e, so the rebase landed before the reset. I did not take the "0 real occurrences" count from the mention. Two independent audits: (1) all 12 formerly-baselined paths re-checked individually — eight now carry no /docs/concepts string at all, the other four carry only kubernetes.io URLs plus the one aliases: entry at content/docs/iac/concepts/resources/_index.md:17, exactly as claimed; (2) more importantly, because an empty baseline removes every cushion, I re-implemented the guard's classifier (isInternalReference / terminator boundary / aliases:-block skip / EXCLUDED_FILES / isGeneratedBundle) and swept all eight scan roots, not just the twelve — outside content/ the only file containing the literal string is the guard's own excluded source, and inside content/ all 211 raw occurrences resolve to external hosts, one /images/docs/concepts/… asset path, or aliases: frontmatter (spot-checked both the flush and indented list forms). Real internal occurrences across the whole tree: 0. The archetypes/ hunk correctly dropped out of the diff as a rebase no-op — three candidates around that and around the reset's lost cushion were investigated and dropped, see 📋 Triaged. One new ⚠️: the rewritten _note dropped the count-only-ratchet caveat sentence that 77763c3 added to resolve an earlier finding; it survives only in DEFAULT_BASELINE_NOTE, which nobody reads until the JSON is gone. Two evidence caveats, both narrower than last pass: node was again unavailable, so --self-test was read rather than run; and the Install deps and build site job on this head (run 33021247653) was still in progress with the lint step not yet started, so the zero-occurrence result above is my audit rather than CI's — a green run on 6270eebb is what would close it. (6270eeb)
  • 2026-08-26T23:19:11Z — re-reviewed after fix push (1 new commit, 0efd545) at @workprentice[bot]'s request, who reported the dropped caveat restored. compare 6270eebb...0efd545 is ahead_by: 1, one file, a single-line patch. The last ⚠️ is resolved and moved to ✅ — both buckets are now empty. The restore was checked rather than accepted: the appended sentence is byte-identical to the one 77763c3 added and to the tail of DEFAULT_BASELINE_NOTE, so the JSON and the fallback constant agree again and loadBaselineNote() will round-trip the caveat through --print-baseline instead of dropping it. No new findings — the commit touches one string in a file the guard never scans (.json is absent from SCANNABLE_EXTENSIONS), and the file still parses with files: {}. Two long-standing evidence caveats retired. First, run 33021247653 on 6270eebb finished green, and its Build and deploy step runs make ci_pull_requestensurelintscripts/lint.sh:6 → the guard, so CI has now executed the zero-baseline guard against a fully built tree and independently reproduced last pass's manual zero-occurrence audit — including on the generated static/js/ bundles that only exist post-ensure. Second, the merge-forward risk an empty baseline creates was closed deterministically: master moved ca4e73e412245b (behind_by: 1), and that delta is four files under infrastructure/, which is not in SCAN_ROOTS, so nothing landing on master since the rebase can turn this guard red after merge. Confidence upgraded to HIGH on both facts and infra. Residual, unchanged: node v24.18.0 was present but direct invocation was not permitted, so --self-test was read rather than executed, and the build job on this head is still pending — neither is load-bearing now that the previous head's equivalent run is green on an identical scanner. Two candidates around the restored sentence were investigated and dropped, see 📋 Triaged. (0efd545)

  • Refresh this review — comment @claude #update-review. Say what you fixed, or which finding you dispute and why; both work in the same mention.
  • Ask for anything else — comment @claude with no hashtag (questions, one-off fixes). Leaves this review untouched.

Important

Please don't hide, resolve, or delete this comment! It breaks things!

📖 How pre-merge review works — the full lifecycle, short-circuits, and escape hatches.

@github-actions github-actions Bot added review:outstanding-issues Claude review completed; outstanding has author-actionable findings and removed review:in-progress Claude review is currently running labels Aug 26, 2026
@pulumi-bot

pulumi-bot commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

@github-actions github-actions Bot added review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention and removed review:outstanding-issues Claude review completed; outstanding has author-actionable findings labels Aug 26, 2026
@workprentice

workprentice Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all four findings, pushed 77763c3.

Outstanding 1 (SCAN_ROOTS gap): Added archetypes, static, and scripts to SCAN_ROOTS. archetypes/templates/template/index.md is now scanned (it carries 3 baselined occurrences from the scaffold, confirmed pre-existing not new). static/ added zero occurrences -- verified it's almost entirely binary images. scripts/ pulled in scripts/search/rank.js:80, baselined as a known non-link historical-href check rather than dropping the root, per your framing that a baselined occurrence still ratchets while an unscanned directory doesn't. Regenerated the baseline against origin/master (not this branch), since #21138 hasn't merged yet and the baseline needs to describe master's real current state.

Outstanding 2 (--print-baseline envelope mismatch): Fixed to emit {_note, files} instead of a flat object, built from the sorted violation-map keys rather than a JSON.stringify replacer keyed off Object.keys(). Added --print-baseline to the Usage: block. Round-trip verified: --print-baseline > file then loading that file back through loadBaseline() reports 13 non-empty entries matching the committed baseline.

Low-confidence 1 (noisy green-run output): Implemented -- a passing run now prints one summary line; full per-file/per-line detail only prints on failure. Verified both paths (see below).

Low-confidence 2 (count-only ratchet defeatable by substitution): Took your suggested path -- documented the limitation in the baseline's own _note rather than switching to line-level tracking, since the baseline file is short-lived by design (deleted once #21138 and #21145 merge) and isn't worth the added churn.

Evidence, run just now on this branch:

$ node scripts/lint/check-concepts-links.js --self-test
[PASS] x9 ... Self-test passed.

$ node scripts/lint/check-concepts-links.js
No new /docs/concepts/* internal links found (13 file(s) with baselined occurrences, all within their allowance).

$ node scripts/lint/check-concepts-links.js --print-baseline > /tmp/b.json
$ node -e "console.log(Object.keys(JSON.parse(require('fs').readFileSync('/tmp/b.json')).files).length)"
13

Fabricated-violation test (appended a /docs/concepts/testing/ link to archetypes/templates/template/index.md, a file only reachable after this fix):

archetypes/templates/template/index.md (4 occurrence(s), 3 allowed by baseline):
  ...L217: See [test](/docs/concepts/testing/) for a fabricated new hop.
Found new internal link(s) to the retired /docs/concepts/* URL space.
EXIT: 1

Reverted the injection; guard returned to the single green summary line, exit 0.

npx prettier --check against both changed files with this repo's .prettierrc.json passes.

@claude #update-review

@github-actions github-actions Bot added review:in-progress Claude review is currently running and removed review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention labels Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @workprentice[bot]'s request.

@github-actions github-actions Bot added review:no-blockers Claude review completed cleanly; outstanding is empty and removed review:in-progress Claude review is currently running labels Aug 26, 2026
workprentice Bot pushed a commit that referenced this pull request Aug 26, 2026
CI runs `make build` before `make lint`, and widening SCAN_ROOTS to include
static/ means the guard now sees webpack's own build output written into
static/js/ and static/css/ under content-hashed filenames (bundle.b15c1961.js
and friends -- see .gitignore's own list of these patterns). That output is
compiled straight from theme/src/ts/*.ts and theme/src/scss/*.scss, which
this guard already scans directly, so a stale link surviving only in the
generated bundle would already have been caught at its real source. Scanning
the bundle too just flags the same defect twice, under a filename that
changes on every build, so a baseline entry can never pin it down -- this is
exactly what failed PR #21146's own CI (static/js/bundle.b15c1961.js, 34
occurrences, 0 allowed).

Exclude by filename pattern rather than dropping static/js and static/css
from the scan outright, since those directories also hold genuine,
hand-authored, git-tracked files (static/js/pulumi-mermaid-theme.js,
static/js/versioned-docs.js) that should still be scanned -- verified this
directly: a real stale link in a non-generated static/js file is still
flagged after this change, only the exact generated-bundle filename is now
skipped.
@github-actions github-actions Bot added review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention and removed review:no-blockers Claude review completed cleanly; outstanding is empty labels Aug 26, 2026
@github-actions github-actions Bot added review:in-progress Claude review is currently running and removed review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention labels Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @workprentice[bot]'s request.

@github-actions github-actions Bot added review:outstanding-issues Claude review completed; outstanding has author-actionable findings and removed review:in-progress Claude review is currently running labels Aug 26, 2026
…output

Addresses both Outstanding findings and both Low-confidence findings
from the review bot on this PR.

Outstanding 1 -- SCAN_ROOTS omitted archetypes/ and static/, so a stale
/docs/concepts/* link born in a page's own generation scaffold
(archetypes/templates/template/index.md, which every new template page
is generated from) went unseen forever, only to be caught in
content/ after it had already been scaffolded into a real page. Added
archetypes, static, and scripts to SCAN_ROOTS. Including scripts pulled
in one occurrence, scripts/search/rank.js:80 (a historical-href check,
not a live link) -- baselined it rather than dropping the scripts root,
since a baselined known occurrence still ratchets while an unscanned
directory does not. static/ added zero new occurrences (verified: it's
mostly binary images, nothing text-scannable matched). Regenerated the
baseline against master state (not this branch's own commits) so it
describes what master actually has today; PR #21138 already removed the
prior rank.js occurrence via a different code path, so this branch's
own widened scan needed the fresh baseline to stay green.

Outstanding 2 -- --print-baseline printed a flat {path: count} object,
but loadBaseline() reads parsed.files || {}. Regenerating the baseline
the obvious way (redirecting --print-baseline output to the baseline
file) silently produced a baseline that loads as empty, then every
baselined file fails with no hint why. Fixed --print-baseline to emit
the same {_note, files} envelope the reader expects, building the
files object from the sorted violation-map keys directly rather than
using a JSON.stringify replacer keyed off Object.keys() (that reads as
a filter and silently drops any key not already present). Also added
--print-baseline to the Usage: block, which previously only documented
it inside main()'s own comments.

Low-confidence 1 -- implemented: a green run now prints one summary
line instead of ~11 file headers and ~56 occurrence lines. Full detail
only prints when the check actually fails. Verified: a fabricated
violation still prints full file-by-file detail and exits 1; a clean
run prints one line and exits 0.

Low-confidence 2 -- decision (not code): kept the ratchet as a
count-only check rather than switching to line-level tracking, since
the baseline is meant to be short-lived and line tracking isn't worth
the added churn for a file that should be deleted once #21138 and
#21145 merge. Documented the limitation directly in the baseline's own
_note so the next reader isn't surprised: fixing one stale link and
introducing a different one in the same file keeps the count unchanged
and still passes.

Verified end-to-end: self-test passes (9/9 fixtures), make-equivalent
full scan is green with the new summary line, --print-baseline output
round-trips through loadBaseline() as 13 non-empty entries, and a
fabricated /docs/concepts/testing/ link injected into
archetypes/templates/template/index.md (a root only reachable after
this fix) correctly fails the check with exit 1 and full detail, then
cleanly reverts to green.
CI runs `make build` before `make lint`, and widening SCAN_ROOTS to include
static/ means the guard now sees webpack's own build output written into
static/js/ and static/css/ under content-hashed filenames (bundle.b15c1961.js
and friends -- see .gitignore's own list of these patterns). That output is
compiled straight from theme/src/ts/*.ts and theme/src/scss/*.scss, which
this guard already scans directly, so a stale link surviving only in the
generated bundle would already have been caught at its real source. Scanning
the bundle too just flags the same defect twice, under a filename that
changes on every build, so a baseline entry can never pin it down -- this is
exactly what failed PR #21146's own CI (static/js/bundle.b15c1961.js, 34
occurrences, 0 allowed).

Exclude by filename pattern rather than dropping static/js and static/css
from the scan outright, since those directories also hold genuine,
hand-authored, git-tracked files (static/js/pulumi-mermaid-theme.js,
static/js/versioned-docs.js) that should still be scanned -- verified this
directly: a real stale link in a non-generated static/js file is still
flagged after this change, only the exact generated-bundle filename is now
skipped.
…lone

Addresses all 5 low-confidence findings from the pre-merge review on
110c61d:

- isGeneratedBundle() now requires the path to sit under static/js/ or
  static/css/ (not just match the basename anywhere across all 8 scan
  roots), closing the /^chunk-.*\.js$/ scope-creep gap.
- A filename-pattern match alone no longer settles it: the file also has
  to be something git itself treats as ignored (`git check-ignore`).
  This is the same source of truth the exclusion's own rationale already
  claimed to mirror, and it correctly tells apart the four checked-in
  static/js/consent-manager.*.js and marketing-homepage.*.js files
  (tracked, so check-ignore says "not ignored", so they're scanned) from
  an actual webpack-generated static/js/bundle.<hash>.js (untracked, so
  check-ignore says "ignored", so it's skipped).
- The same exclusion now applies whether files are found via the repo
  walk or passed explicitly on the command line, closing the seam where
  `node check-concepts-links.js static/js/bundle.<hash>.js` used to
  scan a bundle the full-repo run would have skipped.
- Added --self-test coverage for isGeneratedBundle via an injectable
  checkIgnore function, covering: a truly-ignored bundle (skip), a
  tracked file that only matches by name (scan), a pattern match outside
  static/js|css (scan), and a hand-authored static/js file that matches
  no pattern (scan).

Verified directly, not just asserted:
- Confirmed via `git check-ignore -v` that the four real checked-in
  files (consent-manager.cbf13435.js, consent-manager.e2d01ae6.js,
  marketing-homepage.31d6cfe0.js, marketing-homepage.46f9fbff.js) are
  NOT ignored (git's tracking overrides the .gitignore pattern match),
  so they are now scanned -- and produce 0 baseline entries since none
  currently carries a /docs/concepts/ link.
- Wrote a synthetic static/js/bundle.deadbeef.js matching .gitignore's
  real `static/js/bundle.*.js` rule with a planted stale link: confirmed
  it is skipped (exit 0, not counted).
- Wrote a synthetic static/js/consent-manager.aaaaaaa1.js (untracked, so
  matched and ignored like real build output) to confirm the
  distinguishing signal really is tracked-vs-untracked, not something
  else -- then removed both synthetic files before committing.
- Ran the full node --self-test suite and `bash scripts/lint.sh`
  (markdown lint, this guard, prettier --check) locally: all exit 0.
- gitCheckIgnore(): the catch's two arms both returned false, collapsing
  "git says not ignored" (exit 1, routine) and "git is unusable" (any
  other failure) into one silent outcome. Keep the fail-closed behavior
  but warn once on the atypical case so a red build in an environment
  without git points at its actual cause.
- Drop the unreachable styles.*.css bundle pattern and the static/css
  entry in GENERATED_BUNDLE_DIRS: SCANNABLE_EXTENSIONS has no .css entry,
  so walk() never reaches a stylesheet, generated or not. Both were dead
  code that overstated the guard's scope in its own comments.
- Fix the three stale /docs/concepts/* links in
  archetypes/templates/template/index.md directly (projects, stacks
  #outputs, config), rather than leaving them baselined and confusing to
  the next person who runs `hugo new -k template`. Verified the new
  /docs/iac/concepts/... targets resolve 200 with no redirect hop.
- Move the guard's self-exclusion from a directory-wide skip in
  walk()/shouldSkipDir() to a single-file check in runScan(), scoped to
  just this script rather than all of scripts/lint/. This closes the gap
  where `node check-concepts-links.js scripts/lint/check-concepts-links.js`
  disagreed with a full-repo run and reported ~20 violations against the
  script's own fixtures; both entry points now agree.

Baseline regenerated: 12 files (archetypes/templates/template/index.md
dropped out entirely now that its links are fixed rather than baselined).
PR #21138 and PR #21145 (the cleanup pass this guard exists to protect)
both merged today. Every one of the 12 baselined files now has 0 real
occurrences of an internal /docs/concepts/* link on master, so the
57-occurrence allowance list was pure dead weight: a future PR could
have introduced up to that many brand-new stale links per file and the
guard would stay green and silent, which is exactly the regression it
exists to catch.

Rebased onto current master and reset the baseline to {}, updating its
_note and this script's own doc comments to stop describing the two
PRs as still open. Verified against the merged tree: a full scan finds
0 occurrences in all 12 previously-baselined files, and a fresh
--print-baseline confirms an empty {_note, files: {}} envelope is the
correct state.
@workprentice
workprentice Bot force-pushed the workprentice/concepts-links-lint-guard branch from e6bcf7b to 6270eeb Compare August 26, 2026 22:47
@workprentice

workprentice Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed, and thank you for catching this — rebasing onto master was the missing step. #21138 and #21145 both merged before I pushed the previous commit, but this branch was still based on the pre-merge tree, so the guard's own baseline was silently stale.

Rebased onto current master (6270eebbaf) and reset the baseline to {}. Verified with a full scan against the merged tree: all 12 previously-baselined files now show 0 real /docs/concepts/* occurrences, matching your count exactly. Updated the _note and this script's doc comments to stop describing #21138/#21145 as open.

@claude #update-review

@github-actions github-actions Bot added review:in-progress Claude review is currently running and removed review:outstanding-issues Claude review completed; outstanding has author-actionable findings labels Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @workprentice[bot]'s request.

@github-actions github-actions Bot added review:no-blockers Claude review completed cleanly; outstanding is empty and removed review:in-progress Claude review is currently running labels Aug 26, 2026
@workprentice

workprentice Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Restored the dropped caveat sentence in the shipped _note -- good catch, that's exactly the kind of low-confidence finding that matters once someone actually adds an exception later.

@claude #update-review

@github-actions github-actions Bot added review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention review:in-progress Claude review is currently running and removed review:no-blockers Claude review completed cleanly; outstanding is empty review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention labels Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @workprentice[bot]'s request.

@github-actions github-actions Bot added review:no-blockers Claude review completed cleanly; outstanding is empty and removed review:in-progress Claude review is currently running labels Aug 26, 2026
@workprentice

workprentice Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed clean: the latest pre-merge review shows 0 Outstanding, 0 Low-confidence, 0 Pre-existing, 12 Resolved. CI is green. This PR is ready for review/merge whenever you have a moment.

Understood on the standing directive -- I'll drive every finding on future pulumi/docs PRs (including low-confidence and style) to a resolved state before considering a PR done, not just Outstanding.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain:infra PR touches workflows, scripts, infra, Makefile, or build config review:no-blockers Claude review completed cleanly; outstanding is empty

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants