tackbox 0.1.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/LICENSE +21 -0
- package/README.md +552 -0
- package/bin/tackbox-eslint.js +147 -0
- package/bin/tackbox-mdlint.js +45 -0
- package/eslint.config.preset.js +21 -0
- package/js/README.md +209 -0
- package/js/eslint-plugin.js +48 -0
- package/js/markdownlint-rules/no-non-ascii.js +132 -0
- package/js/report.js +187 -0
- package/js/rules/_shared.js +809 -0
- package/js/rules/no-broad-notify.js +47 -0
- package/js/rules/no-console-error.js +23 -0
- package/js/rules/no-focused-test.js +22 -0
- package/js/rules/no-parse-fallback.js +150 -0
- package/js/rules/no-skipped-test.js +95 -0
- package/js/rules/no-swallow-allsettled.js +52 -0
- package/js/rules/no-swallow-catch.js +31 -0
- package/js/rules/no-swallow-promise-catch.js +38 -0
- package/js/rules/no-throw-and-report.js +30 -0
- package/js/rules/ts-exit-in-catch.js +26 -0
- package/js/rules/ts-rethrow-without-cause.js +62 -0
- package/js/rules/ts-useless-catch.js +25 -0
- package/js/rules/valid-dedup-key.js +50 -0
- package/js/rules/valid-error-report.js +99 -0
- package/js/tests/eslint-wrapper.test.js +53 -0
- package/js/tests/mdlint-wrapper.test.js +85 -0
- package/js/tests/no-non-ascii.test.js +124 -0
- package/js/tests/report.test.js +182 -0
- package/js/tests/reporters.test.js +223 -0
- package/js/tests/rules.test.js +650 -0
- package/js/tests/svelte.test.js +61 -0
- package/package.json +43 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const { ESLint } = require('eslint')
|
|
5
|
+
|
|
6
|
+
const REPORTERS_FLAG = '--reporters='
|
|
7
|
+
|
|
8
|
+
// Split argv into declared reporters (`--reporters=file#func,...`) and the
|
|
9
|
+
// files to lint.
|
|
10
|
+
function parseArgv(argv) {
|
|
11
|
+
const decls = []
|
|
12
|
+
const files = []
|
|
13
|
+
let machine = false
|
|
14
|
+
for (const a of argv) {
|
|
15
|
+
if (a === '--machine') {
|
|
16
|
+
machine = true
|
|
17
|
+
} else if (a.startsWith(REPORTERS_FLAG)) {
|
|
18
|
+
for (const d of a.slice(REPORTERS_FLAG.length).split(',')) {
|
|
19
|
+
if (!d) continue
|
|
20
|
+
const hash = d.lastIndexOf('#')
|
|
21
|
+
if (hash > 0) decls.push({ raw: d, file: d.slice(0, hash), fn: d.slice(hash + 1) })
|
|
22
|
+
}
|
|
23
|
+
} else {
|
|
24
|
+
files.push(a)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { decls, files, machine }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseModule(file, code) {
|
|
31
|
+
const ext = path.extname(file)
|
|
32
|
+
if (ext === '.ts' || ext === '.tsx') {
|
|
33
|
+
return require('@typescript-eslint/parser').parse(code, {
|
|
34
|
+
ecmaVersion: 'latest',
|
|
35
|
+
sourceType: 'module',
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
if (ext === '.svelte') {
|
|
39
|
+
return require('svelte-eslint-parser').parse(code, {})
|
|
40
|
+
}
|
|
41
|
+
return require('espree').parse(code, { ecmaVersion: 'latest', sourceType: 'module' })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// hasBinding: the AST defines a function or const-arrow named `name`. Existence
|
|
45
|
+
// check for `.tackbox-reporters` symbol validation; a deep walk keeps it parser
|
|
46
|
+
// shape-agnostic across espree / ts / svelte.
|
|
47
|
+
function hasBinding(ast, name) {
|
|
48
|
+
const seen = new Set()
|
|
49
|
+
let found = false
|
|
50
|
+
const visit = node => {
|
|
51
|
+
if (found || !node || typeof node !== 'object' || seen.has(node)) return
|
|
52
|
+
seen.add(node)
|
|
53
|
+
if (Array.isArray(node)) {
|
|
54
|
+
for (const c of node) visit(c)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
if (node.type === 'FunctionDeclaration' && node.id && node.id.name === name) {
|
|
58
|
+
found = true
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
node.type === 'VariableDeclarator' &&
|
|
63
|
+
node.id && node.id.type === 'Identifier' && node.id.name === name &&
|
|
64
|
+
node.init &&
|
|
65
|
+
(node.init.type === 'ArrowFunctionExpression' || node.init.type === 'FunctionExpression')
|
|
66
|
+
) {
|
|
67
|
+
found = true
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
for (const k of Object.keys(node)) {
|
|
71
|
+
if (k === 'parent' || k === 'loc' || k === 'range' || k === 'tokens' || k === 'comments') continue
|
|
72
|
+
const c = node[k]
|
|
73
|
+
if (c && typeof c === 'object') visit(c)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
visit(ast)
|
|
77
|
+
return found
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Validate every declaration's symbol, independent of the lint scope: a dead
|
|
81
|
+
// `file#function` fails the whole run even when that file is not being linted.
|
|
82
|
+
function validateDeclarations(decls) {
|
|
83
|
+
for (const d of decls) {
|
|
84
|
+
const abs = path.resolve(process.cwd(), d.file)
|
|
85
|
+
let code
|
|
86
|
+
try {
|
|
87
|
+
code = fs.readFileSync(abs, 'utf8')
|
|
88
|
+
} catch (e) {
|
|
89
|
+
throw new Error(`.tackbox-reporters: cannot read ${d.file}: ${e.message}`, { cause: e })
|
|
90
|
+
}
|
|
91
|
+
let ast
|
|
92
|
+
try {
|
|
93
|
+
ast = parseModule(d.file, code)
|
|
94
|
+
} catch (e) {
|
|
95
|
+
throw new Error(`.tackbox-reporters: cannot parse ${d.file}: ${e.message}`, { cause: e })
|
|
96
|
+
}
|
|
97
|
+
if (!hasBinding(ast, d.fn)) {
|
|
98
|
+
throw new Error(`.tackbox-reporters: no top-level function ${d.fn} in ${d.file}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Internal machine contract: one {file, line, rule} JSON object per error, for
|
|
104
|
+
// the hook. Human (stylish) output is unchanged. A message with no line emits
|
|
105
|
+
// line: null (location-unknown) - the caller over-reports, never drops it.
|
|
106
|
+
function emitMachine(results) {
|
|
107
|
+
for (const r of results) {
|
|
108
|
+
const file = path.relative(process.cwd(), r.filePath)
|
|
109
|
+
for (const m of r.messages) {
|
|
110
|
+
if (m.severity !== 2) continue
|
|
111
|
+
process.stdout.write(JSON.stringify({ file, line: m.line ?? null, rule: m.ruleId, message: m.message }) + '\n')
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function main() {
|
|
117
|
+
const { decls, files, machine } = parseArgv(process.argv.slice(2))
|
|
118
|
+
if (files.length === 0) {
|
|
119
|
+
process.stderr.write('tackbox-eslint: no files supplied\n')
|
|
120
|
+
process.exit(2)
|
|
121
|
+
}
|
|
122
|
+
validateDeclarations(decls)
|
|
123
|
+
const eslint = new ESLint({
|
|
124
|
+
// Inline directives (eslint-disable ... tackbox/<rule>) would be an
|
|
125
|
+
// uninventoried, ungated bypass of every tackbox JS rule. Closed here on
|
|
126
|
+
// the hermetic/published wrapper path; the preset closes it for consumers.
|
|
127
|
+
allowInlineConfig: false,
|
|
128
|
+
overrideConfigFile: path.join(__dirname, '..', 'eslint.config.preset.js'),
|
|
129
|
+
overrideConfig: [{ settings: { tackbox: { reporters: decls.map(d => d.raw) } } }],
|
|
130
|
+
})
|
|
131
|
+
const results = await eslint.lintFiles(files)
|
|
132
|
+
if (machine) {
|
|
133
|
+
emitMachine(results)
|
|
134
|
+
} else {
|
|
135
|
+
const formatter = await eslint.loadFormatter('stylish')
|
|
136
|
+
const output = await formatter.format(results)
|
|
137
|
+
if (output) process.stdout.write(output + '\n')
|
|
138
|
+
}
|
|
139
|
+
const fail = results.some(r => r.errorCount > 0 || r.fatalErrorCount > 0)
|
|
140
|
+
process.exit(fail ? 1 : 0)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// no-report: CLI bootstrap, no reporter wired before main() runs
|
|
144
|
+
main().catch(err => {
|
|
145
|
+
process.stderr.write('tackbox-eslint: ' + (err && err.stack || err) + '\n')
|
|
146
|
+
process.exit(2)
|
|
147
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { lint } = require('markdownlint/promise')
|
|
3
|
+
const noNonAscii = require('../js/markdownlint-rules/no-non-ascii')
|
|
4
|
+
|
|
5
|
+
async function run() {
|
|
6
|
+
const argv = process.argv.slice(2)
|
|
7
|
+
const machine = argv.includes('--machine')
|
|
8
|
+
const files = argv.filter(a => a !== '--machine')
|
|
9
|
+
if (files.length === 0) {
|
|
10
|
+
process.stderr.write('tackbox-mdlint: no files supplied\n')
|
|
11
|
+
process.exit(2)
|
|
12
|
+
}
|
|
13
|
+
const result = await lint({
|
|
14
|
+
files,
|
|
15
|
+
config: { default: true, 'no-non-ascii': true },
|
|
16
|
+
customRules: [noNonAscii],
|
|
17
|
+
noInlineConfig: true,
|
|
18
|
+
})
|
|
19
|
+
let count = 0
|
|
20
|
+
for (const [file, errors] of Object.entries(result)) {
|
|
21
|
+
for (const e of errors) {
|
|
22
|
+
count++
|
|
23
|
+
if (machine) {
|
|
24
|
+
// Internal {file, line, rule} contract for the hook; human output below
|
|
25
|
+
// is unchanged.
|
|
26
|
+
const message = e.ruleDescription + (e.errorDetail ? ' [' + e.errorDetail + ']' : '')
|
|
27
|
+
process.stdout.write(JSON.stringify({ file, line: e.lineNumber, rule: e.ruleNames[0], message }) + '\n')
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
const col = e.errorRange ? ':' + e.errorRange[0] : ''
|
|
31
|
+
const detail = e.errorDetail ? ' [' + e.errorDetail + ']' : ''
|
|
32
|
+
const name = e.ruleNames.slice(0, 2).join('/')
|
|
33
|
+
process.stdout.write(
|
|
34
|
+
file + ':' + e.lineNumber + col + ' ' + name + ' ' + e.ruleDescription + detail + '\n'
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
process.exit(count > 0 ? 1 : 0)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// no-report: CLI bootstrap, no reporter wired before run() executes
|
|
42
|
+
run().catch(err => {
|
|
43
|
+
process.stderr.write('tackbox-mdlint: ' + (err && err.stack || err) + '\n')
|
|
44
|
+
process.exit(2)
|
|
45
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const tackbox = require('./js/eslint-plugin.js')
|
|
2
|
+
const tsParser = require('@typescript-eslint/parser')
|
|
3
|
+
const svelteParser = require('svelte-eslint-parser')
|
|
4
|
+
|
|
5
|
+
const base = {
|
|
6
|
+
plugins: { tackbox },
|
|
7
|
+
rules: tackbox.configs.recommended.rules,
|
|
8
|
+
// Inline eslint-disable directives would silently defeat every tackbox rule;
|
|
9
|
+
// closed for consumers who use this flat config directly.
|
|
10
|
+
linterOptions: { noInlineConfig: true },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = [
|
|
14
|
+
{ files: ['**/*.{js,mjs,cjs,jsx}'], ...base },
|
|
15
|
+
{ files: ['**/*.{ts,tsx}'], languageOptions: { parser: tsParser }, ...base },
|
|
16
|
+
{
|
|
17
|
+
files: ['**/*.svelte'],
|
|
18
|
+
languageOptions: { parser: svelteParser, parserOptions: { parser: tsParser } },
|
|
19
|
+
...base,
|
|
20
|
+
},
|
|
21
|
+
]
|
package/js/README.md
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# tackbox (JS / TS / Svelte)
|
|
2
|
+
|
|
3
|
+
ESLint plugin + browser report helper. Implements the
|
|
4
|
+
`error-reporting-and-coverage` and `error-handling-frontend` specs.
|
|
5
|
+
|
|
6
|
+
## Linting
|
|
7
|
+
|
|
8
|
+
The frontend rules run through the hermetic tackbox CLI, which bundles
|
|
9
|
+
ESLint, the parsers, and this plugin - no `npm install` is needed to
|
|
10
|
+
lint:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
uvx tackbox@latest lint .
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Direct ESLint integration
|
|
17
|
+
|
|
18
|
+
To wire the plugin into your own ESLint run instead, install it from
|
|
19
|
+
npm:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install --save-dev tackbox eslint
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`@typescript-eslint/parser` and `svelte-eslint-parser` ship as direct
|
|
26
|
+
dependencies, so `.ts`, `.tsx`, `.svelte`, and `<script lang="ts">`
|
|
27
|
+
blocks work out of the box. The plugin exposes a `recommended` config:
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
import tackbox from 'tackbox'
|
|
31
|
+
|
|
32
|
+
export default [
|
|
33
|
+
{
|
|
34
|
+
plugins: { tackbox },
|
|
35
|
+
rules: tackbox.configs.recommended.rules,
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or run the bundled preset via the bin wrapper:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx tackbox-eslint src/**/*.{ts,svelte}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Rules
|
|
47
|
+
|
|
48
|
+
| Rule | Summary |
|
|
49
|
+
|------------------------------------|--------------------------------------|
|
|
50
|
+
| `tackbox/no-swallow-catch` | catch must throw, report, or marker |
|
|
51
|
+
| `tackbox/no-swallow-promise-catch` | .catch/.then rejection handler |
|
|
52
|
+
| `tackbox/no-swallow-allsettled` | allSettled result must read .reason |
|
|
53
|
+
| `tackbox/no-console-error` | banned; use `reportError` |
|
|
54
|
+
| `tackbox/valid-error-report` | static msg + cause + tags + dedupKey |
|
|
55
|
+
| `tackbox/valid-dedup-key` | static `area.suffix[:identifier]` |
|
|
56
|
+
| `tackbox/no-throw-and-report` | catch: no throw+report, no cap+notify|
|
|
57
|
+
| `tackbox/no-broad-notify` | notify must sit under a condition |
|
|
58
|
+
| `tackbox/no-parse-fallback` | JSON.parse catch must propagate err |
|
|
59
|
+
| `tackbox/ts-rethrow-without-cause` | `throw new` in catch needs `{cause}` |
|
|
60
|
+
| `tackbox/ts-useless-catch` | catch that only re-throws is a no-op |
|
|
61
|
+
| `tackbox/ts-exit-in-catch` | no `process.exit` inside catch |
|
|
62
|
+
| `tackbox/no-skipped-test` | skip/todo needs `test-skip:` marker |
|
|
63
|
+
| `tackbox/no-focused-test` | banned `.only`/`f`-tests; no escape |
|
|
64
|
+
|
|
65
|
+
Full constraints per rule:
|
|
66
|
+
|
|
67
|
+
- `no-swallow-catch` - `catch` must throw, call a reporter, or have
|
|
68
|
+
`// no-report: <reason>` above the `try`.
|
|
69
|
+
- `no-swallow-promise-catch` - a promise rejection handler must throw,
|
|
70
|
+
call a reporter, call the enclosing `new Promise(...)` executor's
|
|
71
|
+
reject parameter (structural resolution, not the name), return the
|
|
72
|
+
caught error object itself (`e => e` - the rejection-to-value idiom;
|
|
73
|
+
wrapper objects stay refused), or carry the marker. The handler is
|
|
74
|
+
`.catch(onErr)` or the second argument of `.then(onOk, onErr)`
|
|
75
|
+
(`.then(onOk)` alone propagates the rejection and is not checked).
|
|
76
|
+
- `no-swallow-allsettled` - every `Promise.allSettled` call needs at
|
|
77
|
+
least one `.reason` access in the enclosing function, or the rejected
|
|
78
|
+
outcomes are silently dropped (`allSettled` never rejects, so a
|
|
79
|
+
discarded result is the quietest swallow). Passing the result whole
|
|
80
|
+
to a helper is opaque and counts as a swallow. Escape with a
|
|
81
|
+
`// no-report: <reason>` marker above.
|
|
82
|
+
- `no-console-error` - `console.error` is banned; use `reportError`.
|
|
83
|
+
- `valid-error-report` - static 15-200 char msg, cause non-null,
|
|
84
|
+
tags non-empty, dedupKey required. `notify` shares the
|
|
85
|
+
`(msg, cause, tags, dedupKey)` shape and is validated the same way
|
|
86
|
+
(D007), though it is never credited as a reporter.
|
|
87
|
+
- `valid-dedup-key` - dedupKey must be a static literal in
|
|
88
|
+
`area.suffix[:identifier]` form; `notify`'s dedupKey too (D008).
|
|
89
|
+
- `no-throw-and-report` - a `catch` may not both throw and report; nor
|
|
90
|
+
may one path both capture and `notify` (D006 double-lane -
|
|
91
|
+
error/warn already reach the user lane, so the notify double-shows).
|
|
92
|
+
- `no-broad-notify` - a `notify` carrying the caught error may
|
|
93
|
+
terminate a `catch` only when it sits under an additional condition
|
|
94
|
+
(an `if`/`switch` inside the catch); an unconditional notify as the
|
|
95
|
+
sole handling routes every error to a toast and blinds telemetry. The
|
|
96
|
+
complement stays covered by `no-swallow-catch`; a `// no-report:`
|
|
97
|
+
marker above the `try` suppresses.
|
|
98
|
+
- `no-parse-fallback` - a `try` containing `JSON.parse` must propagate
|
|
99
|
+
the parse error on every `catch` path: `throw` the caught error
|
|
100
|
+
object, or return a Result boundary carrying it (`return { ok: false,
|
|
101
|
+
cause: <err> }` when the enclosing function returns Result/Attempt). A
|
|
102
|
+
fallback value, a stringified rethrow, or report-and-continue swallows
|
|
103
|
+
it (report + fallback is still a finding). Escape with a
|
|
104
|
+
`// parse-skip: <reason>` marker above the `try`.
|
|
105
|
+
- `ts-rethrow-without-cause` - `throw new X(...)` in a `catch` must
|
|
106
|
+
pass `{ cause: <caught> }` to preserve the stack chain.
|
|
107
|
+
- `ts-useless-catch` - a `catch` whose only statement re-throws the
|
|
108
|
+
caught error is a no-op wrapper; remove the try/catch.
|
|
109
|
+
- `ts-exit-in-catch` - `process.exit(...)` inside a `catch` masks the
|
|
110
|
+
exception; let it propagate.
|
|
111
|
+
- `no-skipped-test` - a skip/todo/skipIf in a chain rooted at bare
|
|
112
|
+
`it` / `test` / `describe` (`it.skip`, `test.todo`, `it.skipIf(c)(...)`,
|
|
113
|
+
`it.skip.each(...)(...)`), or a bare `xit` / `xdescribe` / `xtest`,
|
|
114
|
+
silently drops coverage. Chained forms report once, on the inner call.
|
|
115
|
+
A deeper root (`queue.skip`, `foo.test.skip`) is out of scope. Escape
|
|
116
|
+
with a `// test-skip: <reason>` marker directly above the statement.
|
|
117
|
+
- `no-focused-test` - a `.only` in a chain rooted at bare `it` / `test` /
|
|
118
|
+
`describe` (`it.only`, `describe.only`, `test.only.each(...)(...)`), or a
|
|
119
|
+
bare `fit` / `fdescribe` / `ftest`, disables the rest of the suite. No
|
|
120
|
+
escape hatch; the focused test must be removed.
|
|
121
|
+
|
|
122
|
+
## Reporter recognition
|
|
123
|
+
|
|
124
|
+
A call counts as a reporter only when its callee resolves to one of the
|
|
125
|
+
reporter names imported from `tackbox` / `tackbox/report` (tier-1), or
|
|
126
|
+
to a function declared in a repo-root `.tackbox-reporters` file
|
|
127
|
+
(tier-2). A bare identifier that merely shares the name is not trusted.
|
|
128
|
+
|
|
129
|
+
Names: `reportError`, `reportWarn`, `reportQuiet`, `reportApiError`,
|
|
130
|
+
`reportLayerError` (4-arg form: msg, cause, tags, dedupKey) and `reportSynth`,
|
|
131
|
+
`reportSynthError` (3-arg form: msg, tags, dedupKey).
|
|
132
|
+
|
|
133
|
+
`notify` (user lane only) is resolved through the same origin gate but
|
|
134
|
+
is deliberately not a reporter name: it never credits a swallow as a
|
|
135
|
+
capture. `no-broad-notify` gates it, and `valid-error-report` /
|
|
136
|
+
`valid-dedup-key` validate its msg and dedupKey.
|
|
137
|
+
|
|
138
|
+
Tier-1 covers named, renamed, default- or namespace-member, and CJS
|
|
139
|
+
`require('tackbox/report')` forms. The strict argument contracts
|
|
140
|
+
(`valid-error-report`, `valid-dedup-key`) apply
|
|
141
|
+
to tier-1 calls; declared sinks carry only the argument-flow contract
|
|
142
|
+
(the caught error must flow into the call).
|
|
143
|
+
|
|
144
|
+
`.tackbox-reporters` lines are `file#function: reason`. The `tackbox`
|
|
145
|
+
CLI parses and validates the file. When you consume this ESLint plugin
|
|
146
|
+
directly (without the CLI), populate `settings.tackbox.reporters` (a
|
|
147
|
+
list of `"file#function"` strings) in your own config; symbol
|
|
148
|
+
validation is the CLI's responsibility and is not performed in that
|
|
149
|
+
mode.
|
|
150
|
+
|
|
151
|
+
## Report helper
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
import { init, reportError, reportWarn, setupGlobalHandlers, flush } from 'tackbox/report'
|
|
155
|
+
|
|
156
|
+
init({
|
|
157
|
+
dsn: import.meta.env.VITE_SENTRY_DSN || '',
|
|
158
|
+
release: import.meta.env.VITE_VERSION,
|
|
159
|
+
verify: true, // confirm connectivity at startup
|
|
160
|
+
debug: false,
|
|
161
|
+
})
|
|
162
|
+
setupGlobalHandlers()
|
|
163
|
+
// ... on shutdown:
|
|
164
|
+
await flush(2000)
|
|
165
|
+
|
|
166
|
+
// in app code:
|
|
167
|
+
try {
|
|
168
|
+
await fetchSomething()
|
|
169
|
+
} catch (err) {
|
|
170
|
+
reportError('failed to fetch projects from API', err, { area: 'projects' }, 'projects.fetch')
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Empty DSN: `init` logs a WARN (suppressible via `silentMissing`)
|
|
175
|
+
and stays log-only. `init({ verify: true })` sends one healthcheck
|
|
176
|
+
event with `fingerprint: ["report.startup"]` and flushes; glitchtip
|
|
177
|
+
groups all startups under one issue, no spam.
|
|
178
|
+
|
|
179
|
+
## Bundled API
|
|
180
|
+
|
|
181
|
+
- `init(opts)`, `flush(timeout)`, `verify(timeout)`, `isReady()`
|
|
182
|
+
- `reportError(msg, cause, tags, dedupKey)` - log + user lane + capture
|
|
183
|
+
- `reportWarn(msg, cause, tags, dedupKey)` - log + user lane + capture
|
|
184
|
+
- `reportQuiet(msg, cause, tags, dedupKey)` - log + warning-level capture,
|
|
185
|
+
no user lane (background / self-healed / degraded-with-fallback)
|
|
186
|
+
- `notify(msg, cause, tags, dedupKey)` - log + user lane only, no capture
|
|
187
|
+
and no rate-window state touched (an expected environmental fault, e.g.
|
|
188
|
+
the user lost connectivity)
|
|
189
|
+
- `reportSynthError(msg, tags, dedupKey)`
|
|
190
|
+
- `reportPanic(name, recovered)`
|
|
191
|
+
- `setupGlobalHandlers()` wires `window.error` and
|
|
192
|
+
`window.unhandledrejection` to `reportError`
|
|
193
|
+
|
|
194
|
+
The `tackbox:error` custom event is the user lane: it is dispatched on the
|
|
195
|
+
window before the init + rate-window gate (and is never rate-limited) after
|
|
196
|
+
each `reportError` / `reportWarn` / `notify` / `reportPanic` call, so a
|
|
197
|
+
single top-level component can render a toast. `reportQuiet` does not
|
|
198
|
+
dispatch it. The event `detail` carries `{ msg, cause, tags, dedupKey,
|
|
199
|
+
level }`; the listener coalesces on `dedupKey`. Capture is gated behind init
|
|
200
|
+
and the per-`dedupKey` rate window; the user lane is not.
|
|
201
|
+
|
|
202
|
+
Platform limit: a `tackbox:error` listener that throws is not observable from
|
|
203
|
+
`dispatchEvent` - the browser routes a listener failure to `window.onerror` by
|
|
204
|
+
design. So the JS user lane cannot capture its own listener's failure the way
|
|
205
|
+
Go, Python, and Java capture a throwing `report.notifier`. A module-level
|
|
206
|
+
re-entrancy guard stops the one loop this opens (a throwing listener reaching
|
|
207
|
+
`window.onerror`, which `setupGlobalHandlers` turns back into
|
|
208
|
+
`reportError` -> dispatch): a dispatch already in progress on the stack skips
|
|
209
|
+
the nested one and logs locally instead. Sequential dispatches are unaffected.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ESLint plugin: error-reporting-and-coverage rules for JS/TS/Svelte.
|
|
2
|
+
// Mirrors the Go ruleset (ERC001-006) with the frontend variants from
|
|
3
|
+
// the error-handling-frontend spec.
|
|
4
|
+
|
|
5
|
+
const noSwallowCatch = require('./rules/no-swallow-catch')
|
|
6
|
+
const noSwallowPromiseCatch = require('./rules/no-swallow-promise-catch')
|
|
7
|
+
const noSwallowAllsettled = require('./rules/no-swallow-allsettled')
|
|
8
|
+
const noConsoleError = require('./rules/no-console-error')
|
|
9
|
+
const validErrorReport = require('./rules/valid-error-report')
|
|
10
|
+
const noThrowAndReport = require('./rules/no-throw-and-report')
|
|
11
|
+
const noBroadNotify = require('./rules/no-broad-notify')
|
|
12
|
+
const noParseFallback = require('./rules/no-parse-fallback')
|
|
13
|
+
const validDedupKey = require('./rules/valid-dedup-key')
|
|
14
|
+
const tsRethrowWithoutCause = require('./rules/ts-rethrow-without-cause')
|
|
15
|
+
const tsUselessCatch = require('./rules/ts-useless-catch')
|
|
16
|
+
const tsExitInCatch = require('./rules/ts-exit-in-catch')
|
|
17
|
+
const noSkippedTest = require('./rules/no-skipped-test')
|
|
18
|
+
const noFocusedTest = require('./rules/no-focused-test')
|
|
19
|
+
|
|
20
|
+
const rules = {
|
|
21
|
+
'no-swallow-catch': noSwallowCatch,
|
|
22
|
+
'no-swallow-promise-catch': noSwallowPromiseCatch,
|
|
23
|
+
'no-swallow-allsettled': noSwallowAllsettled,
|
|
24
|
+
'no-console-error': noConsoleError,
|
|
25
|
+
'valid-error-report': validErrorReport,
|
|
26
|
+
'no-throw-and-report': noThrowAndReport,
|
|
27
|
+
'no-broad-notify': noBroadNotify,
|
|
28
|
+
'no-parse-fallback': noParseFallback,
|
|
29
|
+
'valid-dedup-key': validDedupKey,
|
|
30
|
+
'ts-rethrow-without-cause': tsRethrowWithoutCause,
|
|
31
|
+
'ts-useless-catch': tsUselessCatch,
|
|
32
|
+
'ts-exit-in-catch': tsExitInCatch,
|
|
33
|
+
'no-skipped-test': noSkippedTest,
|
|
34
|
+
'no-focused-test': noFocusedTest,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
meta: { name: 'tackbox', version: '0.1.0' },
|
|
39
|
+
rules,
|
|
40
|
+
configs: {
|
|
41
|
+
recommended: {
|
|
42
|
+
plugins: ['tackbox'],
|
|
43
|
+
rules: Object.fromEntries(
|
|
44
|
+
Object.keys(rules).map(name => [`tackbox/${name}`, 'error']),
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Custom markdownlint rule: flag any character outside the printable
|
|
2
|
+
// ASCII range (codepoints > 0x7F). Keeps docs strictly ASCII so
|
|
3
|
+
// em-dashes, curly quotes, Cyrillic, box-drawing chars, emoji, etc.
|
|
4
|
+
// cannot leak into prose, tables, or code fences.
|
|
5
|
+
//
|
|
6
|
+
// One escape hatch: a language marker in an HTML comment within the first
|
|
7
|
+
// 5 lines widens the alphabet for that one file to a declared language's
|
|
8
|
+
// script (plus a little typographic punctuation). It never disables the
|
|
9
|
+
// rule - every other non-ASCII character (emoji, zero-width, other
|
|
10
|
+
// scripts) is still flagged, and a misplaced / duplicate / malformed
|
|
11
|
+
// marker is a finding that leaves the file strict-ASCII.
|
|
12
|
+
//
|
|
13
|
+
// <!-- tackbox: lang=ru personal experimental repo -->
|
|
14
|
+
//
|
|
15
|
+
// The marker is read from micromark HTML-comment tokens, not params.lines:
|
|
16
|
+
// markdownlint masks HTML-comment interiors in `lines`, so the raw code is
|
|
17
|
+
// only visible in the parse tree.
|
|
18
|
+
|
|
19
|
+
// code -> extra codepoints allowed when a valid marker declares it. Add a
|
|
20
|
+
// language by adding one entry: its script range(s) plus the typographic
|
|
21
|
+
// punctuation its prose uses.
|
|
22
|
+
const LANG_SCRIPTS = {
|
|
23
|
+
ru: {
|
|
24
|
+
// Cyrillic (U+0400-U+04FF).
|
|
25
|
+
ranges: [[0x0400, 0x04ff]],
|
|
26
|
+
// Typographic punctuation common in Russian prose: em/en dash,
|
|
27
|
+
// guillemets, ellipsis, curly single/double quotes (incl. low
|
|
28
|
+
// opening quotes), NBSP.
|
|
29
|
+
punct: [
|
|
30
|
+
0x2014, 0x2013, 0x00ab, 0x00bb, 0x2026,
|
|
31
|
+
0x2018, 0x2019, 0x201c, 0x201d, 0x201e, 0x201a, 0x00a0,
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MARKER_MAX_LINE = 5
|
|
37
|
+
const MARKER_RE = /<!--\s*tackbox:\s*lang=([^\s>]*)[^>]*-->/g
|
|
38
|
+
|
|
39
|
+
function collectMarkers(token, found) {
|
|
40
|
+
for (const m of token.text.matchAll(MARKER_RE)) {
|
|
41
|
+
const before = token.text.slice(0, m.index)
|
|
42
|
+
const lineOffset = (before.match(/\n/g) || []).length
|
|
43
|
+
const lastNl = before.lastIndexOf('\n')
|
|
44
|
+
found.push({
|
|
45
|
+
lineNumber: token.startLine + lineOffset,
|
|
46
|
+
code: m[1],
|
|
47
|
+
col: lineOffset === 0 ? token.startColumn + m.index : m.index - lastNl,
|
|
48
|
+
len: m[0].length,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Every marker occurrence in the file's HTML comments: {lineNumber, code,
|
|
54
|
+
// col, len}. Walks the micromark tree; htmlFlow / htmlText carry the raw
|
|
55
|
+
// comment text (their children just re-slice it, so we do not descend).
|
|
56
|
+
function findMarkers(tokens) {
|
|
57
|
+
const found = []
|
|
58
|
+
const walk = (toks) => {
|
|
59
|
+
for (const t of toks) {
|
|
60
|
+
if (t.type === 'htmlFlow' || t.type === 'htmlText') {
|
|
61
|
+
collectMarkers(t, found)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (t.children && t.children.length) walk(t.children)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
walk(tokens)
|
|
68
|
+
return found
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Validate the marker set; emit findings for a misplaced / duplicate /
|
|
72
|
+
// malformed / unknown marker. Return the allow-config for a single valid
|
|
73
|
+
// marker, or null (strict ASCII-only) for no marker or any invalid one.
|
|
74
|
+
function resolveMarkers(markers, onError) {
|
|
75
|
+
if (markers.length === 0) return null
|
|
76
|
+
const markerErr = (m, detail) =>
|
|
77
|
+
onError({ lineNumber: m.lineNumber, detail, range: [m.col, m.len] })
|
|
78
|
+
|
|
79
|
+
if (markers.length > 1) {
|
|
80
|
+
for (const dup of markers.slice(1)) {
|
|
81
|
+
markerErr(dup, 'duplicate tackbox lang marker (one marker per file)')
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
const m = markers[0]
|
|
86
|
+
if (m.lineNumber > MARKER_MAX_LINE) {
|
|
87
|
+
markerErr(m, `tackbox lang marker must be within the first ${MARKER_MAX_LINE} lines`)
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
if (m.code === '') {
|
|
91
|
+
markerErr(m, 'tackbox lang marker is missing a language code')
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
const cfg = LANG_SCRIPTS[m.code]
|
|
95
|
+
if (!cfg) {
|
|
96
|
+
markerErr(m, `tackbox lang marker: unsupported language code '${m.code}'`)
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
return cfg
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isWidened(code, allow) {
|
|
103
|
+
if (!allow) return false
|
|
104
|
+
for (const [lo, hi] of allow.ranges) {
|
|
105
|
+
if (code >= lo && code <= hi) return true
|
|
106
|
+
}
|
|
107
|
+
return allow.punct.includes(code)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
names: ['MD-ASCII', 'no-non-ascii'],
|
|
112
|
+
description: 'Non-ASCII character',
|
|
113
|
+
tags: ['ascii'],
|
|
114
|
+
parser: 'micromark',
|
|
115
|
+
function: function rule(params, onError) {
|
|
116
|
+
const allow = resolveMarkers(findMarkers(params.parsers.micromark.tokens), onError)
|
|
117
|
+
params.lines.forEach((line, idx) => {
|
|
118
|
+
let col = 0
|
|
119
|
+
for (const ch of line) {
|
|
120
|
+
const code = ch.codePointAt(0)
|
|
121
|
+
if (code > 0x7f && !isWidened(code, allow)) {
|
|
122
|
+
onError({
|
|
123
|
+
lineNumber: idx + 1,
|
|
124
|
+
detail: 'Non-ASCII character U+' + code.toString(16).toUpperCase() + ' (' + ch + ')',
|
|
125
|
+
range: [col + 1, ch.length],
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
col += ch.length
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
}
|