Skip to content

Commit 6d09296

Browse files
committed
bug(#334): make use-node-set-extension fixable
Replace the declarative use-node-set-extension rule with a code-based linter (src/node-set-linter.js) that reports one defect per node-set() call in a @select of an XSLT 2.0/3.0 stylesheet, with a fix that unwraps it (exsl:node-set(\$x) becomes \$x). Because the lexer splits node-set at its hyphen, the linter masks string and comment spans and then finds the call and balances its parentheses on the blanked text, so a node-set( inside a literal is never touched and a nested argument is matched correctly. The check moves from the xpath category to format.
1 parent ea48864 commit 6d09296

16 files changed

Lines changed: 263 additions & 15 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ publication date only; detailed notes begin with the Unreleased section.
99

1010
## Unreleased
1111

12+
- Make `use-node-set-extension` fixable by `--fix`: it is now a code-based
13+
check (`src/node-set-linter.js`) that reports one defect per `node-set()`
14+
call in a `@select` of an XSLT 2.0/3.0 stylesheet, with a fix that unwraps it
15+
(`exsl:node-set($x)``$x`). It masks string and comment spans before
16+
matching, so a `node-set(` inside a literal is never flagged (#334).
17+
1218
- Make `redundant-namespace-declarations` fixable by `--fix`: it is now a
1319
code-based check (`src/namespace-linter.js`) that reports one defect per
1420
namespace prefix declared on the stylesheet but used nowhere, positioned at

CLAUDE.md

Lines changed: 10 additions & 5 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,14 +225,16 @@ place:
225225
xslint --fix path/to/dir
226226
```
227227

228-
Today this covers three checks:
228+
Today this covers four checks:
229229

230230
- `redundant-whitespace` — a doubled space is collapsed to one, and a space
231231
leading or trailing an XPath expression is removed.
232232
- `unabbreviated-axis` — a verbose axis specifier is shortened: `child::x`
233233
becomes `x`, `attribute::x` becomes `@x`, and `parent::node()` becomes `..`.
234234
- `redundant-namespace-declarations` — a namespace prefix declared on the
235235
stylesheet but never used is deleted.
236+
- `use-node-set-extension` — the redundant `node-set()` extension is unwrapped
237+
in XSLT 2.0 and later: `exsl:node-set($x)` becomes `$x`.
236238

237239
Only the exact span that was flagged is rewritten — the rest of the file is
238240
left byte-for-byte intact — and a fix is skipped rather than applied when the

src/node-set-linter.js

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2025-2026 Max Trunnikov
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
6+
const {nodes} = require('./xpath')
7+
const {tokenized, TOKENS} = require('./tokens')
8+
const {yaml} = require('./helpers')
9+
const path = require('path')
10+
const {logger} = require('./logger')
11+
12+
/**
13+
* Name of the check this linter owns.
14+
* @type {string}
15+
*/
16+
const CHECK = 'use-node-set-extension'
17+
18+
/**
19+
* Defect metadata of the check.
20+
* @type {{severity: string, message: string}}
21+
*/
22+
const META = yaml.parsedFromFile(
23+
path.join(__dirname, 'resources', 'checks', 'format', `${CHECK}.yaml`),
24+
)
25+
26+
/**
27+
* Names of the checks this linter owns.
28+
* @type {Array.<string>}
29+
*/
30+
const names = [CHECK]
31+
32+
/**
33+
* Stylesheet versions where the node-set() extension is redundant.
34+
* @type {Array.<string>}
35+
*/
36+
const MODERN = ['2.0', '3.0']
37+
38+
/**
39+
* Pattern of a `prefix:node-set(` call opener.
40+
* @type {RegExp}
41+
*/
42+
const CALL = /[\w.-]+:node-set\s*\(/g
43+
44+
/**
45+
* A select value with its string and comment spans blanked to spaces, so a
46+
* `node-set(` call can be found and its parentheses balanced without tripping
47+
* over text inside a literal. Blanking keeps every offset intact.
48+
* @param {string} select - The `select` attribute value
49+
* @return {string} - The value with literals blanked
50+
*/
51+
const masked = function(select) {
52+
const chars = Array.from(select)
53+
for (const token of tokenized(select)) {
54+
if (token.type === TOKENS.STRING || token.type === TOKENS.COMMENT) {
55+
for (let at = token.start; at < token.start + token.value.length; at++) {
56+
chars[at] = ' '
57+
}
58+
}
59+
}
60+
return chars.join('')
61+
}
62+
63+
/**
64+
* Offset of the `)` that closes the `(` at `open` in a literal-free expression,
65+
* or -1 when it is unbalanced.
66+
* @param {string} expression - Expression with literals already blanked
67+
* @param {number} open - Offset of the opening `(`
68+
* @return {number} - Offset of the matching `)`, or -1
69+
*/
70+
const closes = function(expression, open) {
71+
let depth = 0
72+
for (let at = open; at < expression.length; at++) {
73+
if (expression[at] === '(') {
74+
depth++
75+
} else if (expression[at] === ')') {
76+
depth--
77+
if (depth === 0) {
78+
return at
79+
}
80+
}
81+
}
82+
return -1
83+
}
84+
85+
/**
86+
* The node-set() wrappers in a select value: each carries the offset it starts
87+
* at, its verbatim text, and the inner argument that replaces it. A call whose
88+
* parentheses do not balance is skipped.
89+
* @param {string} select - The `select` attribute value
90+
* @return {Array.<{offset: number, value: string, replacement: string}>} -
91+
* The node-set() calls found
92+
*/
93+
const wrappers = function(select) {
94+
const found = []
95+
const blanked = masked(select)
96+
for (const match of blanked.matchAll(CALL)) {
97+
const open = match.index + match[0].length - 1
98+
const close = closes(blanked, open)
99+
if (close >= 0) {
100+
found.push({
101+
offset: match.index,
102+
value: select.slice(match.index, close + 1),
103+
replacement: select.slice(open + 1, close),
104+
})
105+
}
106+
}
107+
return found
108+
}
109+
110+
/**
111+
* Lint the corpus for the `node-set()` extension used in XSLT 2.0 or 3.0, where
112+
* a variable is already a node sequence, reporting one defect per call with the
113+
* fix that unwraps it.
114+
* @param {Array.<{file: string, xsl: Document}>} corpus - Parsed stylesheets
115+
* @param {Array.<string>} suppressions - Array of suppressed checks
116+
* @return {{name: string, severity: string, message: string, file: string,
117+
* line: number, pos: number, fix: object}[]} - Defects found
118+
*/
119+
const lintByNodeSet = function(corpus, suppressions = []) {
120+
logger.debug(`Node-set linting started`)
121+
const defects = []
122+
if (!suppressions.some((sup) => CHECK.includes(sup))) {
123+
for (const {file, xsl} of corpus) {
124+
if (MODERN.includes(xsl.documentElement.getAttribute('version'))) {
125+
for (const attribute of nodes(xsl, '//@select')) {
126+
for (const {offset, value, replacement} of wrappers(
127+
attribute.nodeValue,
128+
)) {
129+
const pos = attribute.columnNumber + 1 + offset
130+
defects.push({
131+
name: CHECK,
132+
severity: META.severity,
133+
message: META.message,
134+
file: file,
135+
line: attribute.lineNumber,
136+
pos: pos,
137+
fix: {
138+
line: attribute.lineNumber,
139+
col: pos,
140+
value: value,
141+
replacement: replacement,
142+
},
143+
})
144+
}
145+
}
146+
}
147+
}
148+
}
149+
logger.debug(`Found ${defects.length} node-set extension defects`)
150+
return defects
151+
}
152+
153+
module.exports = {
154+
lintByNodeSet,
155+
names,
156+
}

src/resources/checks/xpath/use-node-set-extension.yaml renamed to src/resources/checks/format/use-node-set-extension.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 Max Trunnikov
22
# SPDX-License-Identifier: MIT
33
---
4-
xpath: /xsl:stylesheet[@version = ('2.0', '3.0')]//*[contains(@select, ":node-set")]
54
severity: warning
65
message: The node-set() extension function is unnecessary in XSLT 2.0 and later. Query the variable directly without it.

src/resources/motives/xpath/use-node-set-extension.md renamed to src/resources/motives/format/use-node-set-extension.md

File renamed without changes.

src/xslint.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {lintByXpath, names: xpathChecks} = require('./xpath-linter')
1414
const {lintByCorpus, names: corpusChecks} = require('./corpus-linter')
1515
const {lintByAxis, names: axisChecks} = require('./xpath-axis-linter')
1616
const {lintByNamespace, names: namespaceChecks} = require('./namespace-linter')
17+
const {lintByNodeSet, names: nodeSetChecks} = require('./node-set-linter')
1718
const {lintByFormat, names: formatChecks} = require('./xpath-format-linter')
1819
const {fixed} = require('./fixer')
1920
const {logger, levels} = require('./logger')
@@ -32,6 +33,7 @@ const LINTERS = [
3233
lintByCorpus,
3334
lintByAxis,
3435
lintByNamespace,
36+
lintByNodeSet,
3537
]
3638

3739
/**
@@ -52,7 +54,7 @@ const EXPRESSION_LINTERS = [
5254
const CHECKS = [
5355
...xslChecks, ...xpathValidatorChecks,
5456
...xpathChecks, ...corpusChecks, ...axisChecks, ...namespaceChecks,
55-
...formatChecks,
57+
...nodeSetChecks, ...formatChecks,
5658
]
5759

5860
/**

test/fixer.test.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ describe('fixer', function() {
8080
fixture('redundant-namespace-declarations.fixed.xsl'),
8181
)
8282
})
83+
it('should unwrap the node-set extension with --fix', function() {
84+
const file = scratch(fixture('use-node-set-extension.xsl'))
85+
runXslint(['--fix', file])
86+
assert.equal(
87+
fs.readFileSync(file, 'utf-8'),
88+
fixture('use-node-set-extension.fixed.xsl'),
89+
)
90+
})
8391
it('should collapse a run whose span it can verify', function() {
8492
assert.equal(
8593
fixed(

test/node-set-linter.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* SPDX-FileCopyrightText: Copyright (c) 2025-2026 Max Trunnikov
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
6+
const {lintByNodeSet} = require('../src/node-set-linter')
7+
const {allFilesFrom, xml, yaml} = require('../src/helpers')
8+
const path = require('path')
9+
const assert = require('assert')
10+
11+
/**
12+
* Yaml node-set linter test packs.
13+
* @type {Array<string>}
14+
*/
15+
const PACKS = allFilesFrom(
16+
path.resolve(__dirname, 'resources', 'node-set-packs'),
17+
)
18+
19+
describe('node-set-linter', function() {
20+
PACKS.forEach((pack) => {
21+
const yml = yaml.parsedFromFile(pack)
22+
const input = xml.parsedFromString(yml.input)
23+
describe(`testing ${path.basename(pack)} pack`, function() {
24+
it(`should find ${yml.found.amount} node-set extensions`, function() {
25+
const defects = lintByNodeSet([{file: 'test.xsl', xsl: input}])
26+
assert.equal(defects.length, yml.found.amount)
27+
yml.found.positions.forEach((pos, index) => {
28+
assert.equal(defects[index].line, pos[0])
29+
assert.equal(defects[index].pos, pos[1])
30+
})
31+
})
32+
})
33+
})
34+
})
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!--
3+
* SPDX-FileCopyrightText: Copyright (c) 2025-2026 Max Trunnikov
4+
* SPDX-License-Identifier: MIT
5+
-->
6+
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="2.0">
7+
<xsl:template match="/">
8+
<xsl:value-of select="$x/title"/>
9+
</xsl:template>
10+
</xsl:stylesheet>

0 commit comments

Comments
 (0)