tackbox 0.1.70 → 0.1.72

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/README.md CHANGED
@@ -191,9 +191,14 @@ fetched separately and cached per version:
191
191
  wheel's `engines.json`, and
192
192
  unpacks it once into `$XDG_DATA_HOME/tackbox/engines/<version>/`
193
193
  (default `~/.local/share/...`; override `TACKBOX_ENGINES_DIR`).
194
- Every later thin version reuses that one copy, so a stream of
195
- `@latest` patch bumps never re-materializes the engines. Bumped only
196
- when an engine changes.
194
+ A machine-wide per-version lock makes concurrent first runs share
195
+ one download: one process fetches and verifies while every other
196
+ process waits and then reuses the committed store. Every later thin
197
+ version reuses that one copy, so a stream of `@latest` patch bumps
198
+ never re-materializes the engines. Installed engine versions coexist
199
+ rather than deleting one another during normal commands, so an older
200
+ thin client cannot force a re-download. Bumped only when an engine
201
+ changes.
197
202
 
198
203
  After the first fetch tackbox runs fully offline until the engine
199
204
  version changes. Platform wheels cover Linux x86_64/arm64 (manylinux),
@@ -323,29 +328,40 @@ every lint and writes no clean-cache markers. A `java`-format clone that
323
328
  lies entirely within both files' headers (package, imports, leading
324
329
  comments) has no extractable code and is dropped before it is reported.
325
330
 
326
- ### Markdown: ASCII and the language marker
331
+ ### Markdown: declared charset
327
332
 
328
- The Markdown engine runs the standard markdownlint rules plus `MD-ASCII`,
329
- which flags any non-ASCII character (any codepoint above U+007F) - em
330
- dashes, curly quotes, other scripts, emoji - keeping docs portable and
331
- grep-friendly.
333
+ The Markdown engine does not enforce Markdown or prose style. It runs the
334
+ four link-reference built-ins - `MD011` (reversed links), `MD042` (empty
335
+ links), `MD051` (in-file link fragments), `MD052` (reference links
336
+ defined) - plus `MD-CHARS`, which checks a file's character repertoire
337
+ against a declaration the file makes about itself.
332
338
 
333
- One HTML comment on one of the first five lines widens the alphabet for
334
- a single file:
339
+ The check is opt-in and declaration-driven. With no marker, the charset
340
+ is not checked. One HTML comment on one of the first five lines turns it
341
+ on and names the allowed sets:
335
342
 
336
343
  ```text
337
- <!-- tackbox: lang=ru personal experimental repo -->
344
+ <!-- tackbox: chars=cyrillic,punct -->
338
345
  ```
339
346
 
340
- It widens the allowed set to that language's script plus its typographic
341
- punctuation - `ru` today: Cyrillic, guillemets, em/en dash, ellipsis,
342
- curly quotes, NBSP - and nothing else: every other non-ASCII character,
343
- emoji and other scripts included, is still flagged.
347
+ With a marker present, every codepoint must be in the always-allowed
348
+ ASCII base (U+0000-U+007F) or in one of the declared sets; anything else
349
+ is a finding. The sets are named by character repertoire, not language:
344
350
 
345
- The marker is single-use and never disables the rule. A second marker, a
346
- marker past the fifth line, a missing code, or an unknown language code
347
- is itself a finding and leaves the whole file strict ASCII. Any text
348
- after the code (as above) is a free-form note.
351
+ - `ascii` adds nothing (it declares the check with no extension);
352
+ - `cyrillic` adds the Cyrillic block U+0400-U+04FF;
353
+ - `punct` adds em/en dash, guillemets, ellipsis, curly and low quotes,
354
+ and NBSP.
355
+
356
+ Sets are comma-joined and unioned; a space after a comma is fine
357
+ (`chars=ascii, cyrillic`). Russian prose declares `chars=cyrillic,punct`;
358
+ a grep-friendly zone declares `chars=cyrillic`.
359
+
360
+ The marker strengthens the check, so it is not a suppression: it draws no
361
+ approval and does not appear in the escapes inventory. An invalid marker
362
+ (an unknown set, an empty list, a duplicate set, a duplicate marker, or a
363
+ marker past the fifth line) is itself a finding, and the content charset
364
+ is then not checked - a broken declaration does not pass silently.
349
365
 
350
366
  ## No configuration
351
367
 
@@ -396,8 +412,6 @@ ordinary comment token:
396
412
  engine also accepts a standalone single-line `/* ... */` (see its
397
413
  section).
398
414
  - Python: a `#` comment.
399
- - Markdown: the `tackbox: lang=` HTML comment (see the Markdown
400
- engine section) is the only Markdown marker.
401
415
  - Svelte: inside `<script>` blocks the `//` form works as in JS/TS;
402
416
  the template adds two forms - a `//` comment inside a `{...}`
403
417
  expression (line-adjacent, as ever) and an HTML comment
@@ -592,8 +606,8 @@ cheap command that review tooling of any harness can consume (D013). It
592
606
  enumerates:
593
607
 
594
608
  - **suppression markers** (`// no-report`, `// parse-skip`,
595
- `// nil-return`, `// long-comment`, `// test-skip`, `// dup-ok`, plus
596
- the markdown `tackbox: lang=` marker), each with its reason;
609
+ `// nil-return`, `// long-comment`, `// test-skip`, `// dup-ok`), each
610
+ with its reason;
597
611
  - **`.tackbox/reporters` declarations** - the tier-2 sinks;
598
612
  - **notify / quiet lane choices** - the call sites of the user-lane-only
599
613
  `notify` and the telemetry-only `quiet` verbs.
@@ -647,7 +661,7 @@ uvx tackbox@latest escapes --since origin/main --context 5
647
661
  - `text` is the trimmed source line; for a marker it runs from the marker
648
662
  keyword to end of line.
649
663
  - `reason` (markers only) is what follows the keyword's colon, trimmed -
650
- possibly empty (the `tackbox: lang=` marker carries none).
664
+ possibly empty.
651
665
  - `context` is the surrounding source, `--context N` lines each side
652
666
  (default 3), inclusive of the entry line itself - the window
653
667
  `[line-N, line+N]`, clipped at file edges, each line trimmed of trailing
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  const fs = require('fs')
3
3
  const { lint } = require('markdownlint/promise')
4
- const noNonAscii = require('../js/markdownlint-rules/no-non-ascii')
4
+ const declaredChars = require('../js/markdownlint-rules/declared-chars')
5
+ const linkIntegrity = require('../js/markdownlint-rules/link-integrity')
5
6
 
6
7
  // readFilesFrom reads a newline-separated UTF-8 list-file into its non-empty
7
8
  // paths. Additive to positional paths - the bin is public on npm.
@@ -9,25 +10,67 @@ function readFilesFrom(listPath) {
9
10
  return fs.readFileSync(listPath, 'utf8').split(/\r?\n/).filter(Boolean)
10
11
  }
11
12
 
13
+ // Parse the link-target inventory list-file (LF, `<kind>\t<path>` per line) the
14
+ // tackbox CLI writes: F = linkable files, L = tracked symlinks, G = gitlink
15
+ // roots. Repo-relative paths, kept verbatim.
16
+ function readLinkTargets(listPath) {
17
+ const F = new Set()
18
+ const L = new Set()
19
+ const G = []
20
+ for (const line of readFilesFrom(listPath)) {
21
+ const tab = line.indexOf('\t')
22
+ const kind = line.slice(0, tab)
23
+ const p = line.slice(tab + 1)
24
+ if (kind === 'F') F.add(p)
25
+ else if (kind === 'L') L.add(p)
26
+ else if (kind === 'G') G.push(p)
27
+ }
28
+ return { F, L, G }
29
+ }
30
+
31
+ const USAGE =
32
+ 'tackbox-mdlint: --repo-root <dir> and --link-targets-from <list-file> are ' +
33
+ 'required (the cross-file link rule needs the whole-tree target inventory)\n'
34
+
12
35
  async function run() {
13
36
  const argv = process.argv.slice(2)
14
37
  const machine = argv.includes('--machine')
15
38
  const files = []
39
+ let repoRoot = null
40
+ let linkTargetsFrom = null
16
41
  for (let i = 0; i < argv.length; i++) {
17
42
  const a = argv[i]
18
43
  if (a === '--machine') continue
19
44
  // The file set rides a list-file, not positional argv (ARG_MAX safety).
20
45
  if (a === '--files-from') { files.push(...readFilesFrom(argv[++i])); continue }
46
+ if (a === '--repo-root') { repoRoot = argv[++i]; continue }
47
+ if (a === '--link-targets-from') { linkTargetsFrom = argv[++i]; continue }
21
48
  files.push(a)
22
49
  }
50
+ if (!repoRoot || !linkTargetsFrom) {
51
+ process.stderr.write(USAGE)
52
+ process.exit(2)
53
+ }
23
54
  if (files.length === 0) {
24
55
  process.stderr.write('tackbox-mdlint: no files supplied\n')
25
56
  process.exit(2)
26
57
  }
58
+ const { F, L, G } = readLinkTargets(linkTargetsFrom)
27
59
  const result = await lint({
28
60
  files,
29
- config: { default: true, 'no-non-ascii': true },
30
- customRules: [noNonAscii],
61
+ // Style preset off; only the link-reference built-ins plus the declared-
62
+ // charset and cross-file link rules run (D017/D018). noInlineConfig blocks
63
+ // in-file rule toggles.
64
+ config: {
65
+ default: false,
66
+ MD011: true,
67
+ MD042: true,
68
+ MD051: true,
69
+ MD052: true,
70
+ 'declared-chars': true,
71
+ 'link-integrity': true,
72
+ },
73
+ customRules: [declaredChars, linkIntegrity.makeRule({ repoRoot, F, L, G })],
31
74
  noInlineConfig: true,
32
75
  })
33
76
  let count = 0
package/js/README.md CHANGED
@@ -138,6 +138,47 @@ siblings still report. Recognition reads the enclosing element's
138
138
  preceding `SvelteHTMLComment` sibling; `/* ... */` block comments
139
139
  are never markers.
140
140
 
141
+ ## Markdown
142
+
143
+ The CLI also lints `.md` files through `tackbox-mdlint`, a thin markdownlint
144
+ wrapper. The style preset is off; only the link-reference built-ins (MD011,
145
+ MD042, MD051, MD052) plus two tackbox rules run:
146
+
147
+ - `declared-chars` (MD-CHARS) - a file's character repertoire is checked only
148
+ when it declares one with `<!-- tackbox: chars=... -->` (D017).
149
+ - `link-integrity` (MD-LINK) - a relative link or image must resolve to an
150
+ existing in-repo target, and a `#fragment` into a target `.md` must name a
151
+ real heading slug or HTML anchor (D018). Cross-file only; MD051 holds a
152
+ file's own fragments. Fully offline: any URI scheme or absolute path is out
153
+ of scope.
154
+
155
+ The link rule needs the whole-tree set of valid targets, so the bin takes two
156
+ mandatory flags - a call missing either is a usage error (exit 2):
157
+
158
+ ```bash
159
+ tackbox-mdlint --repo-root <dir> --link-targets-from <list-file> \
160
+ [--files-from <list-file>] [files...]
161
+ ```
162
+
163
+ - `--repo-root <dir>` - the repository root; linted files and targets are
164
+ normalized to it, so a call from a subdirectory resolves correctly.
165
+ - `--link-targets-from <list-file>` - the link-target inventory (below).
166
+ - `--files-from <list-file>` - the files to lint, one per line (additive to
167
+ positional paths; keeps the spawn under ARG_MAX).
168
+
169
+ The inventory list-file is LF-terminated, one `<kind>\t<path>` per line, paths
170
+ repo-relative:
171
+
172
+ - `F` - a linkable file (a source-set file, before generated/vendored
173
+ exclusion: existence is not linting).
174
+ - `L` - a tracked symlink; its target exists but is not dereferenced and its
175
+ fragment is not checked.
176
+ - `G` - a gitlink (submodule) root; a target under it is skipped.
177
+
178
+ The tackbox CLI builds this inventory from the git listing and passes it in;
179
+ the flags exist for that one caller and are a deliberate breaking change to the
180
+ public bin.
181
+
141
182
  ## Reporter recognition
142
183
 
143
184
  A call counts as a reporter only when its callee resolves to one of the
@@ -0,0 +1,155 @@
1
+ // Custom markdownlint rule: a Markdown file's character repertoire is checked
2
+ // only when the file declares one. Semantics are declaration-driven, not a
3
+ // strict-ASCII default (D017):
4
+ //
5
+ // * No marker -> the charset is not checked at all.
6
+ // * A marker -> every codepoint must be in the always-allowed ASCII base
7
+ // (U+0000-U+007F, the Markdown syntax alphabet) or in one of the declared
8
+ // named sets. Everything else is a finding.
9
+ //
10
+ // <!-- tackbox: chars=cyrillic,punct -->
11
+ //
12
+ // Sets are named by character repertoire, not by language (a set proves nothing
13
+ // about the prose's language). The marker lists them comma-joined (union);
14
+ // tokens are trimmed, so a space after a comma is allowed. An invalid marker -
15
+ // an unknown set, an empty token, a duplicate set, a duplicate marker, or a
16
+ // marker below the fifth line - is a finding on the marker itself, and the
17
+ // content charset is then not checked (a broken declaration does not pass
18
+ // silently; there is no default to fall back to).
19
+ //
20
+ // The marker is read from micromark HTML-comment tokens, not params.lines:
21
+ // markdownlint masks HTML-comment interiors in `lines`, so the raw code is
22
+ // only visible in the parse tree.
23
+
24
+ // Named character sets: extra codepoints a set adds beyond the ASCII base. Add
25
+ // a set by adding one entry (its script range(s) and/or individual points).
26
+ const CHAR_SETS = {
27
+ // Declares the check with no extension; the ASCII base is always allowed.
28
+ ascii: { ranges: [], points: [] },
29
+ // Cyrillic block U+0400-U+04FF in full.
30
+ cyrillic: { ranges: [[0x0400, 0x04ff]], points: [] },
31
+ // Typographic punctuation: em/en dash, guillemets, ellipsis, curly
32
+ // single/double quotes (incl. low opening quotes), NBSP.
33
+ punct: {
34
+ ranges: [],
35
+ points: [
36
+ 0x2014, 0x2013, 0x00ab, 0x00bb, 0x2026,
37
+ 0x2018, 0x2019, 0x201c, 0x201d, 0x201e, 0x201a, 0x00a0,
38
+ ],
39
+ },
40
+ }
41
+
42
+ const MARKER_MAX_LINE = 5
43
+ const MARKER_RE = /<!--\s*tackbox:\s*chars=([^>]*?)\s*-->/g
44
+
45
+ function collectMarkers(token, found) {
46
+ for (const m of token.text.matchAll(MARKER_RE)) {
47
+ const before = token.text.slice(0, m.index)
48
+ const lineOffset = (before.match(/\n/g) || []).length
49
+ const lastNl = before.lastIndexOf('\n')
50
+ found.push({
51
+ lineNumber: token.startLine + lineOffset,
52
+ list: m[1],
53
+ col: lineOffset === 0 ? token.startColumn + m.index : m.index - lastNl,
54
+ len: m[0].length,
55
+ })
56
+ }
57
+ }
58
+
59
+ // Every marker occurrence in the file's HTML comments: {lineNumber, list, col,
60
+ // len}. Walks the micromark tree; htmlFlow / htmlText carry the raw comment
61
+ // text (their children just re-slice it, so we do not descend).
62
+ function findMarkers(tokens) {
63
+ const found = []
64
+ const walk = (toks) => {
65
+ for (const t of toks) {
66
+ if (t.type === 'htmlFlow' || t.type === 'htmlText') {
67
+ collectMarkers(t, found)
68
+ continue
69
+ }
70
+ if (t.children && t.children.length) walk(t.children)
71
+ }
72
+ }
73
+ walk(tokens)
74
+ return found
75
+ }
76
+
77
+ // Validate the marker set; emit findings for a misplaced / duplicate / invalid
78
+ // marker. Return the allowed set list for a single valid marker (to check
79
+ // content against), or null (do not check content) for no marker or any invalid
80
+ // one.
81
+ function resolveMarkers(markers, onError) {
82
+ if (markers.length === 0) return null
83
+ const markerErr = (m, detail) =>
84
+ onError({ lineNumber: m.lineNumber, detail, range: [m.col, m.len] })
85
+
86
+ if (markers.length > 1) {
87
+ for (const dup of markers.slice(1)) {
88
+ markerErr(dup, 'duplicate tackbox chars marker (one marker per file)')
89
+ }
90
+ return null
91
+ }
92
+ const m = markers[0]
93
+ if (m.lineNumber > MARKER_MAX_LINE) {
94
+ markerErr(m, `tackbox chars marker must be within the first ${MARKER_MAX_LINE} lines`)
95
+ return null
96
+ }
97
+ const tokens = m.list.split(',').map((t) => t.trim())
98
+ if (tokens.some((t) => t === '')) {
99
+ markerErr(m, 'tackbox chars marker: empty character-set list')
100
+ return null
101
+ }
102
+ const seen = new Set()
103
+ const allowed = []
104
+ for (const t of tokens) {
105
+ if (seen.has(t)) {
106
+ markerErr(m, `tackbox chars marker: duplicate set '${t}'`)
107
+ return null
108
+ }
109
+ seen.add(t)
110
+ const set = CHAR_SETS[t]
111
+ if (!set) {
112
+ markerErr(m, `tackbox chars marker: unknown character set '${t}'`)
113
+ return null
114
+ }
115
+ allowed.push(set)
116
+ }
117
+ return allowed
118
+ }
119
+
120
+ function isAllowed(code, allowed) {
121
+ if (code <= 0x7f) return true
122
+ for (const set of allowed) {
123
+ for (const [lo, hi] of set.ranges) {
124
+ if (code >= lo && code <= hi) return true
125
+ }
126
+ if (set.points.includes(code)) return true
127
+ }
128
+ return false
129
+ }
130
+
131
+ module.exports = {
132
+ names: ['MD-CHARS', 'declared-chars'],
133
+ description: 'Character outside declared repertoire',
134
+ tags: ['charset'],
135
+ parser: 'micromark',
136
+ function: function rule(params, onError) {
137
+ const allowed = resolveMarkers(findMarkers(params.parsers.micromark.tokens), onError)
138
+ if (!allowed) return
139
+ params.lines.forEach((line, idx) => {
140
+ let col = 0
141
+ for (const ch of line) {
142
+ const code = ch.codePointAt(0)
143
+ if (!isAllowed(code, allowed)) {
144
+ onError({
145
+ lineNumber: idx + 1,
146
+ detail:
147
+ 'U+' + code.toString(16).toUpperCase() + ' (' + ch + ') is not in the declared character set',
148
+ range: [col + 1, ch.length],
149
+ })
150
+ }
151
+ col += ch.length
152
+ }
153
+ })
154
+ },
155
+ }
@@ -0,0 +1,275 @@
1
+ // Custom markdownlint rule: every relative Markdown link / image target must
2
+ // exist and stay inside the repo, and a #fragment into a target .md must name a
3
+ // real anchor (D018). Cross-file only - MD051 (enabled alongside) already holds
4
+ // same-file fragments.
5
+ //
6
+ // Target existence is decided against a whole-tree link-target inventory the
7
+ // tackbox CLI builds from the raw git listing, NOT fs.exists: a link to a
8
+ // gitignored file is broken in a clean clone, so it is a finding. The inventory
9
+ // is a factory input, so the rule is constructed per run with the repo root and
10
+ // the parsed inventory baked in.
11
+ //
12
+ // Anchor semantics follow the MD051 / GitHub contract. markdownlint does not
13
+ // export its heading-fragment helper, so the GitHub slugger is ported here and
14
+ // pinned by fixtures. A nested markdownlint lint is not an option: its per-file
15
+ // token cache is module-level and reset per file, so parsing a target inside a
16
+ // rule would corrupt the linted file's own results.
17
+
18
+ const fs = require('fs')
19
+ const path = require('path')
20
+
21
+ // RFC3986 scheme: an external URL (http, mailto, tel, data, ftp, ...) is out of
22
+ // scope - the rule is fully offline, no network anywhere.
23
+ const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/
24
+
25
+ // GitHub heading slugger, ported verbatim from markdownlint's md051
26
+ // convertHeadingToHTMLFragment minus the encodeURIComponent wrapper: anchors are
27
+ // compared decoded, so unicode headings (legal since the charset flip) match a
28
+ // literal or a percent-encoded link fragment alike.
29
+ function slug(text) {
30
+ return text
31
+ .toLowerCase()
32
+ .replace(/[^\p{Letter}\p{Mark}\p{Number}\p{Connector_Punctuation}\- ]/gu, '')
33
+ .replace(/ /gu, '-')
34
+ }
35
+
36
+ // Reduce a raw heading line to its plain inline text before slugging: images
37
+ // drop entirely (GitHub excludes their alt), links keep their visible text, raw
38
+ // HTML / autolinks drop, word-boundary underscore emphasis is removed (intraword
39
+ // `_` stays literal per CommonMark), and backslash escapes become the char. The
40
+ // remaining markup punctuation (`*`, backticks, brackets) is stripped by slug's
41
+ // own punctuation filter, so code spans and asterisk emphasis need no pass here.
42
+ function headingInlineText(raw) {
43
+ return raw
44
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
45
+ .replace(/!\[[^\]]*\](\[[^\]]*\])?/g, '')
46
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
47
+ .replace(/\[([^\]]*)\]\[[^\]]*\]/g, '$1')
48
+ .replace(/\[([^\]]*)\]/g, '$1')
49
+ .replace(/<[^>]*>/g, '')
50
+ .replace(/(^|[^\p{L}\p{N}])_{1,3}(?=\S)/gu, '$1')
51
+ .replace(/(?<=\S)_{1,3}($|[^\p{L}\p{N}])/gu, '$1')
52
+ .replace(/\\(.)/g, '$1')
53
+ }
54
+
55
+ const ID_RE = /<[^>]*?\bid\s*=\s*["']([^"']+)["']/gi
56
+ const NAME_RE = /<a\b[^>]*?\bname\s*=\s*["']([^"']+)["']/gi
57
+
58
+ function collectHtmlAnchors(line, anchors) {
59
+ let m
60
+ while ((m = ID_RE.exec(line)) !== null) anchors.add(m[1])
61
+ while ((m = NAME_RE.exec(line)) !== null) anchors.add(m[1])
62
+ }
63
+
64
+ function addHeadingAnchor(rawText, anchors, counts) {
65
+ const s = slug(headingInlineText(rawText))
66
+ if (s === '') return
67
+ const c = counts.get(s) || 0
68
+ // A duplicate heading slug gets the -1 / -2 ... suffix GitHub assigns.
69
+ if (c > 0) anchors.add(s + '-' + c)
70
+ anchors.add(s)
71
+ counts.set(s, c + 1)
72
+ }
73
+
74
+ // Anchor set of a target .md's text: heading slugs (with GitHub duplicate
75
+ // suffixes), HTML id= / <a name=> anchors, and #top (always valid). Text-based
76
+ // so no markdownlint reentrancy; generous extraction (extra anchors) only ever
77
+ // misses a broken link, never invents one.
78
+ function computeAnchors(text) {
79
+ const anchors = new Set(['top'])
80
+ const counts = new Map()
81
+ const lines = text.split(/\r?\n/)
82
+ let fence = null
83
+ for (let i = 0; i < lines.length; i++) {
84
+ const line = lines[i]
85
+ const open = line.match(/^ {0,3}(`{3,}|~{3,})/)
86
+ if (fence === null && open) {
87
+ fence = open[1][0]
88
+ continue
89
+ }
90
+ if (fence !== null) {
91
+ if (new RegExp('^ {0,3}' + fence + '{3,}[ \\t]*$').test(line)) fence = null
92
+ continue
93
+ }
94
+ const atx = line.match(/^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/)
95
+ if (atx) {
96
+ addHeadingAnchor((atx[2] || '').replace(/[ \t]+#+[ \t]*$/, ''), anchors, counts)
97
+ collectHtmlAnchors(line, anchors)
98
+ continue
99
+ }
100
+ const next = i + 1 < lines.length ? lines[i + 1] : ''
101
+ if (
102
+ line.trim() !== '' &&
103
+ !/^ {0,3}#/.test(line) &&
104
+ /^ {0,3}(=+|-+)[ \t]*$/.test(next)
105
+ ) {
106
+ addHeadingAnchor(line.trim(), anchors, counts)
107
+ }
108
+ collectHtmlAnchors(line, anchors)
109
+ }
110
+ return anchors
111
+ }
112
+
113
+ // Percent-decode without throwing (unlike decodeURIComponent): each maximal run
114
+ // of %XX escapes is decoded as UTF-8 bytes, invalid bytes become U+FFFD, and a
115
+ // stray % stays literal. A malformed link must never crash the lint.
116
+ function percentDecode(s) {
117
+ return s.replace(/(?:%[0-9A-Fa-f]{2})+/g, (seq) => {
118
+ const bytes = new Uint8Array(seq.length / 3)
119
+ for (let i = 0; i < bytes.length; i++) {
120
+ bytes[i] = parseInt(seq.slice(i * 3 + 1, i * 3 + 3), 16)
121
+ }
122
+ return new TextDecoder('utf-8').decode(bytes)
123
+ })
124
+ }
125
+
126
+ // Split a raw destination into { pathPart, fragment }, or null to skip it. The
127
+ // fragment is everything after the first '#'; the query before it is dropped.
128
+ // External schemes, absolute paths, empty and same-file (#...) destinations are
129
+ // skipped - the latter is MD051's job.
130
+ function splitDest(dest) {
131
+ if (dest === '' || SCHEME_RE.test(dest) || dest.startsWith('/') || dest.startsWith('#')) {
132
+ return null
133
+ }
134
+ const hash = dest.indexOf('#')
135
+ let before = hash >= 0 ? dest.slice(0, hash) : dest
136
+ const fragment = hash >= 0 ? dest.slice(hash + 1) : null
137
+ const q = before.indexOf('?')
138
+ if (q >= 0) before = before.slice(0, q)
139
+ if (before === '') return null
140
+ return { pathPart: percentDecode(before), fragment: fragment === null ? null : percentDecode(fragment) }
141
+ }
142
+
143
+ // The rule's own destination string(s), never a nested link/image's: prune the
144
+ // walk at nested link/image boundaries so `[![alt](img)](target)` reports both
145
+ // img and target, each against its own token.
146
+ function ownLinkData(linkToken) {
147
+ const data = { dest: null, ref: null, label: null }
148
+ const rec = (toks) => {
149
+ for (const t of toks) {
150
+ if (t !== linkToken && (t.type === 'link' || t.type === 'image')) continue
151
+ if (t.type === 'resourceDestinationString' && data.dest === null) data.dest = t.text
152
+ if (t.type === 'referenceString' && data.ref === null) data.ref = t.text
153
+ if (t.type === 'labelText' && data.label === null) data.label = t.text
154
+ if (t.children && t.children.length) rec(t.children)
155
+ }
156
+ }
157
+ rec([linkToken])
158
+ return data
159
+ }
160
+
161
+ function normLabel(s) {
162
+ return s.trim().replace(/\s+/g, ' ').toLowerCase()
163
+ }
164
+
165
+ // Every link / image in the file as { dest, line, endLine, startColumn,
166
+ // endColumn }: inline destinations plus reference / collapsed / shortcut ones
167
+ // resolved through the file's link definitions.
168
+ function collectLinks(tokens) {
169
+ const definitions = new Map()
170
+ const links = []
171
+ const walk = (toks) => {
172
+ for (const t of toks) {
173
+ if (t.type === 'definition') {
174
+ let label = null
175
+ let dest = null
176
+ const rec = (xs) => {
177
+ for (const x of xs) {
178
+ if (x.type === 'definitionLabelString' && label === null) label = x.text
179
+ if (x.type === 'definitionDestinationString' && dest === null) dest = x.text
180
+ if (x.children && x.children.length) rec(x.children)
181
+ }
182
+ }
183
+ rec(t.children || [])
184
+ if (label !== null && dest !== null) definitions.set(normLabel(label), dest)
185
+ }
186
+ if (t.type === 'link' || t.type === 'image') {
187
+ links.push({ token: t, data: ownLinkData(t) })
188
+ }
189
+ if (t.children && t.children.length) walk(t.children)
190
+ }
191
+ }
192
+ walk(tokens)
193
+ const out = []
194
+ for (const { token, data } of links) {
195
+ let dest = data.dest
196
+ if (dest === null) {
197
+ const key = data.ref && data.ref !== '' ? data.ref : data.label
198
+ if (key === null) continue
199
+ dest = definitions.get(normLabel(key))
200
+ if (dest === undefined || dest === null) continue
201
+ }
202
+ out.push({
203
+ dest,
204
+ line: token.startLine,
205
+ endLine: token.endLine,
206
+ startColumn: token.startColumn,
207
+ endColumn: token.endColumn,
208
+ })
209
+ }
210
+ return out
211
+ }
212
+
213
+ // Build the rule bound to `repoRoot` (absolute) and the parsed inventory. F =
214
+ // linkable files, L = tracked symlinks (exist, not dereferenced), G = gitlink
215
+ // roots (targets under them are skipped). dirs holds every ancestor prefix of an
216
+ // F/L entry, so a directory link resolves in O(1).
217
+ function makeRule({ repoRoot, F, L, G }) {
218
+ // realpath both the root and each linted file: a symlinked temp root (macOS
219
+ // /tmp -> /private/tmp) would otherwise make path.relative diverge, since a
220
+ // spawned node resolves process.cwd() through the symlink.
221
+ const absRoot = fs.realpathSync(path.resolve(repoRoot))
222
+ const dirs = new Set()
223
+ for (const p of [...F, ...L]) {
224
+ const parts = p.split('/')
225
+ for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join('/'))
226
+ }
227
+ const anchorCache = new Map()
228
+ const anchorsOf = (rel) => {
229
+ if (!anchorCache.has(rel)) {
230
+ anchorCache.set(rel, computeAnchors(fs.readFileSync(path.resolve(absRoot, rel), 'utf8')))
231
+ }
232
+ return anchorCache.get(rel)
233
+ }
234
+ const underGitlink = (rel) => G.some((g) => rel === g || rel.startsWith(g + '/'))
235
+
236
+ return {
237
+ names: ['MD-LINK', 'link-integrity'],
238
+ description: 'Broken relative link target',
239
+ tags: ['links'],
240
+ parser: 'micromark',
241
+ function: function rule(params, onError) {
242
+ const abs = fs.realpathSync(path.resolve(process.cwd(), params.name))
243
+ const relDir = path.relative(absRoot, abs).split(path.sep).join('/').replace(/[^/]*$/, '')
244
+ for (const link of collectLinks(params.parsers.micromark.tokens)) {
245
+ const split = splitDest(link.dest)
246
+ if (split === null) continue
247
+ const rel = path.posix.normalize(path.posix.join(relDir, split.pathPart)).replace(/\/$/, '')
248
+ const report = (detail) => {
249
+ const single = link.line === link.endLine
250
+ onError({
251
+ lineNumber: link.line,
252
+ detail,
253
+ range: single ? [link.startColumn, link.endColumn - link.startColumn] : undefined,
254
+ })
255
+ }
256
+ if (rel === '..' || rel.startsWith('../')) {
257
+ report('link target escapes the repository root: ' + split.pathPart)
258
+ continue
259
+ }
260
+ if (underGitlink(rel)) continue
261
+ if (F.has(rel)) {
262
+ if (split.fragment && rel.endsWith('.md') && !anchorsOf(rel).has(split.fragment)) {
263
+ report('link fragment not found in target: ' + rel + '#' + split.fragment)
264
+ }
265
+ continue
266
+ }
267
+ if (L.has(rel)) continue
268
+ if (dirs.has(rel)) continue
269
+ report('link target does not exist: ' + rel)
270
+ }
271
+ },
272
+ }
273
+ }
274
+
275
+ module.exports = { makeRule, computeAnchors, slug, headingInlineText, percentDecode, splitDest }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tackbox",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
4
4
  "description": "ESLint and Markdown lint plugins plus direct error-reporting helpers for JavaScript and TypeScript.",
5
5
  "license": "MIT",
6
6
  "main": "./js/eslint-plugin.js",
@@ -1,132 +0,0 @@
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
- }