@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.
- package/bin/doc-tools.js +52 -19
- package/bin/mcp-tools/versions.js +1 -1
- package/cli-utils/enterprise-sources.js +6 -6
- package/cli-utils/install-test-dependencies.sh +58 -6
- package/extensions/REFERENCE.adoc +44 -1
- package/extensions/external-link-checker.js +181 -32
- package/extensions/url-to-xref.js +5 -0
- package/extensions/util/scan-content-urls.js +78 -12
- package/extensions/version-fetcher/set-latest-version.js +18 -4
- package/mcp/CLI_INTERFACE.adoc +1 -1
- package/mcp/USER_GUIDE.adoc +4 -4
- package/package.json +1 -1
- package/tools/bundle-openapi.js +28 -10
- package/tools/get-redpanda-version.js +14 -1
- package/tools/lint-strings/diff.js +67 -1
- package/tools/lint-strings/engine.js +6 -0
- package/tools/lint-strings/index.js +32 -2
- package/tools/property-extractor/Makefile +88 -42
- package/tools/property-extractor/README.adoc +1 -1
- package/tools/property-extractor/cloud_config.py +11 -7
- package/tools/rpk-docs/rpk-docs-handler.js +98 -16
|
@@ -2,12 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Scans AsciiDoc content for http(s) URLs that appear in prose, skipping
|
|
5
|
-
* URLs
|
|
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 (....),
|
|
8
|
-
* (
|
|
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
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
return
|
|
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 (
|
|
58
|
-
|
|
59
|
-
else
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/mcp/CLI_INTERFACE.adoc
CHANGED
|
@@ -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/
|
|
46
|
+
REDPANDA_RELEASE_NOTES=https://github.com/redpanda-data/streaming-enterprise/releases/tag/v25.3.1
|
|
47
47
|
----
|
|
48
48
|
|
|
49
49
|
*Flags:*
|
package/mcp/USER_GUIDE.adoc
CHANGED
|
@@ -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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
package/tools/bundle-openapi.js
CHANGED
|
@@ -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/
|
|
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
|
|
652
|
+
console.log('📥 Cloning streaming-enterprise repository...');
|
|
653
653
|
}
|
|
654
654
|
|
|
655
|
-
const {
|
|
655
|
+
const { getGitHubToken } = require('../cli-utils/github-token');
|
|
656
656
|
|
|
657
|
-
|
|
657
|
+
const repositoryUrl = repo || 'https://github.com/redpanda-data/streaming-enterprise.git';
|
|
658
|
+
const usingDefaultRepo = !repo;
|
|
659
|
+
const token = getGitHubToken();
|
|
658
660
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
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/
|
|
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 = '
|
|
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
|
|
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 {
|
|
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 =
|
|
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:')
|