@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.
Files changed (53) hide show
  1. package/LINT.md +237 -0
  2. package/README.md +65 -8
  3. package/examples/insight-lint.toml +32 -0
  4. package/lib/artifacts.d.ts +7 -0
  5. package/lib/artifacts.d.ts.map +1 -1
  6. package/lib/artifacts.js +1 -1
  7. package/lib/artifacts.js.map +1 -1
  8. package/lib/compatibility.js +1 -1
  9. package/lib/compatibility.js.map +1 -1
  10. package/lib/index.js +13 -5
  11. package/lib/index.js.map +1 -1
  12. package/lib/issue.d.ts +13 -2
  13. package/lib/issue.d.ts.map +1 -1
  14. package/lib/issue.js +12 -1
  15. package/lib/issue.js.map +1 -1
  16. package/lib/lint/config.d.ts +19 -0
  17. package/lib/lint/config.d.ts.map +1 -0
  18. package/lib/lint/config.js +149 -0
  19. package/lib/lint/config.js.map +1 -0
  20. package/lib/lint/index.d.ts +4 -0
  21. package/lib/lint/index.d.ts.map +1 -0
  22. package/lib/lint/index.js +204 -0
  23. package/lib/lint/index.js.map +1 -0
  24. package/lib/lint/report.d.ts +48 -0
  25. package/lib/lint/report.d.ts.map +1 -0
  26. package/lib/lint/report.js +136 -0
  27. package/lib/lint/report.js.map +1 -0
  28. package/lib/lint/rules.d.ts +28 -0
  29. package/lib/lint/rules.d.ts.map +1 -0
  30. package/lib/lint/rules.js +57 -0
  31. package/lib/lint/rules.js.map +1 -0
  32. package/lib/mcp.d.ts +6 -0
  33. package/lib/mcp.d.ts.map +1 -1
  34. package/lib/mcp.js +228 -65
  35. package/lib/mcp.js.map +1 -1
  36. package/lib/workflow.d.ts.map +1 -1
  37. package/lib/workflow.js +12 -2
  38. package/lib/workflow.js.map +1 -1
  39. package/package.json +4 -2
  40. package/src/__tests__/issue.test.ts +27 -0
  41. package/src/__tests__/lint.test.ts +185 -0
  42. package/src/__tests__/mcp.test.ts +128 -1
  43. package/src/__tests__/workflow.test.ts +41 -1
  44. package/src/artifacts.ts +3 -1
  45. package/src/compatibility.ts +1 -1
  46. package/src/index.ts +12 -5
  47. package/src/issue.ts +16 -5
  48. package/src/lint/config.ts +102 -0
  49. package/src/lint/index.ts +163 -0
  50. package/src/lint/report.ts +129 -0
  51. package/src/lint/rules.ts +64 -0
  52. package/src/mcp.ts +205 -67
  53. 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('exactly the four closed-loop tools exist', () => {
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
- uploaded.push({ kind, name: file.name.replace(/\.gz$/, ''), usable: true, contentHash: createHash('sha256').update(bytes).digest('hex') })
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
  })
@@ -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 still requires matching SHA-256 bytes and usable: true.'
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
- listIssues,
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 rows = await listIssues(cfg, {
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({ issues: rows }, null, 2)); return 0 }
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 async function listIssues(
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: { status?: string; kind?: string; app?: string; platform?: string; environment?: string; release?: string } = {},
43
- ): Promise<Issue[]> {
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<{ issues: Issue[] }>(c, `/api/issues${qs ? `?${qs}` : ''}`)
53
- return body.issues
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
+ }