22 * CycloneDX Schema Linter - Formatting and Indentation Check
33 *
44 * Validates that JSON schemas follow consistent formatting rules:
5- * - Correct indentation based on nesting level
5+ * - Correct indentation based on nesting level (2 spaces per level)
6+ * - No tabs
67 * - No trailing whitespace
78 * - Consistent line endings
89 * - Final newline
@@ -31,7 +32,7 @@ class FormattingIndentCheck extends LintCheck {
3132 const spaces = config . spaces ?? 2 ;
3233 const requireFinalNewline = config . requireFinalNewline ?? true ;
3334 const allowTrailingWhitespace = config . allowTrailingWhitespace ?? false ;
34- const lineEnding = config . lineEnding ?? 'lf' ; // lf or crlf
35+ const lineEnding = config . lineEnding ?? 'lf' ;
3536
3637 const lines = rawContent . split ( / \r ? \n / ) ;
3738
@@ -62,127 +63,156 @@ class FormattingIndentCheck extends LintCheck {
6263 ) ) ;
6364 }
6465
65- // Check trailing whitespace
66- if ( ! allowTrailingWhitespace ) {
67- lines . forEach ( ( line , index ) => {
68- if ( / [ \t ] + $ / . test ( line ) ) {
69- issues . push ( this . createIssue (
70- `Line ${ index + 1 } has trailing whitespace.` ,
71- '$' ,
72- { line : index + 1 } ,
73- Severity . WARNING
74- ) ) ;
75- }
76- } ) ;
77- }
66+ // Track nesting depth
67+ let depth = 0 ;
7868
79- // Check for tabs
80- lines . forEach ( ( line , index ) => {
81- if ( / ^ \t / . test ( line ) ) {
69+ for ( let i = 0 ; i < lines . length ; i ++ ) {
70+ const line = lines [ i ] ;
71+ const lineNum = i + 1 ;
72+ const trimmed = line . trim ( ) ;
73+
74+ // Skip empty lines
75+ if ( trimmed === '' ) continue ;
76+
77+ // Check trailing whitespace
78+ if ( ! allowTrailingWhitespace && / [ \t ] + $ / . test ( line ) ) {
8279 issues . push ( this . createIssue (
83- `Line ${ index + 1 } uses tabs for indentation. Use ${ spaces } spaces instead .` ,
80+ `Line ${ lineNum } has trailing whitespace .` ,
8481 '$' ,
85- { line : index + 1 }
82+ { line : lineNum } ,
83+ Severity . WARNING
8684 ) ) ;
8785 }
88- } ) ;
89-
90- // Generate canonical formatting and compare line by line
91- try {
92- const canonical = JSON . stringify ( schema , null , spaces ) ;
93- const canonicalLines = canonical . split ( '\n' ) ;
9486
95- // Normalise input lines (remove \r)
96- const normalisedLines = rawContent . replace ( / \r \n / g, '\n' ) . split ( '\n' ) ;
87+ // Check for tabs in indentation
88+ if ( / ^ \t + / . test ( line ) ) {
89+ issues . push ( this . createIssue (
90+ `Line ${ lineNum } uses tabs for indentation. Use ${ spaces } spaces instead.` ,
91+ '$' ,
92+ { line : lineNum }
93+ ) ) ;
94+ // Still update depth tracking
95+ depth += this . getDepthChange ( trimmed ) ;
96+ continue ;
97+ }
98+
99+ // Count openers/closers outside of strings for this line
100+ const { openersBeforeContent, closersAtStart, netChange } = this . analyzeLineStructure ( trimmed ) ;
97101
98- // Remove trailing empty line from normalised if it exists (final newline)
99- if ( normalisedLines [ normalisedLines . length - 1 ] === '' ) {
100- normalisedLines . pop ( ) ;
102+ // If line starts with closer, decrease depth for this line
103+ if ( closersAtStart > 0 ) {
104+ depth = Math . max ( 0 , depth - closersAtStart ) ;
101105 }
102106
103- // Compare line by line
104- const maxLines = Math . max ( canonicalLines . length , normalisedLines . length ) ;
107+ // Validate indentation
108+ const actualIndent = this . getLeadingSpaces ( line ) ;
109+ const expectedIndent = depth * spaces ;
105110
106- for ( let i = 0 ; i < maxLines ; i ++ ) {
107- const expected = canonicalLines [ i ] ;
108- const actual = normalisedLines [ i ] ;
109-
110- // Handle missing lines
111- if ( expected === undefined ) {
112- issues . push ( this . createIssue (
113- `Line ${ i + 1 } is unexpected. File has more lines than expected.` ,
114- '$' ,
115- { line : i + 1 , actual : this . truncate ( actual , 50 ) }
116- ) ) ;
117- continue ;
118- }
119-
120- if ( actual === undefined ) {
121- issues . push ( this . createIssue (
122- `Line ${ i + 1 } is missing. Expected: "${ this . truncate ( expected , 50 ) } "` ,
123- '$' ,
124- { line : i + 1 , expected : this . truncate ( expected , 50 ) }
125- ) ) ;
126- continue ;
127- }
128-
129- // Compare indentation
130- const expectedIndent = this . getLeadingSpaces ( expected ) ;
131- const actualIndent = this . getLeadingSpaces ( actual ) ;
132-
133- if ( expectedIndent !== actualIndent ) {
134- issues . push ( this . createIssue (
135- `Line ${ i + 1 } has incorrect indentation. Expected ${ expectedIndent } spaces, found ${ actualIndent } .` ,
136- '$' ,
137- {
138- line : i + 1 ,
139- expectedIndent,
140- actualIndent,
141- content : this . truncate ( actual . trim ( ) , 40 )
142- }
143- ) ) ;
144- continue ;
145- }
146-
147- // Compare content (after trimming to focus on structure)
148- const expectedTrimmed = expected . trim ( ) ;
149- const actualTrimmed = actual . trim ( ) ;
150-
151- if ( expectedTrimmed !== actualTrimmed ) {
152- // Check if it's a key ordering difference
153- if ( this . isKeyOrderDifference ( expectedTrimmed , actualTrimmed ) ) {
154- issues . push ( this . createIssue (
155- `Line ${ i + 1 } has different key ordering than canonical format.` ,
156- '$' ,
157- {
158- line : i + 1 ,
159- expected : this . truncate ( expectedTrimmed , 50 ) ,
160- actual : this . truncate ( actualTrimmed , 50 )
161- } ,
162- Severity . INFO
163- ) ) ;
164- } else {
165- issues . push ( this . createIssue (
166- `Line ${ i + 1 } content differs from canonical format.` ,
167- '$' ,
168- {
169- line : i + 1 ,
170- expected : this . truncate ( expectedTrimmed , 50 ) ,
171- actual : this . truncate ( actualTrimmed , 50 )
172- } ,
173- Severity . WARNING
174- ) ) ;
111+ if ( actualIndent !== expectedIndent ) {
112+ issues . push ( this . createIssue (
113+ `Line ${ lineNum } has incorrect indentation. Expected ${ expectedIndent } spaces, found ${ actualIndent } .` ,
114+ '$' ,
115+ {
116+ line : lineNum ,
117+ expectedIndent,
118+ actualIndent,
119+ content : this . truncate ( trimmed , 40 )
175120 }
176- }
121+ ) ) ;
177122 }
178123
179- } catch ( err ) {
180- // JSON parsing errors are handled elsewhere
124+ // Update depth for next line based on net openers (excluding leading closers already handled)
125+ depth += netChange + closersAtStart ; // Add back closersAtStart since netChange includes them
126+ depth = Math . max ( 0 , depth ) ;
181127 }
182128
183129 return issues ;
184130 }
185131
132+ /**
133+ * Analyze line structure for bracket counting
134+ * Returns closers at start, and net depth change
135+ */
136+ analyzeLineStructure ( trimmed ) {
137+ let closersAtStart = 0 ;
138+ let inString = false ;
139+ let escapeNext = false ;
140+ let openers = 0 ;
141+ let closers = 0 ;
142+ let foundNonBracket = false ;
143+
144+ for ( let i = 0 ; i < trimmed . length ; i ++ ) {
145+ const char = trimmed [ i ] ;
146+
147+ if ( escapeNext ) {
148+ escapeNext = false ;
149+ continue ;
150+ }
151+
152+ if ( char === '\\' && inString ) {
153+ escapeNext = true ;
154+ continue ;
155+ }
156+
157+ if ( char === '"' ) {
158+ inString = ! inString ;
159+ foundNonBracket = true ;
160+ continue ;
161+ }
162+
163+ if ( inString ) continue ;
164+
165+ if ( char === '{' || char === '[' ) {
166+ openers ++ ;
167+ foundNonBracket = true ;
168+ } else if ( char === '}' || char === ']' ) {
169+ closers ++ ;
170+ // Count closers at start (before any non-bracket content)
171+ if ( ! foundNonBracket ) {
172+ closersAtStart ++ ;
173+ }
174+ } else if ( ! / \s / . test ( char ) ) {
175+ foundNonBracket = true ;
176+ }
177+ }
178+
179+ return {
180+ openersBeforeContent : openers ,
181+ closersAtStart,
182+ netChange : openers - closers
183+ } ;
184+ }
185+
186+ /**
187+ * Get simple depth change (openers - closers) for a line
188+ */
189+ getDepthChange ( trimmed ) {
190+ let inString = false ;
191+ let escapeNext = false ;
192+ let change = 0 ;
193+
194+ for ( const char of trimmed ) {
195+ if ( escapeNext ) {
196+ escapeNext = false ;
197+ continue ;
198+ }
199+ if ( char === '\\' && inString ) {
200+ escapeNext = true ;
201+ continue ;
202+ }
203+ if ( char === '"' ) {
204+ inString = ! inString ;
205+ continue ;
206+ }
207+ if ( inString ) continue ;
208+
209+ if ( char === '{' || char === '[' ) change ++ ;
210+ else if ( char === '}' || char === ']' ) change -- ;
211+ }
212+
213+ return change ;
214+ }
215+
186216 /**
187217 * Get the number of leading spaces in a string
188218 */
@@ -198,15 +228,6 @@ class FormattingIndentCheck extends LintCheck {
198228 if ( str . length <= maxLength ) return str ;
199229 return str . substring ( 0 , maxLength - 3 ) + '...' ;
200230 }
201-
202- /**
203- * Check if difference is likely due to key ordering
204- */
205- isKeyOrderDifference ( expected , actual ) {
206- // Both are object keys
207- const keyPattern = / ^ " [ ^ " ] + " \s * : / ;
208- return keyPattern . test ( expected ) && keyPattern . test ( actual ) ;
209- }
210231}
211232
212233// Create and register the check
0 commit comments