Skip to content

Commit b8abb0c

Browse files
Initial commit of CycloneDX linter
Signed-off-by: Steve Springett <[email protected]>
1 parent dd45ceb commit b8abb0c

20 files changed

+3336
-0
lines changed

tools/src/main/js/linter/README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# CycloneDX Schema Linter
2+
3+
A modular linter for CycloneDX JSON schemas.
4+
5+
## Requirements
6+
7+
- Node.js >= 18.0.0
8+
- aspell with en_US and en_GB-ize dictionaries
9+
10+
```bash
11+
# Ubuntu/Debian
12+
sudo apt-get install aspell aspell-en
13+
14+
# macOS
15+
brew install aspell
16+
```
17+
18+
## Usage
19+
20+
```bash
21+
# Lint files
22+
node cli.js schema.json
23+
node cli.js schemas/*.schema.json
24+
25+
# Exclude or include specific checks
26+
node cli.js --exclude formatting-indent schema.json
27+
node cli.js --include description-full-stop schema.json
28+
29+
# Output formats: stylish (default), json, compact
30+
node cli.js --format json schema.json
31+
32+
# List available checks
33+
node cli.js --list-checks
34+
```
35+
36+
## Checks
37+
38+
| Check | Description |
39+
|-------|-------------|
40+
| `schema-id-pattern` | Validates `$id` matches CycloneDX URL pattern |
41+
| `formatting-indent` | Validates 2-space indentation |
42+
| `description-full-stop` | Descriptions must end with full stop |
43+
| `meta-enum-full-stop` | `meta:enum` values must end with full stop |
44+
| `property-name-american-english` | Property names use American English |
45+
| `description-oxford-english` | Descriptions use Oxford English (British with -ize) |
46+
| `no-uppercase-rfc` | No uppercase RFC 2119 keywords (MUST, SHALL, etc.) |
47+
| `no-must-word` | Use "shall" instead of "must" per ISO style |
48+
| `additional-properties-false` | Objects must have `additionalProperties: false` |
49+
| `title-formatting` | Validates title formatting conventions |
50+
| `enum-value-formatting` | Validates enum value formatting and `meta:enum` coverage |
51+
| `ref-usage` | Suggests using `$ref` for repeated structures |
52+
| `duplicate-content` | Detects duplicate titles and descriptions |
53+
| `duplicate-definitions` | Detects duplicate definitions and missing `$ref` usage |
54+
55+
## Configuration
56+
57+
Create `.cdxlintrc.json` in your project root:
58+
59+
```json
60+
{
61+
"checks": {
62+
"formatting-indent": {
63+
"spaces": 2
64+
}
65+
},
66+
"excludeChecks": ["description-oxford-english"],
67+
"includeChecks": null
68+
}
69+
```
70+
71+
## Running Tests
72+
73+
```bash
74+
npm test
75+
```
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* CycloneDX Schema Linter - Additional Properties False Check
3+
*
4+
* Validates that all object definitions have `additionalProperties: false`.
5+
* This ensures strict schema validation and prevents unexpected properties.
6+
*
7+
* @license Apache-2.0
8+
*/
9+
10+
import { LintCheck, registerCheck, Severity, traverseSchema } from '../index.js';
11+
12+
/**
13+
* Check that validates additionalProperties is set to false on objects
14+
*/
15+
class AdditionalPropertiesFalseCheck extends LintCheck {
16+
constructor() {
17+
super(
18+
'additional-properties-false',
19+
'Additional Properties False',
20+
'Validates that object definitions have additionalProperties set to false.',
21+
Severity.ERROR
22+
);
23+
}
24+
25+
async run(schema, rawContent, config = {}) {
26+
const issues = [];
27+
28+
// Paths to exclude from checking (e.g., extension points)
29+
const excludePaths = config.excludePaths || [];
30+
31+
// Allow additionalProperties to be a schema (for pattern-based validation)
32+
const allowSchema = config.allowSchema ?? false;
33+
34+
traverseSchema(schema, (node, path, key, parent) => {
35+
// Only check objects with 'properties' defined
36+
if (typeof node !== 'object' || node === null || Array.isArray(node)) {
37+
return;
38+
}
39+
40+
// Must have type: "object" or properties defined to be considered an object schema
41+
const isObjectSchema = node.type === 'object' ||
42+
node.properties !== undefined ||
43+
node.patternProperties !== undefined;
44+
45+
if (!isObjectSchema) return;
46+
47+
// Skip excluded paths
48+
if (excludePaths.some(excluded => path.includes(excluded))) return;
49+
50+
// Skip if this is a $ref (references are validated separately)
51+
if (node.$ref) return;
52+
53+
// Skip certain schema composition keywords that don't need additionalProperties
54+
if (key === 'if' || key === 'then' || key === 'else') return;
55+
if (key === 'not') return;
56+
57+
// Check additionalProperties
58+
if (!('additionalProperties' in node)) {
59+
// additionalProperties is not defined
60+
if (node.properties || node.patternProperties) {
61+
issues.push(this.createIssue(
62+
'Object schema is missing "additionalProperties: false".',
63+
path,
64+
{
65+
hasProperties: !!node.properties,
66+
hasPatternProperties: !!node.patternProperties,
67+
suggestion: 'Add "additionalProperties": false to prevent unexpected properties.'
68+
}
69+
));
70+
}
71+
} else if (node.additionalProperties === true) {
72+
// Explicitly set to true
73+
issues.push(this.createIssue(
74+
'Object schema has "additionalProperties: true". Set to false for strict validation.',
75+
path,
76+
{
77+
current: true,
78+
suggestion: 'Change "additionalProperties" to false.'
79+
}
80+
));
81+
} else if (node.additionalProperties !== false) {
82+
// Set to a schema object
83+
if (!allowSchema) {
84+
// Check if it's an empty object (equivalent to true)
85+
if (typeof node.additionalProperties === 'object' &&
86+
Object.keys(node.additionalProperties).length === 0) {
87+
issues.push(this.createIssue(
88+
'Object schema has "additionalProperties: {}" which is equivalent to true. Set to false.',
89+
path,
90+
{
91+
current: '{}',
92+
suggestion: 'Change "additionalProperties" to false.'
93+
},
94+
Severity.WARNING
95+
));
96+
} else {
97+
// It's a schema - this might be intentional
98+
issues.push(this.createIssue(
99+
'Object schema has "additionalProperties" set to a schema. ' +
100+
'Consider using false unless additional properties are intentionally allowed.',
101+
path,
102+
{
103+
current: typeof node.additionalProperties,
104+
suggestion: 'Review whether additional properties should be allowed.'
105+
},
106+
Severity.INFO
107+
));
108+
}
109+
}
110+
}
111+
// else: additionalProperties === false, which is correct
112+
});
113+
114+
return issues;
115+
}
116+
}
117+
118+
// Create and register the check
119+
const check = new AdditionalPropertiesFalseCheck();
120+
registerCheck(check);
121+
122+
export { AdditionalPropertiesFalseCheck };
123+
export default check;
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* CycloneDX Schema Linter - Description Full Stop Check
3+
*
4+
* Validates that all property descriptions end with a full stop (period).
5+
* This follows ISO House Style conventions for technical documentation.
6+
*
7+
* @license Apache-2.0
8+
*/
9+
10+
import { LintCheck, registerCheck, Severity, traverseSchema } from '../index.js';
11+
12+
/**
13+
* Check that validates descriptions end with a full stop
14+
*/
15+
class DescriptionFullStopCheck extends LintCheck {
16+
constructor() {
17+
super(
18+
'description-full-stop',
19+
'Description Full Stop',
20+
'Validates that property descriptions end with a full stop.',
21+
Severity.ERROR
22+
);
23+
}
24+
25+
async run(schema, rawContent, config = {}) {
26+
const issues = [];
27+
28+
// Characters that are acceptable at the end of a description
29+
const validEndings = config.validEndings ?? ['.', '?', '!', ')'];
30+
31+
// Paths to exclude from checking (e.g., examples)
32+
const excludePaths = config.excludePaths ?? [];
33+
34+
traverseSchema(schema, (node, path, key, parent) => {
35+
// Only check 'description' properties
36+
if (key !== 'description') return;
37+
38+
// Skip if in excluded path
39+
if (excludePaths.some(excluded => path.includes(excluded))) return;
40+
41+
// Skip if not a string
42+
if (typeof node !== 'string') return;
43+
44+
// Skip empty descriptions
45+
if (node.trim() === '') return;
46+
47+
// Skip if inside meta:enum (handled by different check)
48+
if (path.includes('meta:enum')) return;
49+
50+
const trimmed = node.trim();
51+
const lastChar = trimmed[trimmed.length - 1];
52+
53+
// Check if the description ends with an acceptable character
54+
if (!validEndings.includes(lastChar)) {
55+
// Check for special cases like URLs at the end
56+
if (this.isUrlEnding(trimmed)) {
57+
// URLs at the end are acceptable if wrapped in markdown or similar
58+
return;
59+
}
60+
61+
// Check for code blocks or inline code at the end
62+
if (trimmed.endsWith('`') || trimmed.endsWith('```')) {
63+
// Code at the end might be acceptable, but let's warn
64+
issues.push(this.createIssue(
65+
`Description ends with code block. Consider adding a full stop after.`,
66+
path,
67+
{
68+
ending: trimmed.substring(Math.max(0, trimmed.length - 30)),
69+
lastChar
70+
},
71+
Severity.WARNING
72+
));
73+
return;
74+
}
75+
76+
issues.push(this.createIssue(
77+
`Description does not end with a full stop. Ends with: "${lastChar}"`,
78+
path,
79+
{
80+
ending: trimmed.substring(Math.max(0, trimmed.length - 30)),
81+
lastChar,
82+
suggestion: trimmed + '.'
83+
}
84+
));
85+
}
86+
});
87+
88+
return issues;
89+
}
90+
91+
/**
92+
* Check if the description ends with a URL
93+
*/
94+
isUrlEnding(text) {
95+
// Pattern for URLs at the end of text
96+
const urlPattern = /https?:\/\/[^\s]+$/;
97+
98+
// Pattern for markdown links at the end
99+
const markdownLinkPattern = /\]\([^)]+\)$/;
100+
101+
return urlPattern.test(text) || markdownLinkPattern.test(text);
102+
}
103+
}
104+
105+
// Create and register the check
106+
const check = new DescriptionFullStopCheck();
107+
registerCheck(check);
108+
109+
export { DescriptionFullStopCheck };
110+
export default check;

0 commit comments

Comments
 (0)