@goliapkg/sentori-cli 1.8.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 +65 -8
- package/examples/insight-lint.toml +32 -0
- package/lib/artifacts.d.ts +7 -0
- package/lib/artifacts.d.ts.map +1 -1
- package/lib/artifacts.js +1 -1
- package/lib/artifacts.js.map +1 -1
- package/lib/compatibility.js +1 -1
- package/lib/compatibility.js.map +1 -1
- package/lib/index.js +13 -5
- package/lib/index.js.map +1 -1
- package/lib/issue.d.ts +13 -2
- package/lib/issue.d.ts.map +1 -1
- package/lib/issue.js +12 -1
- package/lib/issue.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/lib/mcp.d.ts +6 -0
- package/lib/mcp.d.ts.map +1 -1
- package/lib/mcp.js +228 -65
- package/lib/mcp.js.map +1 -1
- package/lib/workflow.d.ts.map +1 -1
- package/lib/workflow.js +12 -2
- package/lib/workflow.js.map +1 -1
- package/package.json +4 -2
- package/src/__tests__/issue.test.ts +27 -0
- package/src/__tests__/lint.test.ts +185 -0
- package/src/__tests__/mcp.test.ts +128 -1
- package/src/__tests__/workflow.test.ts +41 -1
- package/src/artifacts.ts +3 -1
- package/src/compatibility.ts +1 -1
- package/src/index.ts +12 -5
- package/src/issue.ts +16 -5
- 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
- package/src/mcp.ts +205 -67
- package/src/workflow.ts +7 -2
|
@@ -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
|
+
}
|
|
@@ -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' }
|