@goliapkg/sentori-cli 1.9.0 → 1.10.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/LINT.md +237 -0
- package/README.md +14 -7
- package/examples/insight-lint.toml +32 -0
- package/lib/index.js +5 -0
- package/lib/index.js.map +1 -1
- package/lib/lint/config.d.ts +19 -0
- package/lib/lint/config.d.ts.map +1 -0
- package/lib/lint/config.js +149 -0
- package/lib/lint/config.js.map +1 -0
- package/lib/lint/index.d.ts +4 -0
- package/lib/lint/index.d.ts.map +1 -0
- package/lib/lint/index.js +204 -0
- package/lib/lint/index.js.map +1 -0
- package/lib/lint/report.d.ts +48 -0
- package/lib/lint/report.d.ts.map +1 -0
- package/lib/lint/report.js +136 -0
- package/lib/lint/report.js.map +1 -0
- package/lib/lint/rules.d.ts +28 -0
- package/lib/lint/rules.d.ts.map +1 -0
- package/lib/lint/rules.js +57 -0
- package/lib/lint/rules.js.map +1 -0
- package/package.json +4 -2
- package/src/__tests__/lint.test.ts +185 -0
- package/src/index.ts +4 -0
- package/src/lint/config.ts +102 -0
- package/src/lint/index.ts +163 -0
- package/src/lint/report.ts +129 -0
- package/src/lint/rules.ts +64 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type Level = 'off' | 'warn' | 'error';
|
|
2
|
+
export type Rule = {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
message: string;
|
|
6
|
+
level: Level;
|
|
7
|
+
allowlistOnly: boolean;
|
|
8
|
+
group: 'safety' | 'concurrency' | 'maintainability' | 'api';
|
|
9
|
+
tests?: boolean;
|
|
10
|
+
thresholds?: {
|
|
11
|
+
target: number;
|
|
12
|
+
hard?: number;
|
|
13
|
+
unit: 'lines' | 'parameters';
|
|
14
|
+
};
|
|
15
|
+
basis: 'project-policy';
|
|
16
|
+
reference: string;
|
|
17
|
+
section: string;
|
|
18
|
+
rationale: string;
|
|
19
|
+
fixable: false;
|
|
20
|
+
};
|
|
21
|
+
export declare const swiftRuleset = "swift-syntax-604.0.0/swift-quality-v2.1";
|
|
22
|
+
export declare const kotlinRuleset = "kotlin-2.4.21/kotlin-quality-v1";
|
|
23
|
+
export declare const rules: Rule[];
|
|
24
|
+
export declare const ruleById: Map<string, Rule>;
|
|
25
|
+
export declare const ruleset = "swift-syntax-604.0.0/swift-quality-v2.1+kotlin-2.4.21/kotlin-quality-v1";
|
|
26
|
+
export declare function ruleLanguage(id: string): 'swift' | 'kotlin';
|
|
27
|
+
export declare function fileLanguage(path: string): 'swift' | 'kotlin';
|
|
28
|
+
//# sourceMappingURL=rules.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../src/lint/rules.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,KAAK,GAAG,KAAK,GAAG,MAAM,GAAG,OAAO,CAAA;AAC5C,MAAM,MAAM,IAAI,GAAG;IACjB,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,KAAK,CAAC;IAAC,aAAa,EAAE,OAAO,CAAA;IAC/E,KAAK,EAAE,QAAQ,GAAG,aAAa,GAAG,iBAAiB,GAAG,KAAK,CAAA;IAC3D,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,UAAU,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,YAAY,CAAA;KAAE,CAAA;IAC5E,KAAK,EAAE,gBAAgB,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,KAAK,CAAA;CAC/F,CAAA;AA2BD,eAAO,MAAM,YAAY,4CAA4C,CAAA;AACrE,eAAO,MAAM,aAAa,oCAAoC,CAAA;AAwB9D,eAAO,MAAM,KAAK,QAAkC,CAAA;AACpD,eAAO,MAAM,QAAQ,mBAAqC,CAAA;AAC1D,eAAO,MAAM,OAAO,4EAAqC,CAAA;AACzD,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAoD;AAChH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAwD"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const concurrency = 'https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/incrementaladoption/';
|
|
2
|
+
const swiftRules = [
|
|
3
|
+
{ id: 'SWSAFE001', name: 'forced-try', message: 'Replace try! with explicit error handling.', level: 'error', allowlistOnly: false,
|
|
4
|
+
reference: 'https://docs.swift.org/swift-book/documentation/the-swift-programming-language/errorhandling/', section: 'Disabling Error Propagation', rationale: 'An unexpected thrown error terminates the process.' },
|
|
5
|
+
{ id: 'SWSAFE002', name: 'forced-cast', message: 'Replace as! with a checked cast.', level: 'error', allowlistOnly: false,
|
|
6
|
+
reference: 'https://docs.swift.org/swift-book/documentation/the-swift-programming-language/typecasting/', section: 'Downcasting', rationale: 'A failed forced downcast terminates the process.' },
|
|
7
|
+
{ id: 'SWCON001', name: 'unsafe-nonisolation', message: 'nonisolated(unsafe) requires a configured allowance with a reason.', level: 'error', allowlistOnly: true,
|
|
8
|
+
reference: concurrency, section: 'Incremental Adoption', rationale: 'This opts out of compiler isolation checking; document the safety argument.' },
|
|
9
|
+
{ id: 'SWCON002', name: 'unchecked-sendable', message: '@unchecked Sendable requires a configured allowance with a reason.', level: 'error', allowlistOnly: true,
|
|
10
|
+
reference: concurrency, section: 'Incremental Adoption', rationale: 'The programmer assumes responsibility for thread safety.' },
|
|
11
|
+
{ id: 'SWCON003', name: 'detached-task', message: 'Task.detached requires a configured allowance with a reason.', level: 'error', allowlistOnly: true,
|
|
12
|
+
reference: 'https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/', section: 'Unstructured Concurrency', rationale: 'Detached work does not inherit actor isolation. This syntax rule matches Task.detached and Swift.Task.detached calls; it does not resolve shadowed names.' },
|
|
13
|
+
{ id: 'SWMAINT001', name: 'function-length', message: 'Function exceeds the target length.', level: 'error', allowlistOnly: false, tests: true,
|
|
14
|
+
thresholds: { target: 50, hard: 200, unit: 'lines' },
|
|
15
|
+
reference: 'https://www.swift.org/documentation/api-design-guidelines/#fundamentals', section: 'Fundamentals', rationale: 'Project limits, not official numeric limits: count physical lines from func to its closing brace, including comments and blank lines. Includes named nested/operator functions and test functions, but not initializers, accessors, or closures.' },
|
|
16
|
+
{ id: 'SWMAINT002', name: 'file-length', message: 'File exceeds the target length.', level: 'error', allowlistOnly: false,
|
|
17
|
+
thresholds: { target: 300, hard: 500, unit: 'lines' },
|
|
18
|
+
reference: 'https://google.github.io/swift/#source-file-structure', section: 'Source File Structure', rationale: 'Project limits, not official numeric limits: count all physical lines, including comments and blank lines. A final line terminator does not add an empty line. Test files are exempt.' },
|
|
19
|
+
{ id: 'SWSAFE003', name: 'forced-unwrap', message: 'Review this forced unwrap; use checked optional handling.', level: 'error', allowlistOnly: false,
|
|
20
|
+
reference: 'https://docs.swift.org/swift-book/documentation/the-swift-programming-language/thebasics/', section: 'Force Unwrapping', rationale: 'Unwrapping nil terminates the process. This rule matches postfix unwrap expressions, not IUO type declarations, logical negation, try!, or as!.' },
|
|
21
|
+
{ id: 'SWAPI001', name: 'parameter-count', message: 'Declaration exceeds the parameter-count target.', level: 'warn', allowlistOnly: false,
|
|
22
|
+
thresholds: { target: 5, unit: 'parameters' },
|
|
23
|
+
reference: 'https://www.swift.org/documentation/api-design-guidelines/#parameters', section: 'Parameters', rationale: 'Five is a project limit, not an official numeric limit. Counts explicit parameters of functions, initializers, and subscripts, including defaults and variadics; excludes closure parameters and function types.' },
|
|
24
|
+
{ id: 'SWAPI002', name: 'bool-parameter', message: 'Review this Bool parameter; consider a named option type when it represents a choice.', level: 'warn', allowlistOnly: false,
|
|
25
|
+
reference: 'https://www.swift.org/documentation/api-design-guidelines/#fundamentals', section: 'Fundamentals', rationale: 'Optional project policy to review call-site clarity, not a prohibition of Bool in Swift. Matches explicit Bool and Swift.Bool parameters, including inout, but not optional/collection Bool types, closures, or unresolved aliases.' },
|
|
26
|
+
].map((r) => ({ ...r, group: r.id.startsWith('SWCON') ? 'concurrency' : r.id.startsWith('SWMAINT') ? 'maintainability' : r.id.startsWith('SWAPI') ? 'api' : 'safety', level: r.level, basis: 'project-policy', fixable: false }));
|
|
27
|
+
export const swiftRuleset = 'swift-syntax-604.0.0/swift-quality-v2.1';
|
|
28
|
+
export const kotlinRuleset = 'kotlin-2.4.21/kotlin-quality-v1';
|
|
29
|
+
const kotlinReference = 'https://kotlinlang.org/docs/coding-conventions.html';
|
|
30
|
+
const kotlinRules = [
|
|
31
|
+
{ id: 'KTSAFE001', name: 'not-null-assertion', message: 'Replace !! with checked nullable handling.', group: 'safety', level: 'error', allowlistOnly: false,
|
|
32
|
+
reference: 'https://kotlinlang.org/docs/null-safety.html', section: 'Not-null assertion operator', rationale: 'Asserting a null value throws an exception. Matches postfix expressions, excluding comments, strings and logical negation.' },
|
|
33
|
+
{ id: 'KTCON001', name: 'global-scope', message: 'GlobalScope requires a reviewed allowance.', group: 'concurrency', level: 'error', allowlistOnly: true,
|
|
34
|
+
reference: 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-global-scope/', section: 'GlobalScope', rationale: 'Unstructured work is not bound to a lifecycle. Matches explicit coroutine imports (including aliases and wildcard imports) and qualified uses. Syntax only: local shadowing is not resolved.' },
|
|
35
|
+
{ id: 'KTCON002', name: 'run-blocking', message: 'runBlocking in main sources requires a reviewed allowance.', group: 'concurrency', level: 'error', allowlistOnly: true,
|
|
36
|
+
reference: 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/run-blocking.html', section: 'runBlocking', rationale: 'Blocks the calling thread. Matches imported or qualified coroutine calls, including aliases. Configured test sources are exempt; local shadowing is not resolved.' },
|
|
37
|
+
{ id: 'KTMAINT001', name: 'kotlin-function-length', message: 'Function exceeds the target length.', group: 'maintainability', level: 'error', allowlistOnly: false, tests: true,
|
|
38
|
+
thresholds: { target: 50, hard: 200, unit: 'lines' }, reference: kotlinReference, section: 'Functions', rationale: 'Project limits: physical lines from fun through the body end, including blank lines and comments. Includes named nested and expression-body functions, excluding constructors, accessors and lambdas.' },
|
|
39
|
+
{ id: 'KTMAINT002', name: 'kotlin-file-length', message: 'File exceeds the target length.', group: 'maintainability', level: 'error', allowlistOnly: false,
|
|
40
|
+
thresholds: { target: 300, hard: 500, unit: 'lines' }, reference: kotlinReference, section: 'Source file organization', rationale: 'Project limits: all physical lines, including comments and blanks, without a phantom line after the final terminator. Configured test files are exempt.' },
|
|
41
|
+
{ id: 'KTAPI001', name: 'kotlin-parameter-count', message: 'Declaration exceeds the parameter-count target.', group: 'api', level: 'warn', allowlistOnly: false,
|
|
42
|
+
thresholds: { target: 5, unit: 'parameters' }, reference: kotlinReference, section: 'Function formatting', rationale: 'Project warning threshold, not an official numeric limit. Counts explicit function and constructor parameters, including defaults and varargs, excluding lambdas and function types.' },
|
|
43
|
+
{ id: 'KTAPI002', name: 'boolean-parameter', message: 'Review this Boolean parameter for call-site clarity.', group: 'api', level: 'warn', allowlistOnly: false,
|
|
44
|
+
reference: kotlinReference, section: 'Named arguments', rationale: 'Project policy: explicit Boolean and kotlin.Boolean parameters, excluding nullable and collection types, aliases and lambdas.' },
|
|
45
|
+
{ id: 'AKS001', name: 'wildcard-import', message: 'Use explicit imports.', group: 'maintainability', level: 'warn', allowlistOnly: false,
|
|
46
|
+
reference: 'https://developer.android.com/kotlin/style-guide', section: 'Import statements', rationale: 'Android Kotlin style disallows wildcard imports.' },
|
|
47
|
+
{ id: 'AKS002', name: 'control-body-braces', message: 'Use braces around this control body.', group: 'maintainability', level: 'warn', allowlistOnly: false,
|
|
48
|
+
reference: 'https://developer.android.com/kotlin/style-guide', section: 'Braces', rationale: 'Braces for loops and multiline branches; a single-line if without an else-if chain and single-line when branches are exempt.' },
|
|
49
|
+
{ id: 'AKS003', name: 'constant-name', message: 'Name constants in UPPER_SNAKE_CASE.', group: 'maintainability', level: 'warn', allowlistOnly: false,
|
|
50
|
+
reference: 'https://developer.android.com/kotlin/style-guide', section: 'Constant names', rationale: 'Checks ASCII const val names. Non-ASCII identifiers are left for review.' },
|
|
51
|
+
].map((r) => ({ ...r, group: r.group, level: r.level, basis: 'project-policy', fixable: false }));
|
|
52
|
+
export const rules = [...swiftRules, ...kotlinRules];
|
|
53
|
+
export const ruleById = new Map(rules.map(r => [r.id, r]));
|
|
54
|
+
export const ruleset = `${swiftRuleset}+${kotlinRuleset}`;
|
|
55
|
+
export function ruleLanguage(id) { return id.startsWith('SW') ? 'swift' : 'kotlin'; }
|
|
56
|
+
export function fileLanguage(path) { return path.endsWith('.swift') ? 'swift' : 'kotlin'; }
|
|
57
|
+
//# sourceMappingURL=rules.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rules.js","sourceRoot":"","sources":["../../src/lint/rules.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goliapkg/sentori-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Sentori CLI \u2014 configure, upload and verify release debug files and source archives.",
|
|
5
5
|
"license": "Apache-2.0 OR MIT",
|
|
6
6
|
"author": "GOLIA K.K. <takagi@golia.jp> (https://golia.jp)",
|
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
"files": [
|
|
30
30
|
"lib/",
|
|
31
31
|
"src/",
|
|
32
|
-
"README.md"
|
|
32
|
+
"README.md",
|
|
33
|
+
"LINT.md",
|
|
34
|
+
"examples/"
|
|
33
35
|
],
|
|
34
36
|
"engines": {
|
|
35
37
|
"node": ">=18.17"
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync, existsSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { resolve, join } from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { isTest, parseConfig, selected } from '../lint/config.js'
|
|
7
|
+
import { initBaseline, lowerBaseline, makeReport, validateBaseline, validateResponse, sarif } from '../lint/report.js'
|
|
8
|
+
import { swiftRuleset as ruleset, kotlinRuleset } from '../lint/rules.js'
|
|
9
|
+
|
|
10
|
+
const config = () => parseConfig('version = 1\ninclude = ["**"]')
|
|
11
|
+
const finding = (rule = 'SWSAFE001', path = 'App.swift', line = 1) => ({ rule, path, line, column: 1 })
|
|
12
|
+
|
|
13
|
+
describe('lint policy', () => {
|
|
14
|
+
test('insight scopes keep test functions and skip generated paths case insensitively', () => {
|
|
15
|
+
const c = parseConfig(readFileSync(resolve(import.meta.dir, '../../examples/insight-lint.toml'), 'utf8'))
|
|
16
|
+
expect(selected('insight-common-ios/App.swift', c)).toBe(true)
|
|
17
|
+
for (const p of ['insight-consumer-ios/A.swift', 'insight-common-ios/Theme/gEnErAtEd/Foo.swift', 'insight-ios/IconGenerated.swift']) expect(selected(p, c)).toBe(false)
|
|
18
|
+
expect(selected('insight-common-ios/Tests/A.swift', c)).toBe(true)
|
|
19
|
+
expect(isTest('insight-ios/FooTest.swift', c)).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
test('allows require known rules, paths and meaningful reasons', () => {
|
|
22
|
+
const prefix = 'version = 1\n[[allow]]\nrule = "SWCON001"\npath = "App.swift"\n'
|
|
23
|
+
expect(() => parseConfig(prefix)).toThrow('nonempty reason')
|
|
24
|
+
expect(() => parseConfig(prefix + 'reason = " "')).toThrow('nonempty reason')
|
|
25
|
+
expect(parseConfig(prefix + 'reason = "Protected by a lock # verified"').allow[0].reason).toContain('# verified')
|
|
26
|
+
expect(() => parseConfig(prefix.replace('SWCON001', 'TYPO') + 'reason = "reviewed"')).toThrow('known rule')
|
|
27
|
+
})
|
|
28
|
+
test('rejects unsupported and duplicate configuration rather than silently weakening policy', () => {
|
|
29
|
+
for (const text of ['version = 1\nversion = 1', 'version = 1\nunknown = "x"', 'version = 1\n[rules]\nTYPO = "off"', 'version = 1\n[rules]\nSWCON001 = "off"', 'version = 1\nexcludePathRegex = ["("]']) expect(() => parseConfig(text)).toThrow()
|
|
30
|
+
})
|
|
31
|
+
test('counts by rule and file, new files start at zero, warning is non-blocking', () => {
|
|
32
|
+
const c = config(), old = makeReport(['App.swift'], [finding()], c)
|
|
33
|
+
const b = initBaseline(old, c)
|
|
34
|
+
expect(makeReport(['App.swift'], [finding()], c, b).regressions).toHaveLength(0)
|
|
35
|
+
expect(makeReport(['App.swift'], [finding(), finding('SWSAFE001', 'App.swift', 2)], c, b).regressions).toHaveLength(1)
|
|
36
|
+
expect(makeReport(['New.swift'], [finding('SWSAFE001', 'New.swift')], c, b).regressions[0].allowed).toBe(0)
|
|
37
|
+
c.rules.SWSAFE001 = 'warn'
|
|
38
|
+
expect(makeReport(['App.swift'], [finding()], c).regressions).toHaveLength(0)
|
|
39
|
+
})
|
|
40
|
+
test('baseline updates cannot add debt, and cannot absorb concurrency escape hatches', () => {
|
|
41
|
+
const c = config(), b = initBaseline(makeReport(['App.swift'], [finding()], c), c)
|
|
42
|
+
const clean = makeReport(['App.swift'], [], c, b)
|
|
43
|
+
expect(clean.baselineCanDecrease).toBe(true)
|
|
44
|
+
expect(lowerBaseline(clean, b).counts).toEqual({})
|
|
45
|
+
expect(() => lowerBaseline(makeReport(['New.swift'], [finding('SWSAFE001', 'New.swift')], c, b), b)).toThrow('exceed')
|
|
46
|
+
expect(() => initBaseline(makeReport(['App.swift'], [finding('SWCON001')], c), c)).toThrow('allowance')
|
|
47
|
+
})
|
|
48
|
+
test('configuration changes and forged baseline entries are rejected', () => {
|
|
49
|
+
const c = config(), b = initBaseline(makeReport(['App.swift'], [finding()], c), c)
|
|
50
|
+
expect(validateBaseline(b, c)).toEqual(b)
|
|
51
|
+
expect(() => validateBaseline(b, { ...c, exclude: ['App.swift'] })).toThrow('scope')
|
|
52
|
+
expect(() => validateBaseline({ ...b, counts: { 'App.swift': { SWCON001: 3 } } }, c)).toThrow('invalid baseline')
|
|
53
|
+
})
|
|
54
|
+
test('length targets ratchet but crossing a hard limit fails even at the same count', () => {
|
|
55
|
+
const c = config(), length = { ...finding('SWMAINT001'), measured: 51 }
|
|
56
|
+
const b = initBaseline(makeReport(['App.swift'], [length], c), c)
|
|
57
|
+
expect(makeReport(['App.swift'], [{ ...length, measured: 200 }], c, b).regressions).toHaveLength(0)
|
|
58
|
+
const hard = makeReport(['App.swift'], [{ ...length, measured: 201 }], c, b)
|
|
59
|
+
expect(hard.regressions[0].kind).toBe('hard-limit')
|
|
60
|
+
expect(() => initBaseline(hard, c)).toThrow('hard limits')
|
|
61
|
+
expect(() => lowerBaseline(hard, b)).toThrow('exceed')
|
|
62
|
+
const warningConfig = { ...c, rules: { SWMAINT001: 'warn' as const } }
|
|
63
|
+
expect(makeReport(['App.swift'], [length], warningConfig).regressions).toHaveLength(0)
|
|
64
|
+
expect(makeReport(['App.swift'], [{ ...length, measured: 201 }], warningConfig).regressions[0].kind).toBe('hard-limit')
|
|
65
|
+
})
|
|
66
|
+
test('tests participate only in function length, and Bool parameters default to warnings', () => {
|
|
67
|
+
const c = parseConfig('version = 1\ntests = ["**/*Test.swift"]')
|
|
68
|
+
const path = 'AppTest.swift'
|
|
69
|
+
const report = makeReport([path], [finding('SWCON001', path), { ...finding('SWMAINT001', path), measured: 51 }, { ...finding('SWMAINT002', path), measured: 501 }, finding('SWAPI002', path)], c)
|
|
70
|
+
expect(report.results.map(r => r.rule)).toEqual(['SWMAINT001'])
|
|
71
|
+
const bool = makeReport(['App.swift'], [finding('SWAPI002')], c)
|
|
72
|
+
expect(bool.results[0].level).toBe('warn')
|
|
73
|
+
expect(bool.regressions).toHaveLength(0)
|
|
74
|
+
const parameters = { ...finding('SWAPI001'), measured: 6 }
|
|
75
|
+
const warning = makeReport(['App.swift'], [parameters], c)
|
|
76
|
+
expect(warning.results[0].level).toBe('warn')
|
|
77
|
+
expect(warning.regressions).toHaveLength(0)
|
|
78
|
+
expect(makeReport(['App.swift'], [parameters], { ...c, rules: { SWAPI001: 'error' } }).regressions).toHaveLength(1)
|
|
79
|
+
})
|
|
80
|
+
test('analyzer measurements are required for threshold rules', () => {
|
|
81
|
+
const response = (diagnostics: unknown[]) => ({ version: 1, ruleset, files: ['App.swift'], diagnostics })
|
|
82
|
+
expect(() => validateResponse(response([finding('SWMAINT001')]), ['App.swift'])).toThrow('measurement')
|
|
83
|
+
expect(() => validateResponse(response([{ ...finding('SWMAINT001'), measured: 50 }]), ['App.swift'])).toThrow('measurement')
|
|
84
|
+
expect(validateResponse(response([{ ...finding('SWMAINT001'), measured: 51 }]), ['App.swift'])).toHaveLength(1)
|
|
85
|
+
})
|
|
86
|
+
test('allowance remains visible in SARIF with UTF-16 columns and escaped URI', () => {
|
|
87
|
+
const c = parseConfig('version = 1\n[[allow]]\nrule = "SWCON001"\npath = "A #é.swift"\nreason = "Protected by Lock"')
|
|
88
|
+
const r = makeReport(['A #é.swift'], [finding('SWCON001', 'A #é.swift')], c)
|
|
89
|
+
expect(r.regressions).toHaveLength(0)
|
|
90
|
+
const s = sarif(r) as any
|
|
91
|
+
expect(s.runs[0].columnKind).toBe('utf16CodeUnits')
|
|
92
|
+
expect(s.runs[0].results[0].locations[0].physicalLocation.artifactLocation.uri).toBe('A%20%23%C3%A9.swift')
|
|
93
|
+
expect(s.runs[0].results[0].suppressions[0].justification).toBe('Protected by Lock')
|
|
94
|
+
})
|
|
95
|
+
test('analyzer omission, duplicate diagnostics and unknown rules fail closed', () => {
|
|
96
|
+
const good = { version: 1, ruleset, files: ['App.swift'], diagnostics: [finding()] }
|
|
97
|
+
expect(validateResponse(good, ['App.swift'])).toEqual([finding()])
|
|
98
|
+
for (const bad of [{ ...good, files: [] }, { ...good, diagnostics: [finding(), finding()] }, { ...good, diagnostics: [finding('UNKNOWN')] }, { ...good, diagnostics: [{ ...finding(), line: 0 }] }]) expect(() => validateResponse(bad, ['App.swift'])).toThrow()
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
let dir: string
|
|
103
|
+
const entry = resolve(import.meta.dir, '../index.ts')
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
dir = mkdtempSync(join(tmpdir(), 'sentori-lint-'))
|
|
106
|
+
spawnSync('git', ['init', '-q', dir])
|
|
107
|
+
writeFileSync(join(dir, 'sentori-lint.toml'), 'version = 1\n')
|
|
108
|
+
// This protocol peer intentionally uses markers, not Swift parsing. Actual
|
|
109
|
+
// grammar and source locations are exercised by the Swift package tests.
|
|
110
|
+
writeFileSync(join(dir, 'analyzer.cjs'), `#!${process.execPath}
|
|
111
|
+
const fs = require('node:fs'); const request = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
112
|
+
if (process.env.LINT_TEST_CRASH) process.exit(2);
|
|
113
|
+
if (process.env.LINT_TEST_ADD_FILE) fs.writeFileSync('Added.swift', 'BAD');
|
|
114
|
+
const diagnostics = request.files.flatMap(path => fs.readFileSync(path, 'utf8').split('\\n').flatMap((line, i) => line.startsWith('BAD') ? [{path, rule: 'SWSAFE001', line: i + 1, column: 1}] : []));
|
|
115
|
+
console.log(JSON.stringify({version: 1, ruleset: ${JSON.stringify(ruleset)}, files: request.files, diagnostics}));
|
|
116
|
+
`, { mode: 0o755 })
|
|
117
|
+
})
|
|
118
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
119
|
+
function cli(args: string[], env: Record<string, string> = {}) {
|
|
120
|
+
return spawnSync(process.execPath, [entry, 'lint', '--swift-analyzer', join(dir, 'analyzer.cjs'), '--format', 'json', ...args], { cwd: dir, encoding: 'utf8', env: { ...process.env, ...env } })
|
|
121
|
+
}
|
|
122
|
+
describe('lint CLI failure and baseline contracts', () => {
|
|
123
|
+
test('untracked new files fail; initialize once, then only lower', () => {
|
|
124
|
+
writeFileSync(join(dir, 'App.swift'), 'BAD\nBAD\n')
|
|
125
|
+
expect(cli([]).status).toBe(1)
|
|
126
|
+
expect(cli(['--baseline', 'baseline.json', '--init-baseline']).status).toBe(0)
|
|
127
|
+
const original = readFileSync(join(dir, 'baseline.json'), 'utf8')
|
|
128
|
+
expect(cli(['--baseline', 'baseline.json', '--init-baseline']).status).toBe(2)
|
|
129
|
+
writeFileSync(join(dir, 'New.swift'), 'BAD\n')
|
|
130
|
+
expect(cli(['--baseline', 'baseline.json', '--update-baseline']).status).toBe(1)
|
|
131
|
+
expect(readFileSync(join(dir, 'baseline.json'), 'utf8')).toBe(original)
|
|
132
|
+
rmSync(join(dir, 'New.swift'))
|
|
133
|
+
writeFileSync(join(dir, 'App.swift'), 'BAD\n')
|
|
134
|
+
expect(cli(['--baseline', 'baseline.json', '--update-baseline']).status).toBe(0)
|
|
135
|
+
expect(JSON.parse(readFileSync(join(dir, 'baseline.json'), 'utf8')).counts['App.swift'].SWSAFE001).toBe(1)
|
|
136
|
+
})
|
|
137
|
+
test('partial scan or analyzer failure never writes a baseline', () => {
|
|
138
|
+
writeFileSync(join(dir, 'App.swift'), 'BAD\n')
|
|
139
|
+
expect(cli(['App.swift', '--baseline', 'base.json', '--init-baseline']).status).toBe(2)
|
|
140
|
+
expect(cli(['--changed', 'HEAD', '--baseline', 'base.json', '--init-baseline']).status).toBe(2)
|
|
141
|
+
expect(cli(['--baseline', 'base.json', '--init-baseline'], { LINT_TEST_CRASH: '1' }).status).toBe(2)
|
|
142
|
+
expect(existsSync(join(dir, 'base.json'))).toBe(false)
|
|
143
|
+
})
|
|
144
|
+
test('changed scan includes untracked files even with no tracked diff', () => {
|
|
145
|
+
writeFileSync(join(dir, 'App.swift'), 'clean\n')
|
|
146
|
+
spawnSync('git', ['add', '.'], { cwd: dir })
|
|
147
|
+
spawnSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-qm', 'initial'], { cwd: dir })
|
|
148
|
+
writeFileSync(join(dir, 'New.swift'), 'BAD\n')
|
|
149
|
+
const r = cli(['--changed', 'HEAD'])
|
|
150
|
+
expect(r.status).toBe(1)
|
|
151
|
+
expect(JSON.parse(r.stdout).files).toEqual(['New.swift'])
|
|
152
|
+
})
|
|
153
|
+
test('source added during analysis prevents an incomplete baseline snapshot', () => {
|
|
154
|
+
writeFileSync(join(dir, 'App.swift'), 'clean\n')
|
|
155
|
+
expect(cli(['--baseline', 'base.json', '--init-baseline'], { LINT_TEST_ADD_FILE: '1' }).status).toBe(2)
|
|
156
|
+
expect(existsSync(join(dir, 'base.json'))).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
test('selected Kotlin files require a Kotlin analyzer', () => {
|
|
159
|
+
mkdirSync(join(dir, 'src'))
|
|
160
|
+
writeFileSync(join(dir, 'src/App.kt'), 'class App')
|
|
161
|
+
expect(cli(['--kotlin-analyzer', join(dir, 'missing-analyzer')]).status).toBe(2)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('Kotlin protocol and policy', () => {
|
|
166
|
+
test('rejects cross-language diagnostics and mismatched analyzers', () => {
|
|
167
|
+
const files = ['App.kt']
|
|
168
|
+
const response = { version: 1, ruleset: kotlinRuleset, files, diagnostics: [finding('KTSAFE001', 'App.kt')] }
|
|
169
|
+
expect(validateResponse(response, files, 'kotlin')).toHaveLength(1)
|
|
170
|
+
expect(() => validateResponse(response, files, 'swift')).toThrow()
|
|
171
|
+
expect(() => validateResponse({ ...response, diagnostics: [finding('SWSAFE001', 'App.kt')] }, files, 'kotlin')).toThrow()
|
|
172
|
+
})
|
|
173
|
+
test('test functions retain hard limits while main-source concurrency cannot be baselined', () => {
|
|
174
|
+
const c = parseConfig('version = 1\ntests = ["**/src/test/**"]')
|
|
175
|
+
const path = 'app/src/test/Test.kt'
|
|
176
|
+
const r = makeReport([path], [finding('KTCON002', path), finding('KTSAFE001', path), { ...finding('KTMAINT001', path), measured: 201 }], c)
|
|
177
|
+
expect(r.results).toHaveLength(1)
|
|
178
|
+
expect(r.regressions[0].kind).toBe('hard-limit')
|
|
179
|
+
expect(() => initBaseline(makeReport(['Main.kt'], [finding('KTCON001', 'Main.kt')], c), c)).toThrow('allowance')
|
|
180
|
+
const report = makeReport(['Main.kt'], [finding('KTSAFE001', 'Main.kt')], c)
|
|
181
|
+
const baseline = initBaseline(report, c)
|
|
182
|
+
expect(validateBaseline(baseline, c)).toEqual(baseline)
|
|
183
|
+
expect(makeReport(['Main.kt'], report.results, c, baseline).regressions).toHaveLength(0)
|
|
184
|
+
})
|
|
185
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -49,6 +49,9 @@ Legacy kind-presence gate (use verify for exact planned files):
|
|
|
49
49
|
catches the case a local "we ran the upload" ledger cannot:
|
|
50
50
|
the upload step that stopped being called at all.
|
|
51
51
|
|
|
52
|
+
Local Swift and Kotlin quality gate (fails on regressions or tool errors):
|
|
53
|
+
sentori lint [paths...] --help
|
|
54
|
+
|
|
52
55
|
Regression tripwires (design: probes):
|
|
53
56
|
sentori probes sync --release <r> --token <t> [--dir .]
|
|
54
57
|
Statically scans source for sentori.probe('REF') call sites and
|
|
@@ -724,6 +727,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
724
727
|
console.log(HELP)
|
|
725
728
|
return 0
|
|
726
729
|
}
|
|
730
|
+
if (argv[0] === 'lint') return (await import('./lint/index.js')).cmdLint(argv.slice(1))
|
|
727
731
|
const [a, b, ...rest] = argv
|
|
728
732
|
if (a === 'web') return runWeb(argv.slice(1))
|
|
729
733
|
if (a === 'project' && b === 'show') return cmdProjectApps(rest, false)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { ruleById, rules, ruleLanguage, fileLanguage } from './rules.js'
|
|
2
|
+
import type { Level } from './rules.js'
|
|
3
|
+
export type Config = {
|
|
4
|
+
version: 1; include: string[]; exclude: string[]; tests: string[]
|
|
5
|
+
excludePathRegex: string[]; rules: Record<string, Level>
|
|
6
|
+
allow: { rule: string; path: string; reason: string }[]
|
|
7
|
+
}
|
|
8
|
+
// Deliberately small TOML schema: scalar strings/integers, single-line string
|
|
9
|
+
// arrays, [rules], and [[allow]]. Reject unsupported TOML rather than guessing.
|
|
10
|
+
export function parseConfig(source: string): Config {
|
|
11
|
+
const result: Config = { version: 1, include: ['**'], exclude: [], tests: [], excludePathRegex: [], rules: {}, allow: [] }
|
|
12
|
+
const seen = new Set<string>()
|
|
13
|
+
let section = ''
|
|
14
|
+
let allow: Record<string, unknown> | undefined
|
|
15
|
+
const allowances: Record<string, unknown>[] = []
|
|
16
|
+
const stringValue = (raw: string): string => {
|
|
17
|
+
if (raw.startsWith("'") && raw.endsWith("'") && !raw.slice(1, -1).includes("'")) return raw.slice(1, -1)
|
|
18
|
+
const value: unknown = JSON.parse(raw)
|
|
19
|
+
if (typeof value !== 'string') throw Error('expected a string')
|
|
20
|
+
return value
|
|
21
|
+
}
|
|
22
|
+
for (const [index, rawLine] of source.split(/\r?\n/).entries()) {
|
|
23
|
+
try {
|
|
24
|
+
// Comments are removed only outside quoted strings.
|
|
25
|
+
let quote = '', escaped = false, line = ''
|
|
26
|
+
for (const char of rawLine) {
|
|
27
|
+
if (!quote && char === '#') break
|
|
28
|
+
line += char
|
|
29
|
+
if (escaped) { escaped = false; continue }
|
|
30
|
+
if (quote === '"' && char === '\\') { escaped = true; continue }
|
|
31
|
+
if (quote === char) quote = ''
|
|
32
|
+
else if (!quote && (char === '"' || char === "'")) quote = char
|
|
33
|
+
}
|
|
34
|
+
line = line.trim()
|
|
35
|
+
if (!line) continue
|
|
36
|
+
if (line === '[rules]') {
|
|
37
|
+
if (seen.has('[rules]')) throw Error('duplicate [rules]')
|
|
38
|
+
seen.add('[rules]'); section = 'rules'; continue
|
|
39
|
+
}
|
|
40
|
+
if (line === '[[allow]]') { section = 'allow'; allow = {}; allowances.push(allow); continue }
|
|
41
|
+
const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(.+)$/.exec(line)
|
|
42
|
+
if (!match) throw Error('unsupported TOML; use single-line values, [rules], or [[allow]]')
|
|
43
|
+
const [, key, raw] = match
|
|
44
|
+
const identity = section === 'allow' ? `allow.${allowances.length}.${key}` : `${section}.${key}`
|
|
45
|
+
if (seen.has(identity)) throw Error(`duplicate key ${key}`)
|
|
46
|
+
seen.add(identity)
|
|
47
|
+
if (section === 'allow') {
|
|
48
|
+
if (!['rule', 'path', 'reason'].includes(key)) throw Error(`unknown allowance key ${key}`)
|
|
49
|
+
allow![key] = stringValue(raw)
|
|
50
|
+
} else if (section === 'rules') {
|
|
51
|
+
const rule = ruleById.get(key)
|
|
52
|
+
if (!rule) throw Error(`unknown rule ${key}`)
|
|
53
|
+
const level = stringValue(raw)
|
|
54
|
+
if (!['off', 'warn', 'error'].includes(level)) throw Error(`invalid severity ${level}`)
|
|
55
|
+
if (rule.allowlistOnly && level !== 'error') throw Error(`${key} requires error severity; use an allowance`)
|
|
56
|
+
result.rules[key] = level as Level
|
|
57
|
+
} else if (key === 'version') {
|
|
58
|
+
if (raw !== '1') throw Error('unsupported config version')
|
|
59
|
+
} else if (['include', 'exclude', 'tests', 'excludePathRegex'].includes(key)) {
|
|
60
|
+
// Arrays use TOML basic strings, also valid JSON. No multiline arrays.
|
|
61
|
+
const value: unknown = JSON.parse(raw)
|
|
62
|
+
if (!Array.isArray(value) || value.some(v => typeof v !== 'string' || !v)) throw Error(`${key} requires nonempty strings`)
|
|
63
|
+
;(result[key as 'include'] as string[]) = value
|
|
64
|
+
} else throw Error(`unknown configuration key ${key}`)
|
|
65
|
+
} catch (error) { throw Error(`config line ${index + 1}: ${(error as Error).message}`) }
|
|
66
|
+
}
|
|
67
|
+
if (!seen.has('.version')) throw Error('config version = 1 is required')
|
|
68
|
+
if (!result.include.length) throw Error('include must not be empty')
|
|
69
|
+
result.allow = allowances.map(a => {
|
|
70
|
+
if (typeof a.rule !== 'string' || !ruleById.has(a.rule)) throw Error('allowance needs a known rule')
|
|
71
|
+
if (typeof a.path !== 'string' || !a.path.trim() || a.path.startsWith('/') || a.path.split('/').includes('..')) throw Error('allowance needs a repository-relative path pattern')
|
|
72
|
+
if (typeof a.reason !== 'string' || !a.reason.trim()) throw Error('allowance needs a nonempty reason')
|
|
73
|
+
return { rule: a.rule, path: a.path, reason: a.reason.trim() }
|
|
74
|
+
})
|
|
75
|
+
for (const pattern of [...result.include, ...result.exclude, ...result.tests, ...result.allow.map(a => a.path)]) glob(pattern)
|
|
76
|
+
for (const pattern of result.excludePathRegex) new RegExp(pattern, 'i')
|
|
77
|
+
return result
|
|
78
|
+
}
|
|
79
|
+
export function glob(pattern: string): RegExp {
|
|
80
|
+
if (pattern.startsWith('/') || pattern.includes('\\') || pattern.split('/').includes('..') || /[\[\]{}]/.test(pattern)) throw Error(`unsupported path glob: ${pattern}`)
|
|
81
|
+
let regex = '^'
|
|
82
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
83
|
+
const c = pattern[i]
|
|
84
|
+
if (c === '*' && pattern[i + 1] === '*') {
|
|
85
|
+
i++
|
|
86
|
+
if (pattern[i + 1] === '/') { regex += '(?:.*/)?'; i++ } else regex += '.*'
|
|
87
|
+
} else if (c === '*') regex += '[^/]*'
|
|
88
|
+
else if (c === '?') regex += '[^/]'
|
|
89
|
+
else regex += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
90
|
+
}
|
|
91
|
+
return new RegExp(regex + '$')
|
|
92
|
+
}
|
|
93
|
+
export function selected(path: string, config: Config): boolean {
|
|
94
|
+
const any = (patterns: string[]) => patterns.some(p => glob(p).test(path))
|
|
95
|
+
return any(config.include) && !any(config.exclude)
|
|
96
|
+
&& (!any(config.tests) || rules.some(rule => ruleLanguage(rule.id) === fileLanguage(path) && rule.tests && (config.rules[rule.id] ?? rule.level) !== 'off'))
|
|
97
|
+
&& !config.excludePathRegex.some(p => new RegExp(p, 'i').test(path))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isTest(path: string, config: Config): boolean {
|
|
101
|
+
return config.tests.some(pattern => glob(pattern).test(path))
|
|
102
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
+
import { parseArgs } from 'node:util'
|
|
6
|
+
import { parseConfig, selected } from './config.js'
|
|
7
|
+
import type { Config } from './config.js'
|
|
8
|
+
import { initBaseline, lowerBaseline, makeReport, sarif, textReport, validateBaseline, validateResponse } from './report.js'
|
|
9
|
+
import type { Baseline, Finding } from './report.js'
|
|
10
|
+
import { ruleById, ruleset } from './rules.js'
|
|
11
|
+
|
|
12
|
+
const help = `sentori lint [paths...] [options]
|
|
13
|
+
Swift and Kotlin syntax checks (preview 0.3.0).
|
|
14
|
+
--config FILE default: <git-root>/sentori-lint.toml
|
|
15
|
+
--swift-analyzer FILE executable (or SENTORI_LINT_SWIFT; default sentori-lint-swift)
|
|
16
|
+
--kotlin-analyzer FILE executable (or SENTORI_LINT_KOTLIN; default sentori-lint-kotlin)
|
|
17
|
+
--format text|json|sarif
|
|
18
|
+
--baseline FILE read a versioned rule/file-count baseline
|
|
19
|
+
--init-baseline initialize FILE, only if it does not exist
|
|
20
|
+
--update-baseline lower FILE after a successful full scan
|
|
21
|
+
--changed REF scan git diff REF plus untracked files; never update baselines
|
|
22
|
+
--fix run toolchain swift format, then reanalyze
|
|
23
|
+
--explain RULE print rule metadata
|
|
24
|
+
--version print lint protocol/ruleset version
|
|
25
|
+
Exit codes: 0 passed, 1 rule regressions, 2 configuration/analyzer/tool failure.
|
|
26
|
+
Configured tests participate only in function-length checks; generated exclusions apply to all rules.
|
|
27
|
+
`
|
|
28
|
+
function command(executable: string, args: string[], cwd: string, input?: string): string {
|
|
29
|
+
const result = spawnSync(executable, args, { cwd, input, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 120_000 })
|
|
30
|
+
if (result.error || result.status !== 0) {
|
|
31
|
+
throw Error(`${executable} failed: ${result.error?.message ?? result.stderr.trim() ?? result.signal ?? result.status}`)
|
|
32
|
+
}
|
|
33
|
+
return result.stdout
|
|
34
|
+
}
|
|
35
|
+
function git(root: string, args: string[]): string { return command('git', ['--literal-pathspecs', '-C', root, ...args], root) }
|
|
36
|
+
function records(value: string): string[] { return value.split('\0').filter(Boolean) }
|
|
37
|
+
export function discoverFiles(root: string, config: Config, paths: string[] = [], changed?: string): string[] {
|
|
38
|
+
const all = records(git(root, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']))
|
|
39
|
+
const changedPaths = changed === undefined ? undefined : new Set([
|
|
40
|
+
...records(git(root, ['diff', '--name-only', '-z', '--diff-filter=ACMR', changed, '--'])),
|
|
41
|
+
...records(git(root, ['ls-files', '--others', '--exclude-standard', '-z'])),
|
|
42
|
+
])
|
|
43
|
+
const prefixes = paths.map(p => {
|
|
44
|
+
const path = relative(root, resolve(process.cwd(), p)).split(sep).join('/')
|
|
45
|
+
if (path === '..' || path.startsWith('../') || isAbsolute(path)) throw Error(`path outside repository: ${p}`)
|
|
46
|
+
if (!existsSync(resolve(root, path))) throw Error(`path does not exist: ${p}`)
|
|
47
|
+
return path
|
|
48
|
+
})
|
|
49
|
+
return [...new Set(all)].filter(path => {
|
|
50
|
+
if (!/\.(swift|kt)$/.test(path) || !selected(path, config)) return false
|
|
51
|
+
if (changedPaths && !changedPaths.has(path)) return false
|
|
52
|
+
if (prefixes.length && !prefixes.some(p => !p || path === p || path.startsWith(p + '/'))) return false
|
|
53
|
+
const fullPath = resolve(root, path)
|
|
54
|
+
try { lstatSync(fullPath) } catch (error) {
|
|
55
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false // tracked deletion
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
const rel = relative(realpathSync(root), realpathSync(fullPath))
|
|
59
|
+
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel) || lstatSync(fullPath).isSymbolicLink()) throw Error(`refusing source symlink/outside file: ${path}`)
|
|
60
|
+
if (!lstatSync(fullPath).isFile()) throw Error(`not a regular source file: ${path}`)
|
|
61
|
+
return true
|
|
62
|
+
}).sort()
|
|
63
|
+
}
|
|
64
|
+
function analyze(root: string, executable: string, files: string[], language: 'swift' | 'kotlin'): Finding[] {
|
|
65
|
+
if (!files.length) return []
|
|
66
|
+
const output = command(executable, [], root, JSON.stringify({ version: 1, files }))
|
|
67
|
+
return validateResponse(JSON.parse(output), files, language)
|
|
68
|
+
}
|
|
69
|
+
function writeBaseline(path: string, baseline: Baseline, original?: string): void {
|
|
70
|
+
const contents = JSON.stringify(baseline, null, 2) + '\n'
|
|
71
|
+
const lock = `${path}.lock`
|
|
72
|
+
const lockFd = openSync(lock, 'wx')
|
|
73
|
+
const temporary = `${path}.${randomUUID()}.tmp`
|
|
74
|
+
try {
|
|
75
|
+
writeFileSync(lockFd, JSON.stringify({ pid: process.pid }) + '\n')
|
|
76
|
+
const fd = openSync(temporary, 'wx')
|
|
77
|
+
try { writeFileSync(fd, contents); fsyncSync(fd) } finally { closeSync(fd) }
|
|
78
|
+
if (original === undefined) {
|
|
79
|
+
// Atomic create without replacement: interruption cannot leave half JSON.
|
|
80
|
+
linkSync(temporary, path)
|
|
81
|
+
} else {
|
|
82
|
+
if (lstatSync(path).isSymbolicLink() || readFileSync(path, 'utf8') !== original) throw Error('baseline changed during scan or is a symlink; retry')
|
|
83
|
+
renameSync(temporary, path)
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
if (existsSync(temporary)) unlinkSync(temporary)
|
|
87
|
+
closeSync(lockFd)
|
|
88
|
+
unlinkSync(lock)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export async function cmdLint(argv: string[]): Promise<number> {
|
|
92
|
+
try {
|
|
93
|
+
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
|
|
94
|
+
config: { type: 'string' }, 'swift-analyzer': { type: 'string' }, 'kotlin-analyzer': { type: 'string' }, format: { type: 'string', default: 'text' },
|
|
95
|
+
baseline: { type: 'string' }, 'init-baseline': { type: 'boolean' }, 'update-baseline': { type: 'boolean' },
|
|
96
|
+
changed: { type: 'string' }, fix: { type: 'boolean' }, explain: { type: 'string' },
|
|
97
|
+
version: { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
|
|
98
|
+
} })
|
|
99
|
+
if (values.help) { console.log(help); return 0 }
|
|
100
|
+
if (values.version) { console.log(`sentori lint preview 0.3.0 (${ruleset})`); return 0 }
|
|
101
|
+
if (values.explain) {
|
|
102
|
+
const rule = ruleById.get(values.explain)
|
|
103
|
+
if (!rule) throw Error(`unknown rule ${values.explain}`)
|
|
104
|
+
console.log(JSON.stringify(rule, null, 2)); return 0
|
|
105
|
+
}
|
|
106
|
+
if (!['text', 'json', 'sarif'].includes(values.format!)) throw Error('format must be text, json, or sarif')
|
|
107
|
+
const initialize = values['init-baseline'], update = values['update-baseline']
|
|
108
|
+
if (initialize && update) throw Error('choose init-baseline or update-baseline')
|
|
109
|
+
if ((initialize || update) && !values.baseline) throw Error('--baseline FILE is required')
|
|
110
|
+
if ((initialize || update) && (positionals.length || values.changed !== undefined)) throw Error('baseline writes require a full scan without paths or --changed')
|
|
111
|
+
if (values.changed?.startsWith('-')) throw Error('--changed requires a git revision, not an option')
|
|
112
|
+
const root = command('git', ['rev-parse', '--show-toplevel'], process.cwd()).replace(/\r?\n$/, '')
|
|
113
|
+
const configPath = values.config ? resolve(values.config) : resolve(root, 'sentori-lint.toml')
|
|
114
|
+
const configSource = readFileSync(configPath, 'utf8')
|
|
115
|
+
const config = parseConfig(configSource)
|
|
116
|
+
const baselinePath = values.baseline ? resolve(values.baseline) : undefined
|
|
117
|
+
if (initialize && existsSync(baselinePath!)) throw Error('baseline already exists; initialization never overwrites it')
|
|
118
|
+
const original = baselinePath && !initialize ? readFileSync(baselinePath, 'utf8') : undefined
|
|
119
|
+
const baseline = original === undefined ? undefined : validateBaseline(JSON.parse(original), config)
|
|
120
|
+
const files = discoverFiles(root, config, positionals, values.changed)
|
|
121
|
+
if (!files.length && values.changed === undefined) throw Error('no Swift or Kotlin source files selected; check scope/exclusions')
|
|
122
|
+
const swiftFiles = files.filter(path => path.endsWith('.swift'))
|
|
123
|
+
const kotlinFiles = files.filter(path => path.endsWith('.kt'))
|
|
124
|
+
if (values.fix && kotlinFiles.length) throw Error('--fix supports Swift only; select Swift paths for formatting')
|
|
125
|
+
const analyzerEnv = { swift: process.env.SENTORI_LINT_SWIFT, kotlin: process.env.SENTORI_LINT_KOTLIN }
|
|
126
|
+
const runAnalyzers = (): Finding[] => (['swift', 'kotlin'] as const).flatMap(language => {
|
|
127
|
+
const requested = values[`${language}-analyzer`] ?? analyzerEnv[language] ?? `sentori-lint-${language}`
|
|
128
|
+
const executable = requested.includes('/') || requested.includes('\\') ? resolve(requested) : requested
|
|
129
|
+
return analyze(root, executable, language === 'swift' ? swiftFiles : kotlinFiles, language)
|
|
130
|
+
})
|
|
131
|
+
const sourceDigest = () => files.map(path => createHash('sha256').update(readFileSync(resolve(root, path))).digest('hex')).join(':')
|
|
132
|
+
let snapshot = initialize || update ? sourceDigest() : undefined
|
|
133
|
+
let findings = runAnalyzers()
|
|
134
|
+
if (values.fix) {
|
|
135
|
+
// Validate all sources before touching any. Formatting is the only fix;
|
|
136
|
+
// the analyzer never rewrites forced operations or concurrency semantics.
|
|
137
|
+
for (let i = 0; i < files.length; i += 50) command('swift', ['format', 'format', '--in-place', '--', ...files.slice(i, i + 50)], root)
|
|
138
|
+
snapshot = initialize || update ? sourceDigest() : undefined
|
|
139
|
+
findings = runAnalyzers()
|
|
140
|
+
}
|
|
141
|
+
let report = makeReport(files, findings, config, baseline, !positionals.length && values.changed === undefined)
|
|
142
|
+
const cannotWrite = (initialize && report.regressions.some(r => r.kind === 'hard-limit' || ruleById.get(r.rule)!.allowlistOnly))
|
|
143
|
+
|| (update && report.regressions.length > 0)
|
|
144
|
+
if (cannotWrite) console.error('baseline unchanged: resolve regressions/hard limits or add reviewed allowances first')
|
|
145
|
+
if ((initialize || update) && !cannotWrite) {
|
|
146
|
+
if (readFileSync(configPath, 'utf8') !== configSource || JSON.stringify(discoverFiles(root, config)) !== JSON.stringify(files) || sourceDigest() !== snapshot) throw Error('source selection or contents changed during scan; baseline unchanged, retry')
|
|
147
|
+
}
|
|
148
|
+
if (initialize && !cannotWrite) {
|
|
149
|
+
const created = initBaseline(report, config)
|
|
150
|
+
writeBaseline(baselinePath!, created)
|
|
151
|
+
report = makeReport(files, findings, config, created)
|
|
152
|
+
} else if (update && !cannotWrite) {
|
|
153
|
+
const lowered = lowerBaseline(report, baseline!)
|
|
154
|
+
writeBaseline(baselinePath!, lowered, original)
|
|
155
|
+
report = makeReport(files, findings, config, lowered)
|
|
156
|
+
}
|
|
157
|
+
console.log(values.format === 'text' ? textReport(report) : JSON.stringify(values.format === 'sarif' ? sarif(report) : report, null, 2))
|
|
158
|
+
return report.regressions.length ? 1 : 0
|
|
159
|
+
} catch (error) {
|
|
160
|
+
console.error(`sentori lint: ${(error as Error).message}`)
|
|
161
|
+
return 2
|
|
162
|
+
}
|
|
163
|
+
}
|