A linter for XSL/XSLT stylesheets. It first checks that every stylesheet is well-formed and every XPath expression compiles, then flags stylistic, semantic, and logical mistakes — each pinpointed to its exact line and column, with a fix where the correction is unambiguous. Think of it as the checks ESLint gives JavaScript, but for XSL.
npx @maxonfjvipon/xslint path/to/stylesheets
It also runs in your editor — VS Code and compatible editors and JetBrains IDEs — in CI via the GitHub Action, or embedded through the language server.
[ERROR] sheet.xsl(2:1) The xsl:output instruction is missing. (not-using-output)
[WARNING] sheet.xsl(3:3) The match attribute starts with //, which scans the whole tree. (starts-with-double-slash)
[WARNING] sheet.xsl(4:5) A variable has a single-character name. (short-names)
Pointed at core stylesheets from the three most widely-used XSLT projects
— DocBook-XSL
(1.0), TEI (2.0), and
DITA-OT (1.0/2.0), 70 files
in all — xslint surfaced 1,974 findings across 22 different checks, with
no false positives from its validators: 106 xsl:choose blocks
with no xsl:otherwise, 67 unused named templates, 40 stylesheet
functions never called, and more. Real stylistic and logical findings in code
that has shipped for decades.
69 checks, each with its rationale:
| Check | Type | Severity | Description |
|---|---|---|---|
| blank-nested-if | xpath | warning | A nested xsl:if inside another xsl:if is prohibited. Combine conditions with 'and' into a single xsl:if. |
| duplicate-param-name | xpath | error | Two xsl:param siblings share a @name. Each parameter of a template, function, or stylesheet must have a distinct name. |
| duplicate-with-param-name | xpath | error | A single call passes two xsl:with-param with the same @name. Each parameter can be supplied only once. |
| empty-choose | xpath | error | 'xsl:choose' has no 'xsl:when' branch. Add one, or drop the 'xsl:choose' and keep its content directly. |
| empty-content-in-instructions | xpath | warning | An instruction element such as xsl:for-each or xsl:if has no content. Add content or remove the empty element. |
| empty-variable | xpath | warning | An empty xsl:variable binds the empty string for no reason. Give it a @select or some content, or remove it. |
| function-complexity | xpath | warning | The function contains more than 50 XSLT elements. Split it into smaller functions. |
| function-template-is-not-child-of-stylesheet | xpath | error | 'xsl:function' or 'xsl:template' are used in the wrong places. Use 'xsl:function' and 'xsl:template' as child nodes of 'xsl:stylesheet'. |
| function-use-in-xslt-1 | xpath | error | 'xsl:function' requires XSLT 2.0 or later, and the version in force here is below that. Use a named template, or raise that version to 2.0. |
| incorrect-use-of-boolean-constants | xpath | warning suggested fix | The test is the string 'true' or 'false', a non-empty string that is always true, not a boolean. Use true() or false() instead. |
| malformed-version-in-stylesheet | xpath | error | A declared version is not a number, so it names no XSLT version and no version gate under it clears its floor, leaving every check about a construct a later version introduced quiet rather than guessing. Declare it as a decimal, such as 2.0 |
| missing-id-in-stylesheet | xpath | warning | The '@id' attribute is missing in the 'xsl:stylesheet' element. Declare it to specify the unique identifier explicitly. |
| missing-or-empty-href | xpath | error | The '@href' is missing or empty here, so this pulls in no other module and no processor will load the stylesheet |
| missing-or-empty-name | xpath | error | The '@name' is missing or empty here, and neither is a QName, so no processor will load this stylesheet |
| missing-version-in-stylesheet | xpath | warning suggested fix | The version attribute is missing on the stylesheet's root element. Declare it to specify the stylesheet version explicitly. |
| mode-or-priority-without-match | xpath | error suggested fix | An xsl:template that has no match attribute must have no mode attribute and no priority attribute. Add match attribute or remove mode or priority. |
| modern-construct-in-xslt-1 | xpath | error | This XSLT 2.0 construct stands where the version in force is below 2.0, which a conformant 1.0 processor rejects. Use a 1.0 equivalent, or raise that version to 2.0. |
| name-starts-with-numeric | xpath | warning | A variable, function, or template name starts with a digit, which is invalid in XPath. Rename it to start with a letter. |
| not-creating-attribute-correctly | xpath | warning | xsl:attribute with a static name on a literal element can be a literal attribute (an AVT for a computed value). Write it inline. |
| not-using-attribute-test-in-if-when-node | xpath | error | 'xsl:if' or 'xsl:when' don't have attribute @test. Add this attribute or delete node. |
| otherwise-not-last | xpath | error | 'xsl:otherwise' must be the last child of its 'xsl:choose'. Move it after every 'xsl:when'. |
| oversized-template | xpath | warning | The template contains more than 100 XSLT elements. Decompose it with apply-templates into focused templates. |
| param-after-content | xpath | error | 'xsl:param' must be declared before any other content in its 'xsl:template' or 'xsl:function'. Move it above the content. |
| setting-value-of-variable-incorrectly | xpath | warning | A variable is assigned via a nested xsl:value-of instead of the select attribute. Use select syntax instead. |
| short-names | xpath | warning | A variable, function, or template has a single-character name. Use a descriptive name that reveals intent. |
| sort-not-first | xpath | error | 'xsl:sort' must come before any other content in its 'xsl:for-each' or 'xsl:apply-templates'. Move it to the front. |
| stylesheet-has-no-templates | xpath | warning | The stylesheet declares nothing at all, so it contributes nothing to a transformation. Add a declaration, or delete the file. |
| template-has-no-name-or-match | xpath | error | An 'xsl:template' must have a '@match' or '@name' attribute, or both. Add one of them. |
| text-outside-xsl-text | xpath | warning suggested fix | Literal text appears directly inside an 'xsl:' instruction element. Wrap it in 'xsl:text'. |
| too-many-templates | xpath | warning | The stylesheet declares 10 or more templates, too many to read in one file. Split it into imported modules. |
| use-choose-without-otherwise | xpath | warning | xsl:choose has no xsl:otherwise branch. Add xsl:otherwise to handle unmatched cases explicitly. |
| use-single-option-for-choose | xpath | warning | xsl:choose contains only one xsl:when branch. Replace it with xsl:if. |
| using-disable-output-escaping | xpath | warning suggested fix | disable-output-escaping='yes' is set, which produces implementation-defined output. Use xsl:copy-of or literal elements instead. |
| using-not-outermost-stylesheet | xpath | error | xsl:stylesheet is nested inside another element. It must be the outermost element of the document. |
| variable-or-param-with-select-and-content | xpath | error suggested fix | An xsl:variable, xsl:param or xsl:with-param has both a select attribute and a non-empty body. Use only one way to set the value. |
| when-or-otherwise-outside-choose | xpath | error | 'xsl:when' and 'xsl:otherwise' are valid only as children of 'xsl:choose'. Move it into an 'xsl:choose', or use 'xsl:if'. |
| with-param-use-in-invalid-parent-node | xpath | error | xsl:with-param is allowed only within xsl:call-template, xsl:apply-templates, xsl:next-match, xsl:next-iteration, or an xsl:apply-imports whose version in force is 2.0 or later. Move it into a call, or raise that version. |
| with-param-without-name | xpath | error | xsl:with-param requires a @name attribute to bind the parameter it passes. Add one. |
| unreachable-function | corpus | warning | A stylesheet function is called only from within a recursion cycle that nothing else enters, so it can never run. Remove it, or call it from a template. |
| unused-function | corpus | warning | A stylesheet function is never called in any expression across the corpus. Remove it or call it. |
| unused-named-template | corpus | warning | A named template is never invoked via xsl:call-template. Remove it or call it. |
| unused-variable | corpus | warning | A variable is declared but never referenced by $name across the corpus. Remove it or use it. |
| invalid-xpath-expression | validation | error | An XPath expression is malformed and cannot be parsed. Fix its syntax. |
| malformed-stylesheet | validation | error | The stylesheet is not well-formed XML and cannot be parsed. Fix its syntax. |
| syntax-newer-than-xslt-version | validation | error | This XPath syntax belongs to a later XSLT version than the one in force here, so a conformant processor of the version in force rejects it. Raise that version, or spell the construct the way it allows. |
| circular-import | format | error | The stylesheet is part of an xsl:import/xsl:include cycle — it pulls in, directly or through a chain, a stylesheet that pulls it back (or imports itself). This is a static error. Break the cycle so no module depends on one that depends on it. |
| confusing-variable-and-node | format | warning suggested fix | A variable name is used bare as a node selector. Use $name syntax to reference the variable. |
| count-compared-to-zero | format | warning safe fix | count(...) is compared with 0 to test existence, which walks the whole sequence. Test existence directly instead (exists()/empty() on XSLT 2.0+, boolean()/not() on 1.0). |
| leaking-result-namespace | format | warning suggested fix | The xsl:stylesheet declares a namespace prefix used only in stylesheet logic, which a literal result element copies into the output. Add the prefix to exclude-result-prefixes. |
| name-compared-to-string | format | warning suggested fix | name() or local-name() is compared with a string, which is prefix-fragile and slower than a node test. Use self::name instead. |
| not-creating-element-correctly | format | warning | xsl:element is used with a static name. Use a literal result element instead. |
| not-using-output | format | warning | The xsl:output instruction is missing. Declare it to specify the serialization format explicitly. |
| output-method-xml | format | warning suggested fix | xsl:output declares method='xml' while the root template builds an HTML document. Change the method to 'html'. |
| predicate-position-literal | format | warning safe fix | A positional predicate is written the long way; [position() = N] is just [N] and [position() = last()] is just [last()]. Shorten it. |
| redundant-boolean-call | format | warning safe fix | A boolean() call stands where nothing but a truth is taken — a test or a use-when, an operand of and/or, the argument of not(), the condition of an if, the body of a satisfies — so the wrapper is redundant. Drop it. |
| redundant-double-negation | format | warning safe fix | A double negation not(not(x)) is redundant; it is just boolean(x), and where nothing but a truth is taken simply x. Replace it. |
| redundant-import | format | warning suggested fix | The stylesheet imports or includes the same module more than once. Keep a single xsl:import/xsl:include of it and remove the other. |
| redundant-namespace-declarations | format | warning safe fix | The xsl:stylesheet declares a namespace prefix that is never used. Remove the redundant xmlns declaration. |
| redundant-whitespace | format | warning safe fix | An XPath expression has redundant whitespace. Remove the extra spaces. |
| select-starts-with-double-slash | format | warning suggested fix | The select attribute starts with //, which scans the whole document from the root. Anchor it with .// or a specific path. |
| starts-with-double-slash | format | warning safe fix suggested fix | A pattern alternative starts with //, which is redundant since every XSLT pattern already matches at any depth, and it lowers the rule's default priority from 0.5 to that of the step alone. Remove the leading // and give the rule an explicit priority if it must keep ranking as it does. |
| string-length-compared-to-zero | format | warning suggested fix | string-length(...) is compared with 0 to test emptiness, which measures the whole string. Compare the value with '' instead. |
| template-writes-nothing | format | warning | This template declares variables and writes nothing to the result tree, so applying it produces nothing |
| translate-for-case | format | warning suggested fix | translate() folds case over the ASCII alphabet; XSLT 2.0 does this with lower-case() or upper-case(), which are shorter and Unicode-aware. |
| unabbreviated-axis | format | warning safe fix | Verbose axis specifiers child::, attribute::, parent::node(), or self::node() are used. Replace them with the node name alone, @, .., or . respectively. |
| unused-function-template-parameter | format | warning | A parameter is declared but never referenced in the function or template body. Remove it or use it. |
| use-double-slash | format | warning | A pattern contains //, which matches at any depth and is broader than intended. Use a specific path. |
| use-node-set-extension | format | warning safe fix | The node-set() extension function is unnecessary in XSLT 2.0 and later. Query the variable directly without it. |
| using-namespace-axis | format | warning | The namespace:: axis is deprecated in XSLT 2.0 and later. Use in-scope-prefixes() and namespace-uri-for-prefix() instead. |