@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,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
|
+
})
|
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
import { describe, expect, test } from 'bun:test'
|
|
4
4
|
|
|
5
5
|
import { buildTools } from '../mcp.js'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
6
7
|
|
|
7
8
|
describe('mcp tools', () => {
|
|
8
9
|
const tools = buildTools()
|
|
9
10
|
|
|
10
|
-
test('
|
|
11
|
+
test('the closed loop includes durable watch', () => {
|
|
11
12
|
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
13
|
+
'sentori_issue_attachment',
|
|
12
14
|
'sentori_issue_bundle',
|
|
13
15
|
'sentori_issue_list',
|
|
14
16
|
'sentori_issue_note',
|
|
17
|
+
'sentori_issue_read',
|
|
15
18
|
'sentori_issue_resolve',
|
|
19
|
+
'sentori_issue_watch',
|
|
16
20
|
])
|
|
17
21
|
})
|
|
18
22
|
|
|
@@ -30,3 +34,126 @@ describe('mcp tools', () => {
|
|
|
30
34
|
}
|
|
31
35
|
})
|
|
32
36
|
})
|
|
37
|
+
|
|
38
|
+
test('watch and evidence use the project API with credentials and lossless cursors', async () => {
|
|
39
|
+
const requests: { url: URL; auth: string | null }[] = []
|
|
40
|
+
const server = Bun.serve({
|
|
41
|
+
port: 0,
|
|
42
|
+
fetch(req) {
|
|
43
|
+
const url = new URL(req.url)
|
|
44
|
+
requests.push({ url, auth: req.headers.get('authorization') })
|
|
45
|
+
if (url.searchParams.get('format') === 'json') return Response.json({ schemaVersion: 1, evidence: { missing: ['stack'] } })
|
|
46
|
+
if (url.pathname.endsWith('/bundle')) return new Response('# Evidence')
|
|
47
|
+
return Response.json({ changes: [], nextCursor: 'v1:test:9223372036854775807', hasMore: false })
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
try {
|
|
51
|
+
const tools = buildTools()
|
|
52
|
+
const ctx = { apiUrl: server.url.toString(), token: 'test-only' }
|
|
53
|
+
const watch = tools.find((t) => t.name === 'sentori_issue_watch')!
|
|
54
|
+
const bundle = tools.find((t) => t.name === 'sentori_issue_bundle')!
|
|
55
|
+
const cursor = 'v1:test:9223372036854775807'
|
|
56
|
+
const result = await watch.handler({ cursor, limit: 17, waitSeconds: 25 }, ctx)
|
|
57
|
+
expect(result).toEqual({ changes: [], nextCursor: cursor, hasMore: false })
|
|
58
|
+
expect(requests[0].url.pathname).toBe('/api/issues/watch')
|
|
59
|
+
expect(requests[0].url.searchParams.get('cursor')).toBe(cursor)
|
|
60
|
+
expect(requests[0].url.searchParams.get('waitSeconds')).toBe('25')
|
|
61
|
+
expect(requests[0].auth).toBe('Bearer test-only')
|
|
62
|
+
await bundle.handler({ issueId: 'a/b', format: 'json' }, ctx)
|
|
63
|
+
expect(requests[1].url.pathname).toBe('/api/issues/a%2Fb/bundle')
|
|
64
|
+
expect(requests[1].url.searchParams.get('format')).toBe('json')
|
|
65
|
+
expect(await bundle.handler({ issueId: 'test' }, ctx)).toBe('# Evidence')
|
|
66
|
+
for (const args of [{ waitSeconds: 26 }, { limit: 0 }, { limit: 1.5 }]) {
|
|
67
|
+
await expect(watch.handler(args, ctx)).rejects.toThrow()
|
|
68
|
+
}
|
|
69
|
+
expect(requests).toHaveLength(3)
|
|
70
|
+
} finally { server.stop(true) }
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('stdio negotiates, stays responsive during watch, and separates protocol and tool errors', async () => {
|
|
74
|
+
const server = Bun.serve({
|
|
75
|
+
port: 0,
|
|
76
|
+
async fetch(req) {
|
|
77
|
+
if (new URL(req.url).pathname.endsWith('/attachments/test')) {
|
|
78
|
+
return new Response(Buffer.from('R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', 'base64'), { headers: { 'Content-Type': 'image/gif' } })
|
|
79
|
+
}
|
|
80
|
+
if (new URL(req.url).pathname.endsWith('/watch')) {
|
|
81
|
+
await Bun.sleep(200)
|
|
82
|
+
return Response.json({ schemaVersion: 1, changes: [], nextCursor: 'v1:test:0', hasMore: false })
|
|
83
|
+
}
|
|
84
|
+
return Response.json({ error: 'database_unavailable' }, { status: 503 })
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
const source = fileURLToPath(new URL('../mcp.ts', import.meta.url))
|
|
88
|
+
const requests = [
|
|
89
|
+
{ id: 0, method: 'initialize', params: { protocolVersion: '2025-11-25' } },
|
|
90
|
+
{ id: 1, method: 'tools/call', params: { name: 'sentori_issue_watch', arguments: { waitSeconds: 1 } } },
|
|
91
|
+
{ id: 2, method: 'ping' },
|
|
92
|
+
{ id: 3, method: 'tools/call', params: { name: 'sentori_issue_list' } },
|
|
93
|
+
{ id: 4, method: 'tools/call', params: { name: 'sentori_issue_watch', arguments: { waitSeconds: 26 } } },
|
|
94
|
+
{ id: 5, method: 'unknown' },
|
|
95
|
+
{ id: 6, method: 'tools/call', params: { name: 'missing' } },
|
|
96
|
+
{ id: 7, method: 'tools/list' },
|
|
97
|
+
{ id: 8, method: 'tools/call', params: { name: 'sentori_issue_attachment', arguments: { issueId: 'issue', attachmentRef: 'test' } } },
|
|
98
|
+
].map((r) => JSON.stringify({ jsonrpc: '2.0', ...r })).join('\n') + '\nnull\n{broken\n'
|
|
99
|
+
try {
|
|
100
|
+
const child = Bun.spawn([process.execPath, '-e', `import {runMcpServer} from ${JSON.stringify(source)}; await runMcpServer({apiUrl: ${JSON.stringify(server.url.toString())}, token: 'test-only'})`], {
|
|
101
|
+
stdin: new Blob([requests]), stdout: 'pipe', stderr: 'pipe',
|
|
102
|
+
})
|
|
103
|
+
const output = await new Response(child.stdout).text()
|
|
104
|
+
expect(await child.exited).toBe(0)
|
|
105
|
+
const responses = output.trim().split('\n').map((line) => JSON.parse(line))
|
|
106
|
+
const byId = (id: number) => responses.find((r) => r.id === id)
|
|
107
|
+
expect(byId(0).result.protocolVersion).toBe('2025-11-25')
|
|
108
|
+
expect(byId(1).result.structuredContent.nextCursor).toBe('v1:test:0')
|
|
109
|
+
expect(responses.indexOf(byId(2))).toBeLessThan(responses.indexOf(byId(1)))
|
|
110
|
+
expect(byId(3).result.isError).toBe(true)
|
|
111
|
+
expect(byId(3).result.structuredContent.error).toEqual({ code: 'database_unavailable', status: 503, retryable: true })
|
|
112
|
+
expect(byId(4).result.isError).toBe(true)
|
|
113
|
+
expect(byId(5).error.code).toBe(-32601)
|
|
114
|
+
expect(byId(6).error.code).toBe(-32602)
|
|
115
|
+
expect(byId(7).result.tools.find((t: { name: string }) => t.name === 'sentori_issue_watch').annotations.readOnlyHint).toBe(true)
|
|
116
|
+
expect(byId(8).result.content[0]).toEqual({ type: 'image', mimeType: 'image/gif', data: 'R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==' })
|
|
117
|
+
expect(responses.filter((r) => r.id === null).map((r) => r.error.code)).toEqual([-32600, -32700])
|
|
118
|
+
} finally { server.stop(true) }
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('attachment reads are bounded even without Content-Length', async () => {
|
|
122
|
+
const server = Bun.serve({
|
|
123
|
+
port: 0,
|
|
124
|
+
fetch() {
|
|
125
|
+
return new Response(new ReadableStream({
|
|
126
|
+
start(controller) {
|
|
127
|
+
controller.enqueue(new Uint8Array(1024 * 1024))
|
|
128
|
+
controller.enqueue(new Uint8Array(1))
|
|
129
|
+
controller.close()
|
|
130
|
+
},
|
|
131
|
+
}), { headers: { 'Content-Type': 'text/plain' } })
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
try {
|
|
135
|
+
const tool = buildTools().find((t) => t.name === 'sentori_issue_attachment')!
|
|
136
|
+
await expect(tool.handler({ issueId: 'i', attachmentRef: 'r' }, { apiUrl: server.url.toString(), token: 'test-only' })).rejects.toThrow('1 MiB')
|
|
137
|
+
} finally { server.stop(true) }
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('a cancelled watch releases the request without blocking subsequent calls', async () => {
|
|
141
|
+
const server = Bun.serve({ port: 0, fetch: () => new Promise<Response>(() => {}) })
|
|
142
|
+
const source = fileURLToPath(new URL('../mcp.ts', import.meta.url))
|
|
143
|
+
const input = [
|
|
144
|
+
{ id: 1, method: 'tools/call', params: { name: 'sentori_issue_watch', arguments: { waitSeconds: 25 } } },
|
|
145
|
+
{ method: 'notifications/cancelled', params: { requestId: 1, reason: 'test cancellation' } },
|
|
146
|
+
{ id: 2, method: 'ping' },
|
|
147
|
+
].map((r) => JSON.stringify({ jsonrpc: '2.0', ...r })).join('\n') + '\n'
|
|
148
|
+
try {
|
|
149
|
+
const child = Bun.spawn([process.execPath, '-e', `import {runMcpServer} from ${JSON.stringify(source)}; await runMcpServer({apiUrl: ${JSON.stringify(server.url.toString())}, token: 'test-only'})`], {
|
|
150
|
+
stdin: new Blob([input]), stdout: 'pipe', stderr: 'pipe',
|
|
151
|
+
})
|
|
152
|
+
const output = await new Response(child.stdout).text()
|
|
153
|
+
expect(await child.exited).toBe(0)
|
|
154
|
+
const responses = output.trim().split('\n').map((line) => JSON.parse(line))
|
|
155
|
+
expect(responses).toHaveLength(2)
|
|
156
|
+
expect(responses.find((r) => r.id === 1).result.isError).toBe(true)
|
|
157
|
+
expect(responses.find((r) => r.id === 2).result).toEqual({})
|
|
158
|
+
} finally { server.stop(true) }
|
|
159
|
+
})
|
|
@@ -42,7 +42,8 @@ describe('unified release upload', () => {
|
|
|
42
42
|
const bytes = gunzipSync(Buffer.from(await file.arrayBuffer()))
|
|
43
43
|
const kind = String(form.get('kind'))
|
|
44
44
|
if (kind === 'srcbundle') expect(inspectSourceArchive(bytes, 'app@1+7').files).toEqual(['src/a.ts'])
|
|
45
|
-
|
|
45
|
+
const contentHash = createHash('sha256').update(bytes).digest('hex')
|
|
46
|
+
uploaded.push({ kind, name: file.name.replace(/\.gz$/, ''), usable: true, contentHash, storageVerification: { integrity: 'verified', actualContentHash: contentHash, actualSizeBytes: bytes.length, usable: true } })
|
|
46
47
|
return reply({ id: String(uploaded.length), usable: true }, 201)
|
|
47
48
|
}
|
|
48
49
|
return reply({ release: 'app@1+7', known: uploaded.length > 0, artifacts: uploaded,
|
|
@@ -135,3 +136,42 @@ test('matching project is verified before artifact compatibility and upload', as
|
|
|
135
136
|
expect(calls[0]).toBe('https://upload.test/api/project')
|
|
136
137
|
expect(calls).toHaveLength(2)
|
|
137
138
|
})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
test('verification fails closed for stale metadata and corrupted or missing stored bytes', async () => {
|
|
142
|
+
writeFileSync(join(root, 'mapping.txt'), 'A -> a:\n')
|
|
143
|
+
const plan = prepareUploadPlan(config({ mapping: ['mapping.txt'] }))
|
|
144
|
+
const file = plan.files[0]!
|
|
145
|
+
const row = { kind: file.kind, name: file.name, contentHash: file.hash, usable: true }
|
|
146
|
+
for (const storageVerification of [undefined,
|
|
147
|
+
{ integrity: 'mismatch', actualContentHash: file.hash, actualSizeBytes: 0, usable: true },
|
|
148
|
+
{ integrity: 'unavailable' },
|
|
149
|
+
{ integrity: 'verified', actualContentHash: 'wrong', actualSizeBytes: file.bytes, usable: true },
|
|
150
|
+
]) {
|
|
151
|
+
globalThis.fetch = (async url => {
|
|
152
|
+
expect(String(url)).toEndWith('?verify=true')
|
|
153
|
+
return reply({ known: true, release: plan.release, artifacts: [{ ...row, storageVerification }] })
|
|
154
|
+
}) as typeof fetch
|
|
155
|
+
expect((await verifyUploadPlan(plan, 't'))[0]).toContain('stored bytes not verified')
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
test('strict upload preserves HTTP failure details even when verification also fails', async () => {
|
|
161
|
+
writeFileSync(join(root, 'mapping.txt'), 'A -> a:\n')
|
|
162
|
+
const path = config({ mapping: ['mapping.txt'] })
|
|
163
|
+
let posted = false
|
|
164
|
+
globalThis.fetch = (async (_url, init) => {
|
|
165
|
+
if (init?.method === 'POST') { posted = true; return reply({ error: 'upstream unavailable' }, 502) }
|
|
166
|
+
if (posted) return reply({ error: 'verification unavailable' }, 503)
|
|
167
|
+
return reply({ known: false, release: 'app@1+7', artifacts: [] })
|
|
168
|
+
}) as typeof fetch
|
|
169
|
+
const originalError = console.error, messages: string[] = []
|
|
170
|
+
console.error = (...args) => { messages.push(args.join(' ')) }
|
|
171
|
+
try { expect(await runWorkflow('upload', ['--config', path, '--strict'])).toBe(1) }
|
|
172
|
+
finally { console.error = originalError }
|
|
173
|
+
expect(messages.join('\n')).toContain('mapping.txt: 502')
|
|
174
|
+
expect(messages.join('\n')).toContain('upstream unavailable')
|
|
175
|
+
expect(messages.join('\n')).toContain('verification request failed: 503')
|
|
176
|
+
expect(messages.join('\n')).not.toContain('api-test')
|
|
177
|
+
})
|
package/src/artifacts.ts
CHANGED
|
@@ -31,6 +31,7 @@ export type ArtifactRow = {
|
|
|
31
31
|
* the check existed — "never looked at", not "looked at and
|
|
32
32
|
* fine". `false` means it stored and will symbolicate nothing. */
|
|
33
33
|
usable?: boolean | null
|
|
34
|
+
storageVerification?: { integrity: string; actualContentHash?: string; actualSizeBytes?: number; usable?: boolean | null }
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export type ArtifactsResponse = {
|
|
@@ -55,10 +56,11 @@ export async function fetchArtifacts(opts: {
|
|
|
55
56
|
apiUrl: string
|
|
56
57
|
release: string
|
|
57
58
|
token: string
|
|
59
|
+
verify?: boolean
|
|
58
60
|
}): Promise<ArtifactsResponse> {
|
|
59
61
|
const url =
|
|
60
62
|
`${opts.apiUrl.replace(/\/+$/, '')}/v1/releases/` +
|
|
61
|
-
`${encodeURIComponent(opts.release)}/artifacts`
|
|
63
|
+
`${encodeURIComponent(opts.release)}/artifacts${opts.verify ? "?verify=true" : ""}`
|
|
62
64
|
const resp = await fetch(url, {
|
|
63
65
|
headers: { Authorization: `Bearer ${opts.token}` },
|
|
64
66
|
})
|
package/src/compatibility.ts
CHANGED
|
@@ -9,7 +9,7 @@ export function checkCompatibility(result: ArtifactsResponse, sourceZip: boolean
|
|
|
9
9
|
const info = result.compatibility
|
|
10
10
|
if (info === undefined) {
|
|
11
11
|
if (sourceZip) throw new Error('This server does not advertise source ZIP support. No files were uploaded. Upgrade the Sentori server to a build advertising artifact protocol 1 and sourceBundleFormats: ["zip"], then run sentori doctor. Existing CLI 1.5 pipelines may continue using legacy JSON sources.')
|
|
12
|
-
return 'Legacy artifact API: server capabilities are not advertised. Source maps, dSYM and mapping uploads remain available; verification
|
|
12
|
+
return 'Legacy artifact API: server capabilities are not advertised. Source maps, dSYM and mapping uploads remain available; verification requires a server that supplies fresh storageVerification proof; inventory metadata alone cannot pass.'
|
|
13
13
|
}
|
|
14
14
|
if (!info || typeof info.serverVersion !== 'string' || info.artifactProtocol !== 1
|
|
15
15
|
|| !Array.isArray(info.sourceBundleFormats) || !Array.isArray(info.verification)
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { listApps, showProject } from './apps.js'
|
|
|
8
8
|
import {
|
|
9
9
|
fetchBundle,
|
|
10
10
|
formatIssueLine,
|
|
11
|
-
|
|
11
|
+
listIssuesPage,
|
|
12
12
|
noteIssue,
|
|
13
13
|
resolveIssue,
|
|
14
14
|
} from './issue.js'
|
|
@@ -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
|
|
@@ -58,7 +61,7 @@ CI triage (the same /api surface an AI agent uses):
|
|
|
58
61
|
sentori project show [--json] project selected by the API token
|
|
59
62
|
sentori app list [--json] apps within that project
|
|
60
63
|
sentori issue list [--status open] [--kind error] [--app <uuid>]
|
|
61
|
-
[--platform <platform>] [--environment <env>] [--release <release>] [--json]
|
|
64
|
+
[--platform <platform>] [--environment <env>] [--release <release>] [--limit 50] [--offset 0] [--json]
|
|
62
65
|
sentori issue resolve <issue-id> [--in-release <r>]
|
|
63
66
|
sentori issue note <issue-id> --body "fixed in abc123"
|
|
64
67
|
sentori issue bundle <issue-id>
|
|
@@ -466,7 +469,7 @@ async function cmdIssueList(argv: string[]): Promise<number> {
|
|
|
466
469
|
try {
|
|
467
470
|
parsed = parseArgs({
|
|
468
471
|
args: argv,
|
|
469
|
-
options: { ...ISSUE_OPTS, kind: { type: 'string' }, status: { type: 'string' }, app: { type: 'string' }, platform: { type: 'string' }, environment: { type: 'string' }, release: { type: 'string' }, json: { type: 'boolean' } },
|
|
472
|
+
options: { ...ISSUE_OPTS, kind: { type: 'string' }, status: { type: 'string' }, app: { type: 'string' }, platform: { type: 'string' }, environment: { type: 'string' }, release: { type: 'string' }, limit: { type: 'string' }, offset: { type: 'string' }, json: { type: 'boolean' } },
|
|
470
473
|
})
|
|
471
474
|
} catch (e) {
|
|
472
475
|
console.error(`error: ${(e as Error).message}\n${HELP}`)
|
|
@@ -479,15 +482,18 @@ async function cmdIssueList(argv: string[]): Promise<number> {
|
|
|
479
482
|
const cfg = parseApiCfg(parsed.values)
|
|
480
483
|
if (!cfg) return 2
|
|
481
484
|
try {
|
|
482
|
-
const
|
|
485
|
+
const page = await listIssuesPage(cfg, {
|
|
483
486
|
kind: parsed.values.kind as string | undefined,
|
|
484
487
|
status: (parsed.values.status as string | undefined) ?? 'open',
|
|
485
488
|
app: parsed.values.app,
|
|
486
489
|
platform: parsed.values.platform,
|
|
487
490
|
environment: parsed.values.environment,
|
|
488
491
|
release: parsed.values.release,
|
|
492
|
+
limit: parsed.values.limit === undefined ? undefined : Number(parsed.values.limit),
|
|
493
|
+
offset: parsed.values.offset === undefined ? undefined : Number(parsed.values.offset),
|
|
489
494
|
})
|
|
490
|
-
if (parsed.values.json) { console.log(JSON.stringify(
|
|
495
|
+
if (parsed.values.json) { console.log(JSON.stringify(page, null, 2)); return 0 }
|
|
496
|
+
const rows = page.issues
|
|
491
497
|
if (rows.length === 0) {
|
|
492
498
|
console.log('(no matching issues)')
|
|
493
499
|
return 0
|
|
@@ -721,6 +727,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
721
727
|
console.log(HELP)
|
|
722
728
|
return 0
|
|
723
729
|
}
|
|
730
|
+
if (argv[0] === 'lint') return (await import('./lint/index.js')).cmdLint(argv.slice(1))
|
|
724
731
|
const [a, b, ...rest] = argv
|
|
725
732
|
if (a === 'web') return runWeb(argv.slice(1))
|
|
726
733
|
if (a === 'project' && b === 'show') return cmdProjectApps(rest, false)
|
package/src/issue.ts
CHANGED
|
@@ -37,10 +37,19 @@ async function apiFetch<T>(c: ApiConfig, path: string, init?: RequestInit): Prom
|
|
|
37
37
|
return (await resp.json()) as T
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export
|
|
40
|
+
export type IssueListOptions = { status?: string; kind?: string; app?: string; platform?: string; environment?: string; release?: string; limit?: number; offset?: number }
|
|
41
|
+
export type IssuePage = { issues: Issue[]; total?: number; limit?: number; offset?: number; nextOffset?: number | null }
|
|
42
|
+
|
|
43
|
+
export async function listIssues(c: ApiConfig, opts: IssueListOptions = {}): Promise<Issue[]> {
|
|
44
|
+
return (await listIssuesPage(c, opts)).issues
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function listIssuesPage(
|
|
41
48
|
c: ApiConfig,
|
|
42
|
-
opts:
|
|
43
|
-
): Promise<
|
|
49
|
+
opts: IssueListOptions = {},
|
|
50
|
+
): Promise<IssuePage> {
|
|
51
|
+
if (opts.limit !== undefined && (!Number.isSafeInteger(opts.limit) || opts.limit < 1 || opts.limit > 200)) throw new Error('--limit must be an integer from 1 to 200')
|
|
52
|
+
if (opts.offset !== undefined && (!Number.isSafeInteger(opts.offset) || opts.offset < 0)) throw new Error('--offset must be a non-negative safe integer')
|
|
44
53
|
const q = new URLSearchParams()
|
|
45
54
|
if (opts.status) q.set('status', opts.status)
|
|
46
55
|
if (opts.kind) q.set('kind', opts.kind)
|
|
@@ -48,9 +57,11 @@ export async function listIssues(
|
|
|
48
57
|
if (opts.platform) q.set('platform', opts.platform)
|
|
49
58
|
if (opts.environment) q.set('environment', opts.environment)
|
|
50
59
|
if (opts.release) q.set('release', opts.release)
|
|
60
|
+
if (opts.limit !== undefined) q.set('limit', String(opts.limit))
|
|
61
|
+
if (opts.offset !== undefined) q.set('offset', String(opts.offset))
|
|
51
62
|
const qs = q.toString()
|
|
52
|
-
const body = await apiFetch<
|
|
53
|
-
return body
|
|
63
|
+
const body = await apiFetch<IssuePage>(c, `/api/issues${qs ? `?${qs}` : ''}`)
|
|
64
|
+
return body
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
export async function resolveIssue(
|
|
@@ -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
|
+
}
|