@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.
@@ -0,0 +1,129 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { glob, isTest } from './config.js'
3
+ import type { Config } from './config.js'
4
+ import { ruleById, rules, ruleset, swiftRuleset, kotlinRuleset, ruleLanguage, fileLanguage } from './rules.js'
5
+ import type { Level } from './rules.js'
6
+ export type Finding = { rule: string; path: string; line: number; column: number; measured?: number }
7
+ export type Result = Finding & { level: Level; message: string; allowance?: string; hardLimitExceeded?: boolean }
8
+ export type Baseline = { version: 1; ruleset: string; scope: string; counts: Record<string, Record<string, number>> }
9
+ export type Regression = { path: string; rule: string; count: number; allowed: number; kind: 'count' | 'hard-limit' }
10
+ export type Report = {
11
+ version: 1; ruleset: string; language: 'swift' | 'kotlin' | 'mixed'; fullScan: boolean; files: string[]; results: Result[]
12
+ regressions: Regression[]; baselineCanDecrease: boolean
13
+ }
14
+ export function scope(config: Config): string {
15
+ // A different policy is a migration, not an improvement. This also prevents
16
+ // disabling/excluding rules or files from silently erasing baseline debt.
17
+ return createHash('sha256').update(JSON.stringify({ ...config, rules: Object.fromEntries(Object.entries(config.rules).sort()) })).digest('hex')
18
+ }
19
+ export function validateBaseline(value: unknown, config: Config): Baseline {
20
+ const b = value as Baseline
21
+ if (!b || b.version !== 1 || b.ruleset !== ruleset || b.scope !== scope(config) || !b.counts || typeof b.counts !== 'object' || Array.isArray(b.counts)) {
22
+ throw Error('baseline version/ruleset/scope mismatch; policy changes require an explicit baseline migration')
23
+ }
24
+ for (const [path, counts] of Object.entries(b.counts)) {
25
+ if (!path || path.startsWith('/') || path.split('/').includes('..') || !/\.(swift|kt)$/.test(path) || !counts || typeof counts !== 'object' || Array.isArray(counts)) throw Error('invalid baseline path/counts')
26
+ for (const [id, count] of Object.entries(counts)) {
27
+ if (!ruleById.has(id) || ruleLanguage(id) !== fileLanguage(path) || ruleById.get(id)!.allowlistOnly || !Number.isSafeInteger(count) || count < 0) throw Error(`invalid baseline count for ${path}: ${id}`)
28
+ }
29
+ }
30
+ return b
31
+ }
32
+ export function validateResponse(value: unknown, files: string[], language: 'swift' | 'kotlin' = 'swift'): Finding[] {
33
+ const response = value as { version: number; ruleset: string; files: string[]; diagnostics: Finding[] }
34
+ if (!response || response.version !== 1 || response.ruleset !== (language === 'swift' ? swiftRuleset : kotlinRuleset) || !Array.isArray(response.files)
35
+ || JSON.stringify([...response.files].sort()) !== JSON.stringify([...files].sort()) || !Array.isArray(response.diagnostics)) throw Error('incomplete or incompatible analyzer response')
36
+ const paths = new Set(files), seen = new Set<string>()
37
+ for (const d of response.diagnostics) {
38
+ if (!d || !ruleById.has(d.rule) || ruleLanguage(d.rule) !== language || fileLanguage(d.path) !== language || !paths.has(d.path) || !Number.isSafeInteger(d.line) || d.line < 1 || !Number.isSafeInteger(d.column) || d.column < 1) throw Error('invalid analyzer diagnostic')
39
+ const thresholds = ruleById.get(d.rule)!.thresholds
40
+ if (thresholds && (!Number.isSafeInteger(d.measured) || d.measured! <= thresholds.target)) throw Error('missing or invalid analyzer measurement')
41
+ if (!thresholds && d.measured !== undefined) throw Error('unexpected analyzer measurement')
42
+ const key = JSON.stringify([d.rule, d.path, d.line, d.column])
43
+ if (seen.has(key)) throw Error('duplicate analyzer diagnostic')
44
+ seen.add(key)
45
+ }
46
+ return response.diagnostics
47
+ }
48
+ export function makeReport(files: string[], findings: Finding[], config: Config, baseline?: Baseline, fullScan = true): Report {
49
+ const results = findings.filter(d => !isTest(d.path, config) || ruleById.get(d.rule)!.tests).map(d => {
50
+ const rule = ruleById.get(d.rule)!
51
+ const allowance = config.allow.find(a => a.rule === d.rule && glob(a.path).test(d.path))?.reason
52
+ const hardLimitExceeded = rule.thresholds?.hard !== undefined && d.measured! > rule.thresholds.hard
53
+ const configured = config.rules[d.rule] ?? rule.level
54
+ const level: Level = configured === 'off' ? 'off' : hardLimitExceeded ? 'error' : configured
55
+ const measured = rule.thresholds ? ` Measured ${d.measured} ${rule.thresholds.unit}; target ${rule.thresholds.target}${rule.thresholds.hard !== undefined ? `; hard limit ${rule.thresholds.hard}` : ''}.` : ''
56
+ return { ...d, level, message: rule.message + measured, ...(hardLimitExceeded ? { hardLimitExceeded: true } : {}), ...(allowance ? { allowance } : {}) }
57
+ }).filter(d => d.level !== 'off').sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.column - b.column || a.rule.localeCompare(b.rule))
58
+ const counts = countResults(results)
59
+ const regressions: Regression[] = []
60
+ for (const [path, byRule] of Object.entries(counts)) {
61
+ for (const [rule, count] of Object.entries(byRule)) {
62
+ const allowed = ruleById.get(rule)!.allowlistOnly ? 0 : baseline?.counts[path]?.[rule] ?? 0
63
+ const hard = results.some(d => d.path === path && d.rule === rule && d.hardLimitExceeded && !d.allowance)
64
+ if (hard || count > allowed) regressions.push({ path, rule, count, allowed, kind: hard ? 'hard-limit' : 'count' })
65
+ }
66
+ }
67
+ const scanned = new Set(files)
68
+ const baselineCanDecrease = !!baseline && Object.entries(baseline.counts).some(([path, byRule]) => (fullScan || scanned.has(path)) && Object.entries(byRule).some(([rule, count]) => (counts[path]?.[rule] ?? 0) < count))
69
+ return { version: 1, ruleset, language: files.every(p => p.endsWith('.swift')) ? 'swift' : files.every(p => p.endsWith('.kt')) ? 'kotlin' : 'mixed', fullScan, files, results, regressions, baselineCanDecrease }
70
+ }
71
+ export function countResults(results: Result[]): Record<string, Record<string, number>> {
72
+ const counts: Record<string, Record<string, number>> = Object.create(null)
73
+ for (const d of results) {
74
+ if (d.allowance || d.level !== 'error') continue
75
+ const byRule = counts[d.path] ??= Object.create(null) as Record<string, number>
76
+ byRule[d.rule] = (byRule[d.rule] ?? 0) + 1
77
+ }
78
+ return counts
79
+ }
80
+ export function initBaseline(report: Report, config: Config): Baseline {
81
+ if (!report.fullScan) throw Error('cannot initialize baseline from a partial scan')
82
+ if (report.regressions.some(r => r.kind === 'hard-limit' || ruleById.get(r.rule)!.allowlistOnly)) throw Error('hard limits and concurrency escape hatches require an allowance; they cannot enter a baseline')
83
+ return { version: 1, ruleset, scope: scope(config), counts: countResults(report.results) }
84
+ }
85
+ export function lowerBaseline(report: Report, baseline: Baseline): Baseline {
86
+ if (!report.fullScan) throw Error('cannot update baseline from a partial scan')
87
+ if (report.regressions.length) throw Error('cannot update baseline while violations exceed it')
88
+ const current = countResults(report.results)
89
+ const counts: Baseline['counts'] = Object.create(null)
90
+ for (const [path, byRule] of Object.entries(baseline.counts)) {
91
+ for (const [id, previous] of Object.entries(byRule)) {
92
+ const count = Math.min(previous, current[path]?.[id] ?? 0)
93
+ if (count) (counts[path] ??= Object.create(null) as Record<string, number>)[id] = count
94
+ }
95
+ }
96
+ return { ...baseline, counts }
97
+ }
98
+ export function sarif(report: Report): unknown {
99
+ return {
100
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json', version: '2.1.0',
101
+ runs: [{
102
+ tool: { driver: { name: 'sentori-lint', semanticVersion: '0.3.0', rules: rules.map(r => ({
103
+ id: r.id, name: r.name, shortDescription: { text: r.message }, fullDescription: { text: r.rationale },
104
+ helpUri: r.reference, defaultConfiguration: { level: r.level === 'warn' ? 'warning' : 'error' },
105
+ properties: { group: r.group, basis: r.basis, section: r.section, fixable: r.fixable, allowlistOnly: r.allowlistOnly, tests: !!r.tests, ...(r.thresholds ? { thresholds: r.thresholds } : {}) },
106
+ })) } },
107
+ columnKind: 'utf16CodeUnits',
108
+ results: report.results.map(d => ({
109
+ ruleId: d.rule, ruleIndex: rules.findIndex(r => r.id === d.rule),
110
+ level: d.level === 'warn' ? 'warning' : 'error', message: { text: d.message },
111
+ locations: [{ physicalLocation: {
112
+ artifactLocation: { uri: d.path.split('/').map(encodeURIComponent).join('/') },
113
+ region: { startLine: d.line, startColumn: d.column },
114
+ } }],
115
+ ...(d.measured !== undefined ? { properties: { measured: d.measured, hardLimitExceeded: !!d.hardLimitExceeded } } : {}),
116
+ ...(d.allowance ? { suppressions: [{ kind: 'external', justification: d.allowance, status: 'accepted' }] } : {}),
117
+ })),
118
+ properties: { language: report.language, fullScan: report.fullScan, filesScanned: report.files.length, regressions: report.regressions, baselineCanDecrease: report.baselineCanDecrease },
119
+ invocations: [{ executionSuccessful: true }],
120
+ }],
121
+ }
122
+ }
123
+ export function textReport(report: Report): string {
124
+ const lines = report.results.map(d => `${d.path}:${d.line}:${d.column}: ${d.level} ${d.rule} ${d.message}${d.allowance ? ` [allowed: ${d.allowance}]` : ''}`)
125
+ for (const r of report.regressions) lines.push(r.kind === 'hard-limit' ? `hard limit exceeded: ${r.path} ${r.rule}` : `regression: ${r.path} ${r.rule}: ${r.count} > ${r.allowed}`)
126
+ lines.push(`${report.language}: ${report.files.length} files, ${report.results.length} findings, ${report.regressions.length} failing rule/file groups.`)
127
+ if (report.baselineCanDecrease) lines.push('Baseline can decrease; run a full scan with --update-baseline.')
128
+ return lines.join('\n')
129
+ }
@@ -0,0 +1,64 @@
1
+ export type Level = 'off' | 'warn' | 'error'
2
+ export type Rule = {
3
+ id: string; name: string; message: string; level: Level; allowlistOnly: boolean
4
+ group: 'safety' | 'concurrency' | 'maintainability' | 'api'
5
+ tests?: boolean
6
+ thresholds?: { target: number; hard?: number; unit: 'lines' | 'parameters' }
7
+ basis: 'project-policy'; reference: string; section: string; rationale: string; fixable: false
8
+ }
9
+ const concurrency = 'https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/incrementaladoption/'
10
+ const swiftRules: Rule[] = [
11
+ { id: 'SWSAFE001', name: 'forced-try', message: 'Replace try! with explicit error handling.', level: 'error', allowlistOnly: false,
12
+ 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.' },
13
+ { id: 'SWSAFE002', name: 'forced-cast', message: 'Replace as! with a checked cast.', level: 'error', allowlistOnly: false,
14
+ reference: 'https://docs.swift.org/swift-book/documentation/the-swift-programming-language/typecasting/', section: 'Downcasting', rationale: 'A failed forced downcast terminates the process.' },
15
+ { id: 'SWCON001', name: 'unsafe-nonisolation', message: 'nonisolated(unsafe) requires a configured allowance with a reason.', level: 'error', allowlistOnly: true,
16
+ reference: concurrency, section: 'Incremental Adoption', rationale: 'This opts out of compiler isolation checking; document the safety argument.' },
17
+ { id: 'SWCON002', name: 'unchecked-sendable', message: '@unchecked Sendable requires a configured allowance with a reason.', level: 'error', allowlistOnly: true,
18
+ reference: concurrency, section: 'Incremental Adoption', rationale: 'The programmer assumes responsibility for thread safety.' },
19
+ { id: 'SWCON003', name: 'detached-task', message: 'Task.detached requires a configured allowance with a reason.', level: 'error', allowlistOnly: true,
20
+ 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.' },
21
+ { id: 'SWMAINT001', name: 'function-length', message: 'Function exceeds the target length.', level: 'error', allowlistOnly: false, tests: true,
22
+ thresholds: { target: 50, hard: 200, unit: 'lines' as const },
23
+ 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.' },
24
+ { id: 'SWMAINT002', name: 'file-length', message: 'File exceeds the target length.', level: 'error', allowlistOnly: false,
25
+ thresholds: { target: 300, hard: 500, unit: 'lines' as const },
26
+ 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.' },
27
+ { id: 'SWSAFE003', name: 'forced-unwrap', message: 'Review this forced unwrap; use checked optional handling.', level: 'error', allowlistOnly: false,
28
+ 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!.' },
29
+ { id: 'SWAPI001', name: 'parameter-count', message: 'Declaration exceeds the parameter-count target.', level: 'warn', allowlistOnly: false,
30
+ thresholds: { target: 5, unit: 'parameters' as const },
31
+ 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.' },
32
+ { id: 'SWAPI002', name: 'bool-parameter', message: 'Review this Bool parameter; consider a named option type when it represents a choice.', level: 'warn', allowlistOnly: false,
33
+ 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.' },
34
+ ].map((r): Rule => ({ ...r, group: r.id.startsWith('SWCON') ? 'concurrency' : r.id.startsWith('SWMAINT') ? 'maintainability' : r.id.startsWith('SWAPI') ? 'api' : 'safety', level: r.level as Level, basis: 'project-policy', fixable: false }))
35
+ export const swiftRuleset = 'swift-syntax-604.0.0/swift-quality-v2.1'
36
+ export const kotlinRuleset = 'kotlin-2.4.21/kotlin-quality-v1'
37
+ const kotlinReference = 'https://kotlinlang.org/docs/coding-conventions.html'
38
+ const kotlinRules: Rule[] = [
39
+ { id: 'KTSAFE001', name: 'not-null-assertion', message: 'Replace !! with checked nullable handling.', group: 'safety', level: 'error', allowlistOnly: false,
40
+ 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.' },
41
+ { id: 'KTCON001', name: 'global-scope', message: 'GlobalScope requires a reviewed allowance.', group: 'concurrency', level: 'error', allowlistOnly: true,
42
+ 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.' },
43
+ { id: 'KTCON002', name: 'run-blocking', message: 'runBlocking in main sources requires a reviewed allowance.', group: 'concurrency', level: 'error', allowlistOnly: true,
44
+ 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.' },
45
+ { id: 'KTMAINT001', name: 'kotlin-function-length', message: 'Function exceeds the target length.', group: 'maintainability', level: 'error', allowlistOnly: false, tests: true,
46
+ thresholds: { target: 50, hard: 200, unit: 'lines' as const }, 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.' },
47
+ { id: 'KTMAINT002', name: 'kotlin-file-length', message: 'File exceeds the target length.', group: 'maintainability', level: 'error', allowlistOnly: false,
48
+ thresholds: { target: 300, hard: 500, unit: 'lines' as const }, 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.' },
49
+ { id: 'KTAPI001', name: 'kotlin-parameter-count', message: 'Declaration exceeds the parameter-count target.', group: 'api', level: 'warn', allowlistOnly: false,
50
+ thresholds: { target: 5, unit: 'parameters' as const }, 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.' },
51
+ { id: 'KTAPI002', name: 'boolean-parameter', message: 'Review this Boolean parameter for call-site clarity.', group: 'api', level: 'warn', allowlistOnly: false,
52
+ reference: kotlinReference, section: 'Named arguments', rationale: 'Project policy: explicit Boolean and kotlin.Boolean parameters, excluding nullable and collection types, aliases and lambdas.' },
53
+ { id: 'AKS001', name: 'wildcard-import', message: 'Use explicit imports.', group: 'maintainability', level: 'warn', allowlistOnly: false,
54
+ reference: 'https://developer.android.com/kotlin/style-guide', section: 'Import statements', rationale: 'Android Kotlin style disallows wildcard imports.' },
55
+ { id: 'AKS002', name: 'control-body-braces', message: 'Use braces around this control body.', group: 'maintainability', level: 'warn', allowlistOnly: false,
56
+ 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.' },
57
+ { id: 'AKS003', name: 'constant-name', message: 'Name constants in UPPER_SNAKE_CASE.', group: 'maintainability', level: 'warn', allowlistOnly: false,
58
+ reference: 'https://developer.android.com/kotlin/style-guide', section: 'Constant names', rationale: 'Checks ASCII const val names. Non-ASCII identifiers are left for review.' },
59
+ ].map((r): Rule => ({ ...r, group: r.group as Rule['group'], level: r.level as Level, basis: 'project-policy', fixable: false }))
60
+ export const rules = [...swiftRules, ...kotlinRules]
61
+ export const ruleById = new Map(rules.map(r => [r.id, r]))
62
+ export const ruleset = `${swiftRuleset}+${kotlinRuleset}`
63
+ export function ruleLanguage(id: string): 'swift' | 'kotlin' { return id.startsWith('SW') ? 'swift' : 'kotlin' }
64
+ export function fileLanguage(path: string): 'swift' | 'kotlin' { return path.endsWith('.swift') ? 'swift' : 'kotlin' }