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.
@@ -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
+ }