@redpanda-data/docs-extensions-and-macros 5.23.0 → 5.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,12 +2,26 @@
2
2
 
3
3
  /**
4
4
  * Scans AsciiDoc content for http(s) URLs that appear in prose, skipping
5
- * URLs inside code contexts where rewriting or checking them would be wrong:
5
+ * URLs that never reach a rendered page, or that live in a code context where
6
+ * rewriting or checking them would be wrong:
6
7
  *
7
- * - delimited listing (----), literal (....), fenced (```), and passthrough
8
- * (++++) blocks
8
+ * - delimited listing (----), literal (....), passthrough (++++) and comment
9
+ * (////) blocks
10
+ * - fenced blocks (```), including a language info string (```bash, ```yml).
11
+ * Asciidoctor treats the info string as part of the delimiter, so a fence
12
+ * opened as ```bash still closes on a bare ``` line
13
+ *
14
+ * A block closes only on a delimiter of the same length as the one that opened
15
+ * it; a longer run, such as the row of dashes in a rendered table, is content.
16
+ * - line comments (//), which Asciidoctor drops entirely
9
17
  * - inline code spans (`...`)
10
18
  *
19
+ * URLs holding an unresolved attribute reference (https://github.com/{project-github}/...)
20
+ * are returned with hasAttributeReference set, because a caller cannot check
21
+ * or rewrite one: this scanner runs at contentClassified, before Asciidoctor
22
+ * substitutes attributes, so the braces are still literal here even though the
23
+ * published URL is fine.
24
+ *
11
25
  * Each match records enough position information for a caller to splice a
12
26
  * replacement into the original content.
13
27
  *
@@ -26,6 +40,8 @@
26
40
  * example image:d.png[alt,link=https://...]); rewriting one of these to an
27
41
  * xref macro would corrupt the surrounding macro, but the URL is still a
28
42
  * real link target worth checking
43
+ * - hasAttributeReference: true when the URL contains a {name} attribute
44
+ * reference that has not been substituted yet
29
45
  */
30
46
 
31
47
  const URL_RX = /(link:)?(https?:\/\/[^\s\][)"'<>]+)(\[[^\]]*\])?/g
@@ -34,16 +50,62 @@ const URL_RX = /(link:)?(https?:\/\/[^\s\][)"'<>]+)(\[[^\]]*\])?/g
34
50
  // is not part of the URL.
35
51
  const TRAILING_PUNCT_RX = /[.,;:!?*_]+$/
36
52
  const ATTRIBUTE_ENTRY_RX = /^:!?[a-zA-Z0-9_][a-zA-Z0-9_-]*!?:(?:\s|$)/
53
+ // A line comment. Asciidoctor drops these before rendering, so any URL on one
54
+ // is not a link on the published page: generator provenance notes, Doc
55
+ // Detective test steps, and writers' editorial asides all live here.
56
+ const LINE_COMMENT_RX = /^\s*\/\//
57
+ // An attribute reference that substitution has not resolved yet.
58
+ const ATTRIBUTE_REFERENCE_RX = /\{[a-zA-Z0-9_][a-zA-Z0-9_-]*\}/
37
59
  // Cap on how far a wrapped label may run, so an unmatched bracket somewhere in
38
60
  // prose cannot swallow the rest of the document.
39
61
  const MAX_WRAPPED_LABEL_LENGTH = 500
40
62
 
63
+ // A delimiter run, plus the info string a fence may carry. Comment blocks
64
+ // (////) come first in the alternation so a //// line is not mistaken for the
65
+ // start of a line comment.
66
+ // A markdown-style fence is exactly three backticks: Asciidoctor does not
67
+ // recognise four or more, it reads them as a paragraph. The other
68
+ // delimiters take four or more.
69
+ const BLOCK_DELIMITER_RX = /^(\/{4,}|-{4,}|\.{4,}|`{3}(?!`)|\+{4,})(\S*)$/
70
+
71
+ /**
72
+ * Parses a block delimiter line, or returns null when the line is not one.
73
+ *
74
+ * Two details of Asciidoctor's rules matter here, and both were verified
75
+ * against @asciidoctor/core rather than assumed:
76
+ *
77
+ * - A fenced block may carry a language info string (```bash, ```yml) on its
78
+ * opening line. Matching only bare backticks meant every annotated code
79
+ * block was scanned as prose, which is where the config defaults
80
+ * (https://api.openai.com/v1) and the truncated shell examples
81
+ * (.../releases/download/v, cut at its <version> placeholder) in the weekly
82
+ * report came from. No other delimiter takes an info string.
83
+ * - A block closes only on a delimiter of the SAME LENGTH. A longer run is
84
+ * content. Treating any run of four or more as a delimiter inverted the
85
+ * open/closed state for the rest of the file whenever a listing block held
86
+ * a line of dashes, which is how command output renders a table rule:
87
+ *
88
+ * [source,sql]
89
+ * ----
90
+ * SELECT ...
91
+ * ---------------------- <- content, not a close
92
+ * row
93
+ * ---- <- the actual close
94
+ *
95
+ * Everything after such a block was then read with the state flipped, so
96
+ * prose was skipped and code was scanned. That is why the example URL in
97
+ * cloud-docs' regexp_match reference was reported as a broken link.
98
+ */
41
99
  function blockDelimiter (line) {
42
- if (/^-{4,}$/.test(line)) return '-'
43
- if (/^\.{4,}$/.test(line)) return '.'
44
- if (/^`{3,}$/.test(line)) return '`'
45
- if (/^\+{4,}$/.test(line)) return '+'
46
- return null
100
+ const match = BLOCK_DELIMITER_RX.exec(line)
101
+ if (!match) return null
102
+ const [, run, info] = match
103
+ if (info && run[0] !== '`') return null
104
+ return { char: run[0], length: run.length, info }
105
+ }
106
+
107
+ function closesBlock (delimiter, open) {
108
+ return delimiter.char === open.char && delimiter.length === open.length && !delimiter.info
47
109
  }
48
110
 
49
111
  function scanContentUrls (content) {
@@ -54,10 +116,13 @@ function scanContentUrls (content) {
54
116
  for (const rawLine of lines) {
55
117
  const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
56
118
  const delimiter = blockDelimiter(line)
57
- if (delimiter) {
58
- if (openDelimiter === delimiter) openDelimiter = null
59
- else if (!openDelimiter) openDelimiter = delimiter
60
- } else if (!openDelimiter) {
119
+ if (openDelimiter) {
120
+ // Inside a block, only its own matching delimiter gets out. Anything
121
+ // else on this line is content and is never scanned.
122
+ if (delimiter && closesBlock(delimiter, openDelimiter)) openDelimiter = null
123
+ } else if (delimiter) {
124
+ openDelimiter = delimiter
125
+ } else if (!LINE_COMMENT_RX.test(line)) {
61
126
  scanLine(line, offset, matches, content)
62
127
  }
63
128
  offset += rawLine.length + 1
@@ -114,6 +179,7 @@ function scanLine (line, offset, matches, content) {
114
179
  hasLinkPrefix: Boolean(linkPrefix),
115
180
  inAttributeEntry,
116
181
  inAttributeValue: /[\w-]=["']?$/.test(before),
182
+ hasAttributeReference: ATTRIBUTE_REFERENCE_RX.test(url),
117
183
  })
118
184
  }
119
185
  }
@@ -16,7 +16,12 @@ module.exports.register = function ({ config }) {
16
16
  const token = getGitHubToken();
17
17
 
18
18
  if (!token) {
19
- logger.warn('GitHub token not set (REDPANDA_GITHUB_TOKEN, GITHUB_TOKEN, or GH_TOKEN). Attempting unauthenticated request.');
19
+ // Unauthenticated requests still work for the public repos this
20
+ // extension queries (connect, redpanda-operator), but the Redpanda
21
+ // version now comes from the private streaming-enterprise repo, whose
22
+ // API 404s without a token -- the latest-redpanda-* attributes are then
23
+ // left unset and pages fall back to their antora.yml values.
24
+ logger.warn('GitHub token not set (GIT_CREDENTIALS, REDPANDA_GITHUB_TOKEN, GITHUB_TOKEN, or GH_TOKEN). The Redpanda version lookup against the private streaming-enterprise repo will fail and leave latest-redpanda-* attributes unset; other lookups proceed unauthenticated.');
20
25
  }
21
26
 
22
27
  this.on('contentClassified', async ({ contentCatalog }) => {
@@ -41,7 +46,11 @@ module.exports.register = function ({ config }) {
41
46
  latestOperatorResult,
42
47
  latestConnectResult,
43
48
  ] = await Promise.allSettled([
44
- GetLatestRedpandaVersion(github, owner, 'redpanda', logger),
49
+ // streaming-enterprise, not the frozen public 'redpanda' repo: the public
50
+ // repo still answers API calls but stopped receiving releases on
51
+ // 2026-08-20, so resolving from it silently pins the docs to the last
52
+ // pre-freeze version forever.
53
+ GetLatestRedpandaVersion(github, owner, 'streaming-enterprise', logger),
45
54
  GetLatestDockerTag(dockerNamespace, 'console', logger),
46
55
  GetLatestDockerTag(dockerNamespace, 'redpanda-operator', logger),
47
56
  GetLatestConnectVersion(github, owner, 'connect', logger),
@@ -218,10 +227,15 @@ module.exports.register = function ({ config }) {
218
227
  return version.replace(/^v/, '');
219
228
  }
220
229
 
221
- // Helper function to update multiple attributes based on a list of mappings
230
+ // Helper function to update multiple attributes based on a list of mappings.
231
+ // Gated on the value, not just the condition: gating on the enclosing object
232
+ // alone assigned `undefined` whenever the fetch returned an object without
233
+ // the field, and that clobbered the antora.yml fallback instead of falling
234
+ // back to it. The visible symptom was a literal {latest-operator-version} in
235
+ // the published release-notes pages.
222
236
  function updateAttributes(asciidoc, mappings) {
223
237
  mappings.forEach(({ condition, key, value }) => {
224
- if (condition) {
238
+ if (condition && value) {
225
239
  asciidoc.attributes[key] = value;
226
240
  setShortVersionAttribute(asciidoc, key, value);
227
241
  }
@@ -43,7 +43,7 @@ npx doc-tools get-redpanda-version [--beta]
43
43
  ----
44
44
  REDPANDA_VERSION=v25.3.1
45
45
  REDPANDA_DOCKER_TAG=docker.redpanda.com/redpandadata/redpanda:v25.3.1
46
- REDPANDA_RELEASE_NOTES=https://github.com/redpanda-data/redpanda/releases/tag/v25.3.1
46
+ REDPANDA_RELEASE_NOTES=https://github.com/redpanda-data/streaming-enterprise/releases/tag/v25.3.1
47
47
  ----
48
48
 
49
49
  *Flags:*
@@ -571,7 +571,7 @@ NOTE: Stack traces are only included when `NODE_ENV=development` for security re
571
571
  "version": "v25.3.1",
572
572
  "docker_tag": "docker.redpanda.com/redpandadata/redpanda:v25.3.1",
573
573
  "is_beta": false,
574
- "notes_url": "https://github.com/redpanda-data/redpanda/releases/tag/v25.3.1"
574
+ "notes_url": "https://github.com/redpanda-data/streaming-enterprise/releases/tag/v25.3.1"
575
575
  }
576
576
  ----
577
577
 
@@ -592,7 +592,7 @@ notes_url:: GitHub release notes URL
592
592
  "version": "v25.4.1-rc1",
593
593
  "docker_tag": "docker.redpanda.com/redpandadata/redpanda-unstable:v25.4.1-rc1",
594
594
  "is_beta": true,
595
- "notes_url": "https://github.com/redpanda-data/redpanda/releases/tag/v25.4.1-rc1"
595
+ "notes_url": "https://github.com/redpanda-data/streaming-enterprise/releases/tag/v25.4.1-rc1"
596
596
  }
597
597
  ----
598
598
 
@@ -857,7 +857,7 @@ Claude: *uses get_redpanda_version MCP tool*
857
857
  The latest Redpanda version is v25.3.1, released on January 15, 2024.
858
858
 
859
859
  Docker tag: docker.redpanda.com/redpandadata/redpanda:v25.3.1
860
- Release notes: https://github.com/redpanda-data/redpanda/releases/tag/v25.3.1
860
+ Release notes: https://github.com/redpanda-data/streaming-enterprise/releases/tag/v25.3.1
861
861
  ----
862
862
 
863
863
  ----
@@ -969,7 +969,7 @@ Claude: *uses generate_property_docs MCP tool*
969
969
  ❌ Generation failed: Tag v99.9.9 not found in Redpanda repository
970
970
 
971
971
  Suggestion: Check that the version exists in the Redpanda repository. You can find available versions at:
972
- https://github.com/redpanda-data/redpanda/releases
972
+ https://github.com/redpanda-data/streaming-enterprise/releases
973
973
  ----
974
974
 
975
975
  === Example 7: Property documentation updates (with prompt)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.23.0",
3
+ "version": "5.30.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { execSync, spawnSync } = require('child_process');
5
+ const { execSync, execFileSync, spawnSync } = require('child_process');
6
6
  const yaml = require('yaml');
7
7
  const { getMajorMinor } = require('../cli-utils/version');
8
8
 
@@ -585,7 +585,7 @@ function postProcessBundle(filePath, options, quiet = false) {
585
585
  * @param {string} [options.output] - Standalone output file path; when provided, used for the single output file.
586
586
  * @param {string} [options.outAdmin] - Output path for the admin API when integrating with doc-tools mode.
587
587
  * @param {string} [options.outConnect] - Output path for the connect API when integrating with doc-tools mode.
588
- * @param {string} [options.repo] - Repository URL to clone (defaults to https://github.com/redpanda-data/redpanda.git).
588
+ * @param {string} [options.repo] - Repository URL to clone (defaults to https://github.com/redpanda-data/streaming-enterprise.git, a private repo that requires a GitHub token).
589
589
  * @param {string} [options.adminMajor] - Admin API major version string used for metadata (for example, 'v2.0.0').
590
590
  * @param {boolean} [options.useAdminMajorVersion] - When true and processing the admin surface, use `adminMajor` for the bundle info.version.
591
591
  * @param {boolean} [options.quiet=false] - Suppress logging to stdout/stderr when true.
@@ -649,24 +649,42 @@ async function bundleOpenAPI(options) {
649
649
  try {
650
650
  // Clone repository (only once for all surfaces)
651
651
  if (!quiet) {
652
- console.log('📥 Cloning redpanda repository...');
652
+ console.log('📥 Cloning streaming-enterprise repository...');
653
653
  }
654
654
 
655
- const { getAuthenticatedGitHubUrl, hasGitHubToken } = require('../cli-utils/github-token');
655
+ const { getGitHubToken } = require('../cli-utils/github-token');
656
656
 
657
- let repositoryUrl = repo || 'https://github.com/redpanda-data/redpanda.git';
657
+ const repositoryUrl = repo || 'https://github.com/redpanda-data/streaming-enterprise.git';
658
+ const usingDefaultRepo = !repo;
659
+ const token = getGitHubToken();
658
660
 
659
- // Use token if available for better rate limits and reliability
660
- if (hasGitHubToken() && repositoryUrl.includes('github.com')) {
661
- repositoryUrl = getAuthenticatedGitHubUrl(repositoryUrl);
661
+ if (usingDefaultRepo && !token) {
662
+ throw new Error('redpanda-data/streaming-enterprise is a private repository: set GH_TOKEN, GITHUB_TOKEN, or REDPANDA_GITHUB_TOKEN so the clone can authenticate.');
663
+ }
664
+
665
+ // Auth travels via a credential helper registered through GIT_CONFIG_*
666
+ // environment variables, never as a git -c argument, so the token
667
+ // cannot appear in this process's argv (visible to anything that can
668
+ // list processes) or get embedded in an exec error's command-line
669
+ // message. It's also never embedded in the remote URL or persisted in
670
+ // the clone's .git/config.
671
+ const gitEnv = { ...process.env };
672
+ if (token && repositoryUrl.includes('github.com')) {
662
673
  if (!quiet) {
663
674
  console.log('🔑 Using authenticated clone (token provided)');
664
675
  }
676
+ gitEnv.BUNDLE_OPENAPI_CLONE_TOKEN = token;
677
+ gitEnv.GIT_CONFIG_COUNT = '2';
678
+ gitEnv.GIT_CONFIG_KEY_0 = 'credential.helper';
679
+ gitEnv.GIT_CONFIG_VALUE_0 = '';
680
+ gitEnv.GIT_CONFIG_KEY_1 = 'credential.https://github.com.helper';
681
+ gitEnv.GIT_CONFIG_VALUE_1 = '!f() { echo "username=x-access-token"; echo "password=$BUNDLE_OPENAPI_CLONE_TOKEN"; }; f';
665
682
  }
666
683
 
667
684
  try {
668
- execSync(`git clone --depth 1 --branch ${tag} ${repositoryUrl} redpanda`, {
685
+ execFileSync('git', ['clone', '--depth', '1', '--branch', tag, repositoryUrl, 'redpanda'], {
669
686
  cwd: tempDir,
687
+ env: gitEnv,
670
688
  stdio: quiet ? 'ignore' : 'inherit',
671
689
  timeout: 60000 // 1 minute timeout
672
690
  });
@@ -825,7 +843,7 @@ if (require.main === module) {
825
843
  .option('-o, --output <path>', 'Output file path (defaults: admin/redpanda-admin-api.yaml or connect/redpanda-connect-api.yaml)')
826
844
  .option('--out-admin <path>', 'Output path for admin API', 'admin/redpanda-admin-api.yaml')
827
845
  .option('--out-connect <path>', 'Output path for connect API', 'connect/redpanda-connect-api.yaml')
828
- .option('--repo <url>', 'Repository URL', 'https://github.com/redpanda-data/redpanda.git')
846
+ .option('--repo <url>', 'Repository URL. The default is a private repo, so requires a GitHub token (GH_TOKEN, GITHUB_TOKEN, or REDPANDA_GITHUB_TOKEN)', 'https://github.com/redpanda-data/streaming-enterprise.git')
829
847
  .option('--admin-major <string>', 'Admin API major version', 'v2.0.0')
830
848
  .option('--use-admin-major-version', 'Use admin major version for info.version instead of git tag', false)
831
849
  .option('-q, --quiet', 'Suppress output', false)
@@ -2,6 +2,7 @@
2
2
 
3
3
  const GetLatestRedpandaVersion = require('../extensions/version-fetcher/get-latest-redpanda-version.js');
4
4
  const { getPrereleaseFromAntora } = require('../cli-utils/antora-utils.js');
5
+ const { getGitHubToken } = require('../cli-utils/github-token');
5
6
 
6
7
  /**
7
8
  * Fetches and prints the latest Redpanda version and Docker repository.
@@ -10,8 +11,20 @@ const { getPrereleaseFromAntora } = require('../cli-utils/antora-utils.js');
10
11
  * @param {boolean} options.fromAntora - Whether to derive beta flag from antora.yml
11
12
  */
12
13
  module.exports = async function getRedpandaVersion({ beta = false, fromAntora = false }) {
14
+ // streaming-enterprise, not the old public redpanda repo: the public repo
15
+ // still answers API requests but froze at its 2026-08-20 state when the
16
+ // source moved, so resolving "latest" from it silently pins to the last
17
+ // pre-freeze release forever. streaming-enterprise is private, and
18
+ // unauthenticated release listings 404 rather than fail loudly, so refuse
19
+ // to guess without a token instead of quietly resolving nothing.
13
20
  const owner = 'redpanda-data';
14
- const repo = 'redpanda';
21
+ const repo = 'streaming-enterprise';
22
+
23
+ if (!getGitHubToken()) {
24
+ console.error(`❌ redpanda-data/${repo} is a private repository.`);
25
+ console.error(' Set GH_TOKEN, REDPANDA_GITHUB_TOKEN, or GITHUB_TOKEN to a token with access.');
26
+ process.exit(1);
27
+ }
15
28
 
16
29
  // Determine whether to treat this as a beta (RC) release
17
30
  let useBeta = beta;
@@ -50,6 +50,13 @@ function routeFile (file) {
50
50
  * @returns {Map<string, Set<number>>}
51
51
  */
52
52
  function getChangedLines (repo, base) {
53
+ return parseUnifiedDiff(runGitDiff(repo, base))
54
+ }
55
+
56
+ /**
57
+ * Run `git diff <base>...HEAD` and return the raw text.
58
+ */
59
+ function runGitDiff (repo, base) {
53
60
  const result = spawnSync(
54
61
  'git',
55
62
  ['diff', '--unified=0', '--no-color', `${base}...HEAD`],
@@ -59,7 +66,17 @@ function getChangedLines (repo, base) {
59
66
  if (result.status !== 0) {
60
67
  throw new Error(`git diff ${base}...HEAD failed in ${repo}: ${result.stderr || result.stdout}`)
61
68
  }
62
- return parseUnifiedDiff(result.stdout)
69
+ return result.stdout
70
+ }
71
+
72
+ /**
73
+ * Both sides of the diff from a single git invocation.
74
+ *
75
+ * @returns {{ changed: Map<string, Set<number>>, removed: Map<string, Set<number>> }}
76
+ */
77
+ function getDiffLines (repo, base) {
78
+ const diffText = runGitDiff(repo, base)
79
+ return { changed: parseUnifiedDiff(diffText), removed: parseUnifiedDiffRemovals(diffText) }
63
80
  }
64
81
 
65
82
  /**
@@ -98,6 +115,53 @@ function parseUnifiedDiff (diffText) {
98
115
  return changed
99
116
  }
100
117
 
118
+ /**
119
+ * Parse unified diff text into file -> Set(deleted PRE-image line numbers),
120
+ * keyed by the old path. This is the half parseUnifiedDiff deliberately throws
121
+ * away, and on its own it is what keeps a deletion-only PR visible.
122
+ *
123
+ * Why it is needed: declarations are extracted from HEAD, so a removed
124
+ * property, metric or command cannot be extracted at all, and a pure-deletion
125
+ * hunk contributes no post-image lines to anchor on. Without the old side, a
126
+ * PR that only deletes doc-string declarations reports zero declarations and
127
+ * every downstream review step skips, precisely when a removed public surface
128
+ * is the most doc-relevant change a PR can make.
129
+ *
130
+ * These line numbers are a signal that a doc-string surface lost content, not
131
+ * an extraction: naming them as declarations would overstate what a diff alone
132
+ * can tell us.
133
+ */
134
+ function parseUnifiedDiffRemovals (diffText) {
135
+ const removed = new Map()
136
+ const lines = diffText.split('\n')
137
+ let oldFile = null
138
+
139
+ for (let i = 0; i < lines.length; i++) {
140
+ const line = lines[i]
141
+ // git always emits the ---/+++ header pair on consecutive lines, so
142
+ // requiring the partner keeps a deleted source line that happens to start
143
+ // with "-- " from being misread as a header (the mirror of the "++ "
144
+ // hazard noted in parseUnifiedDiff).
145
+ if (line.startsWith('--- ') && (lines[i + 1] || '').startsWith('+++ ')) {
146
+ oldFile = line.startsWith('--- a/') ? line.slice(6).trim() : null
147
+ continue
148
+ }
149
+ if (!line.startsWith('@@') || oldFile === null) continue
150
+
151
+ const hunk = line.match(/^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/)
152
+ if (!hunk) continue
153
+ const start = parseInt(hunk[1], 10)
154
+ const count = hunk[2] === undefined ? 1 : parseInt(hunk[2], 10)
155
+ if (count === 0) continue // pure addition: nothing removed
156
+
157
+ if (!removed.has(oldFile)) removed.set(oldFile, new Set())
158
+ const lineSet = removed.get(oldFile)
159
+ for (let i2 = 0; i2 < count; i2++) lineSet.add(start + i2)
160
+ }
161
+
162
+ return removed
163
+ }
164
+
101
165
  /**
102
166
  * Group changed files by surface.
103
167
  *
@@ -131,7 +195,9 @@ module.exports = {
131
195
  SURFACE_ROUTES,
132
196
  routeFile,
133
197
  getChangedLines,
198
+ getDiffLines,
134
199
  parseUnifiedDiff,
200
+ parseUnifiedDiffRemovals,
135
201
  classifyDiff,
136
202
  spanIntersects
137
203
  }
@@ -99,6 +99,12 @@ function emptySummary () {
99
99
  return {
100
100
  totalDeclarations: 0,
101
101
  flaggedDeclarations: 0,
102
+ // Deletions on the old side of the diff, in files that route to a
103
+ // doc-string surface. Only populated in diff mode. See
104
+ // parseUnifiedDiffRemovals in ./diff.js for why a removed declaration
105
+ // cannot be counted in totalDeclarations.
106
+ removedSurfaceLines: 0,
107
+ removedSurfaceFiles: [],
102
108
  errors: 0,
103
109
  warnings: 0,
104
110
  info: 0,
@@ -4,7 +4,7 @@ const fs = require('fs')
4
4
  const path = require('path')
5
5
 
6
6
  const { runRules, mergeResults } = require('./engine')
7
- const { getChangedLines, classifyDiff, spanIntersects, SURFACE_ROUTES } = require('./diff')
7
+ const { getDiffLines, classifyDiff, spanIntersects, SURFACE_ROUTES } = require('./diff')
8
8
  const { COMMON_RULES } = require('./rules/common')
9
9
  const { VERBATIM_ASCIIDOC_RULES } = require('./rules/verbatim-asciidoc')
10
10
 
@@ -82,10 +82,12 @@ function lintStrings (options) {
82
82
 
83
83
  const results = []
84
84
  const unsupportedSurfaces = []
85
+ let removedSurfaceFiles = []
85
86
 
86
87
  if (diffBase) {
87
- const changed = getChangedLines(repoPath, diffBase)
88
+ const { changed, removed } = getDiffLines(repoPath, diffBase)
88
89
  const classified = classifyDiff(changed)
90
+ removedSurfaceFiles = collectRemovals(removed, surfaces)
89
91
 
90
92
  for (const [surfaceName, files] of Object.entries(classified)) {
91
93
  if (!SURFACES[surfaceName]) {
@@ -119,9 +121,33 @@ function lintStrings (options) {
119
121
  merged.findings.sort((a, b) =>
120
122
  a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file) || (a.line_start || 0) - (b.line_start || 0))
121
123
  merged.unsupported_surfaces = unsupportedSurfaces
124
+ merged.summary.removedSurfaceFiles = removedSurfaceFiles
125
+ merged.summary.removedSurfaceLines = removedSurfaceFiles.reduce((sum, entry) => sum + entry.lines, 0)
122
126
  return merged
123
127
  }
124
128
 
129
+ /**
130
+ * Deletions in files that route to a doc-string surface, so a deletion-only PR
131
+ * still registers as touching a surface. Reported separately from
132
+ * totalDeclarations because a removed declaration is absent from HEAD and
133
+ * therefore cannot be extracted or linted.
134
+ *
135
+ * Surfaces routed without a registered extractor are included: the gate cares
136
+ * that documented content went away, not whether we can lint what replaced it.
137
+ *
138
+ * @param {Map<string, Set<number>>} removed - From getDiffLines().removed
139
+ * @param {string[]|null} surfaces - Explicit --surface narrowing, if any
140
+ */
141
+ function collectRemovals (removed, surfaces) {
142
+ const only = surfaces && surfaces.length > 0 ? new Set(surfaces) : null
143
+ const entries = []
144
+ for (const [surface, files] of Object.entries(classifyDiff(removed))) {
145
+ if (only && !only.has(surface)) continue
146
+ for (const [file, lines] of files) entries.push({ surface, file, lines: lines.size })
147
+ }
148
+ return entries.sort((a, b) => a.surface.localeCompare(b.surface) || a.file.localeCompare(b.file))
149
+ }
150
+
125
151
  /**
126
152
  * Human-readable report (the default --format).
127
153
  */
@@ -148,6 +174,10 @@ function formatHuman (result) {
148
174
  lines.push('='.repeat(60))
149
175
  lines.push(`Declarations checked: ${summary.totalDeclarations}`)
150
176
  lines.push(`Declarations flagged: ${summary.flaggedDeclarations}`)
177
+ if (summary.removedSurfaceLines) {
178
+ lines.push(`Lines deleted from doc-string surfaces: ${summary.removedSurfaceLines} ` +
179
+ `(${summary.removedSurfaceFiles.length} file(s); removed declarations cannot be extracted from HEAD)`)
180
+ }
151
181
  lines.push(`Errors: ${summary.errors} Warnings: ${summary.warnings} Info: ${summary.info}`)
152
182
  if (Object.keys(summary.byRule).length > 0) {
153
183
  lines.push('\nBy rule:')