tackbox 0.1.69 → 0.1.71

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
@@ -323,29 +323,40 @@ every lint and writes no clean-cache markers. A `java`-format clone that
323
323
  lies entirely within both files' headers (package, imports, leading
324
324
  comments) has no extractable code and is dropped before it is reported.
325
325
 
326
- ### Markdown: ASCII and the language marker
326
+ ### Markdown: declared charset
327
327
 
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.
328
+ The Markdown engine does not enforce Markdown or prose style. It runs the
329
+ four link-reference built-ins - `MD011` (reversed links), `MD042` (empty
330
+ links), `MD051` (in-file link fragments), `MD052` (reference links
331
+ defined) - plus `MD-CHARS`, which checks a file's character repertoire
332
+ against a declaration the file makes about itself.
332
333
 
333
- One HTML comment on one of the first five lines widens the alphabet for
334
- a single file:
334
+ The check is opt-in and declaration-driven. With no marker, the charset
335
+ is not checked. One HTML comment on one of the first five lines turns it
336
+ on and names the allowed sets:
335
337
 
336
338
  ```text
337
- <!-- tackbox: lang=ru personal experimental repo -->
339
+ <!-- tackbox: chars=cyrillic,punct -->
338
340
  ```
339
341
 
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.
342
+ With a marker present, every codepoint must be in the always-allowed
343
+ ASCII base (U+0000-U+007F) or in one of the declared sets; anything else
344
+ is a finding. The sets are named by character repertoire, not language:
344
345
 
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.
346
+ - `ascii` adds nothing (it declares the check with no extension);
347
+ - `cyrillic` adds the Cyrillic block U+0400-U+04FF;
348
+ - `punct` adds em/en dash, guillemets, ellipsis, curly and low quotes,
349
+ and NBSP.
350
+
351
+ Sets are comma-joined and unioned; a space after a comma is fine
352
+ (`chars=ascii, cyrillic`). Russian prose declares `chars=cyrillic,punct`;
353
+ a grep-friendly zone declares `chars=cyrillic`.
354
+
355
+ The marker strengthens the check, so it is not a suppression: it draws no
356
+ approval and does not appear in the escapes inventory. An invalid marker
357
+ (an unknown set, an empty list, a duplicate set, a duplicate marker, or a
358
+ marker past the fifth line) is itself a finding, and the content charset
359
+ is then not checked - a broken declaration does not pass silently.
349
360
 
350
361
  ## No configuration
351
362
 
@@ -396,8 +407,6 @@ ordinary comment token:
396
407
  engine also accepts a standalone single-line `/* ... */` (see its
397
408
  section).
398
409
  - Python: a `#` comment.
399
- - Markdown: the `tackbox: lang=` HTML comment (see the Markdown
400
- engine section) is the only Markdown marker.
401
410
  - Svelte: inside `<script>` blocks the `//` form works as in JS/TS;
402
411
  the template adds two forms - a `//` comment inside a `{...}`
403
412
  expression (line-adjacent, as ever) and an HTML comment
@@ -457,6 +466,74 @@ event, `dev.py check`, and CI reports until the entry lands or the
457
466
  marker is reverted. Removing a manifest line is free; a marker whose
458
467
  text, scope, or count changes needs its entry updated the same way.
459
468
 
469
+ ## Generated and vendored code
470
+
471
+ Committed code that carries a generated or vendored git attribute is
472
+ excluded from the whole lint - findings there are not fixable in the
473
+ file (they belong in the generator), and a suppression marker cannot
474
+ survive regeneration. tackbox honors exactly three attributes,
475
+ `linguist-generated`, `gitlab-generated`, and `linguist-vendored`,
476
+ read from `.gitattributes` the same way the host (GitHub, GitLab)
477
+ reads them - no exclude surface of tackbox's own. The best fix stays
478
+ organizational: generated code should normally not be committed at
479
+ all; this serves the forced residue.
480
+
481
+ A file is excluded when `git check-attr` reports one of the three as
482
+ set. Semantics by example:
483
+
484
+ ```text
485
+ gen/** linguist-generated
486
+ vendor/** linguist-vendored
487
+ gen/keep.go linguist-generated=false
488
+ ```
489
+
490
+ `gen/**` excludes everything under `gen/`; `vendor/**` the same for
491
+ vendored. Note `dir/**`, not `dir/` - gitattributes patterns, unlike
492
+ gitignore, do not match a trailing-slash directory form. `=false`
493
+ re-includes a single file inside an excluded tree (`gen/keep.go` above
494
+ is linted normally); `-attr` and `!attr` also leave a file in. Only
495
+ `set` / `=true` excludes.
496
+
497
+ The exclusion covers everything: per-file engines, the erclint Go
498
+ package run (a mixed package's excluded file is compiled but its
499
+ findings drop; a compile break still fails loudly), duplication, the
500
+ CodeClimate report, and the marker inventory - an excluded file's
501
+ markers are dead, so a manifest entry addressing one orphans. A lint
502
+ run whose scope touches excluded files prints one summary line:
503
+
504
+ ```text
505
+ excluded by attributes: 12 files in scope (tackbox escapes lists all)
506
+ ```
507
+
508
+ It counts unique excluded files in the current scope (absent at zero),
509
+ so scoped runs are not wallpapered with a global constant;
510
+ `tackbox escapes` lists the full population as `attribute-excluded`
511
+ entries.
512
+
513
+ Because the excluded population is where the lint, the marker
514
+ inventory, and host diff review are all blind, the agent hook makes
515
+ the two ways into it loud:
516
+
517
+ - adding a positive exclusion line (a bare `<attr>` or `<attr>=true`)
518
+ to any `.gitattributes` draws a PreToolUse ask, one joint ask per
519
+ edit listing every added line; removals, `=false`, `-attr`, and
520
+ non-exclusion lines are free;
521
+ - editing (or creating) a file that is effective-excluded draws an
522
+ ask naming the attributes.
523
+
524
+ Generators run through Bash and are unaffected - the boundary is that
525
+ the change stays in the commit/PR diff, and hosts collapse
526
+ excluded-file diffs, so reviewers must expand them.
527
+
528
+ `tackbox doctor` adds an informational `attributes` section (never a
529
+ check, no exit-code effect) naming local conditions that can make a
530
+ run diverge from a clean CI clone - an `info/attributes` or an
531
+ untracked/index-hidden `.gitattributes` carrier mentioning the
532
+ attributes, or a neutralized attribute source override. `tackbox
533
+ escapes --since <rev>` resolves the baseline's attributes as of the
534
+ rev and so needs git >= 2.40 (older git is a named infra error on the
535
+ `--since` path only; the plain listing needs no version bump).
536
+
460
537
  ## Runtime reporting helpers
461
538
 
462
539
  Direct reporting helpers ship per language; their shared runtime behavior -
@@ -483,9 +560,11 @@ Claude Code hook event on stdin and dispatches by `hook_event_name`:
483
560
  nothing, and the block repeats on every event until the tree is
484
561
  consistent. The authoritative gate stays pre-commit / CI.
485
562
  - **PreToolUse** asks for approval before a new `.tackbox/approvals`
486
- line or a new `.tackbox/reporters` line lands; removing one is
487
- free. Editing markers in code draws no Pre ask - the consistency
488
- check owns them.
563
+ line or a new `.tackbox/reporters` line lands, before a positive
564
+ exclusion line is added to any `.gitattributes`, and before an edit
565
+ to an attribute-excluded file (see "Generated and vendored code");
566
+ removing a line is free. Editing markers in code draws no Pre ask -
567
+ the consistency check owns them.
489
568
 
490
569
  Only markers in files an engine would lint participate in the check
491
570
  (D012): a marker in a Go `testdata/` path or a non-lintable fixture
@@ -522,8 +601,8 @@ cheap command that review tooling of any harness can consume (D013). It
522
601
  enumerates:
523
602
 
524
603
  - **suppression markers** (`// no-report`, `// parse-skip`,
525
- `// nil-return`, `// long-comment`, `// test-skip`, `// dup-ok`, plus
526
- the markdown `tackbox: lang=` marker), each with its reason;
604
+ `// nil-return`, `// long-comment`, `// test-skip`, `// dup-ok`), each
605
+ with its reason;
527
606
  - **`.tackbox/reporters` declarations** - the tier-2 sinks;
528
607
  - **notify / quiet lane choices** - the call sites of the user-lane-only
529
608
  `notify` and the telemetry-only `quiet` verbs.
@@ -543,9 +622,11 @@ uvx tackbox@latest escapes --since origin/main --context 5
543
622
 
544
623
  ```json
545
624
  {
546
- "version": 1,
625
+ "version": 2,
547
626
  "since": null,
548
627
  "entries": [
628
+ {"kind": "attribute-excluded", "file": "gen/api.pb.go",
629
+ "attribute": "linguist-generated"},
549
630
  {"kind": "marker", "file": "a/b.py", "line": 12,
550
631
  "text": "no-report: central boundary already captures it",
551
632
  "reason": "central boundary already captures it",
@@ -559,29 +640,39 @@ uvx tackbox@latest escapes --since origin/main --context 5
559
640
  {"kind": "quiet-site", "file": "go/x.go", "line": 9,
560
641
  "text": "report.Quiet(ctx, ...)", "context": ["..."]}
561
642
  ],
562
- "counts": {"marker": 1, "reporter-decl": 1, "notify-site": 1, "quiet-site": 1}
643
+ "counts": {"marker": 1, "reporter-decl": 1, "notify-site": 1,
644
+ "quiet-site": 1, "attribute-excluded": 1}
563
645
  }
564
646
  ```
565
647
 
566
- - `version` is the schema version (`1`); `counts` always carries all four
567
- kinds, even at zero, so consumers see a stable shape.
648
+ - `version` is the schema version (`2`); `counts` always carries all five
649
+ kinds, even at zero, so consumers see a stable shape. Every count is an
650
+ entry count except `attribute-excluded`, which counts unique files.
568
651
  - `since` echoes the `--since` rev, or `null`.
652
+ - `attribute-excluded` entries carry only `kind` / `file` / `attribute` (no
653
+ line or text): the whole file is the bypass, one entry per set attribute
654
+ of the three (`linguist-generated`, `gitlab-generated`,
655
+ `linguist-vendored`). See "Generated and vendored code".
569
656
  - `text` is the trimmed source line; for a marker it runs from the marker
570
657
  keyword to end of line.
571
658
  - `reason` (markers only) is what follows the keyword's colon, trimmed -
572
- possibly empty (the `tackbox: lang=` marker carries none).
659
+ possibly empty.
573
660
  - `context` is the surrounding source, `--context N` lines each side
574
661
  (default 3), inclusive of the entry line itself - the window
575
662
  `[line-N, line+N]`, clipped at file edges, each line trimmed of trailing
576
663
  whitespace. It is plain source; the entry line is not marked.
577
- - `entries` are sorted by `(file, line)` for stable output.
664
+ - `entries` are sorted by `(file, kind, kind-subkey)` - the subkey is
665
+ `(line, text)` for the line-bearing kinds and `(attribute,)` for
666
+ `attribute-excluded`.
578
667
 
579
668
  ### Scope and detection
580
669
 
581
670
  The scan covers the same lintable source set the linter would scan (the
582
671
  D012 predicate: extension match plus each engine's path filter, so a Go
583
- `testdata/` file is out), plus the root `.tackbox/reporters` (every
584
- non-empty line is one declaration - the file has no comment syntax).
672
+ `testdata/` file is out) minus the attribute-excluded files, plus the root
673
+ `.tackbox/reporters` (every non-empty line is one declaration - the file has
674
+ no comment syntax). An attribute-excluded file's own markers are dead, so it
675
+ surfaces only as its `attribute-excluded` entries.
585
676
  notify / quiet call sites are detected **textually per language**
586
677
  (`report_quiet` / `notify` in Python, `reportQuiet` / `notify` in the JS
587
678
  family, `.Quiet(` / `.Notify(` in Go, `.quiet(` / `.notify(` in Java),
@@ -592,12 +683,19 @@ this is observability, not a lint.
592
683
  ### `--since <rev>`
593
684
 
594
685
  `--since <rev>` prints only entries **new against `<rev>`**, compared by
595
- content identity `(kind, file, text)` - the same extraction run against
596
- the tree at `<rev>` (via `git ls-tree` + `git show`) subtracted, count
597
- aware, from the current tree's entries. It over-reports on moved code (a
598
- new file path is a new identity) but never silently drops an entry - the
599
- conservative direction for a review aid. A bad rev is the one infra error:
600
- one stderr line, exit 1.
686
+ content identity (`(kind, file, text)`, or `(kind, file, attribute)` for
687
+ attribute-excluded) - the same extraction run against the tree at `<rev>`
688
+ (via `git ls-tree` + `git show`) subtracted, count aware, from the current
689
+ tree's entries. The baseline is attribute-aware: it resolves the attributes
690
+ as of `<rev>` (via the seam's `git check-attr --source`), so an attribute
691
+ added since the rev reports its newly-excluded files, a removed one
692
+ re-activates its markers as new (never a silent subtraction), and an
693
+ unchanged one adds no noise. Because `--source` needs git >= 2.40, an older
694
+ git is a named infra error on the `--since` path only (the plain listing
695
+ needs no version bump). It over-reports on moved code (a new file path is a
696
+ new identity) but never silently drops an entry - the conservative direction
697
+ for a review aid. A bad rev is the other infra error: one stderr line,
698
+ exit 1.
601
699
 
602
700
  ## Layout
603
701
 
@@ -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.69",
3
+ "version": "0.1.71",
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
- }