docbridge 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.0] - 2026-10-12
11
+
12
+ ### Added
13
+
14
+ - `doc_anchor_not_found` suggests the closest anchor in the same Markdown file
15
+ with `Did you mean`, for both `@doc` annotations and link manifest entries.
16
+ Anchors in other doc files are not suggested, and no suggestion is added when
17
+ no anchor is close enough.
18
+
19
+ ## [0.14.0] - 2026-10-08
20
+
21
+ ### Changed
22
+
23
+ - A heading anchor keeps `_`, as GitHub's heading slugs do. Before, `_` became
24
+ `-`, so `### title_definitions` gave `#title-definitions`; it now gives
25
+ `#title_definitions`. A link written with the old `-` form now produces
26
+ `doc_anchor_not_found`; change it to match the heading, such as
27
+ `#title_definitions`.
28
+
10
29
  ## [0.13.1] - 2026-10-06
11
30
 
12
31
  ### Fixed
@@ -558,7 +577,9 @@ Initial release of the SpecLink CLI.
558
577
  - `speclink check` command with `--root`, `--json`, and `--audit` options.
559
578
  - `speclink --version` (alias `-v`) and `speclink --help` (alias `-h`).
560
579
 
561
- [Unreleased]: https://github.com/salan70/docbridge/compare/v0.13.1...HEAD
580
+ [Unreleased]: https://github.com/salan70/docbridge/compare/v0.15.0...HEAD
581
+ [0.15.0]: https://github.com/salan70/docbridge/releases/tag/v0.15.0
582
+ [0.14.0]: https://github.com/salan70/docbridge/releases/tag/v0.14.0
562
583
  [0.13.1]: https://github.com/salan70/docbridge/releases/tag/v0.13.1
563
584
  [0.13.0]: https://github.com/salan70/docbridge/releases/tag/v0.13.0
564
585
  [0.12.0]: https://github.com/salan70/docbridge/releases/tag/v0.12.0
@@ -1,4 +1,4 @@
1
- 41027ff70b494c96a27c0a4e373a070b47222323d0fb8941f4e694eeaa2f2ad9 docbridge-swift-scanner
2
- 42e6858926db804a07efc8b370b0744e9b23c691b8a363461e5286115c7950ef docbridge_dart_scanner
1
+ e07fdf1803763dd68bdbb274d2ffe439a5e9bdf19cb1d450d2fb163e0e387752 docbridge-swift-scanner
2
+ eddd64885f3c9071db22459cbcb1665f717c22bf9bd682ab3943b28338023f38 docbridge_dart_scanner
3
3
  7fc3e6842b3e1bf425b8fd63e241eee7e64875f16be7ba8d1ae4a25baa7f2338 docbridge-rust-scanner
4
- 720b5a88fda4a2ffd10660bae19dd5070989fbe5c9463421a180f17bb50b7915 docbridge-go-scanner
4
+ 03b0a4dc30dd5e6a4958aa8d4ee1a6731c6efa7f6a90782c131641f0e02d084c docbridge-go-scanner
@@ -1,4 +1,4 @@
1
- 3f2005f4df20fcf6001111724eac5a9ece412edb5457e3450bd67420e85ba8e3 docbridge-swift-scanner
1
+ 8b37cbbfd3afbad16d9b2f1a1a63c6f6db50e63573cdb7c0166ab2a7ea4d5f9a docbridge-swift-scanner
2
2
  b75b14006ca792ed3ca8faf7ceee9e4d33c895f27dd416b640818cf8a4bdd9c8 docbridge_dart_scanner
3
3
  e0da7b8cc6ee23704f5fe8514cc309f0bb60380c77b55e3bc5ed113abd1f3478 docbridge-rust-scanner
4
- 2b8820630246507cdaca9de288dc8a0eb5d870db36287cf8ffd6ec31ce13b328 docbridge-go-scanner
4
+ 77fbcc7637f0719cc2628e8bcbaf29d0f84651a578a6b324ea6458c9c717b328 docbridge-go-scanner
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/dist/bin/linux-x64/docbridge-swift-scanner
3
3
  size: 40932352 bytes
4
- sha256: 3f2005f4df20fcf6001111724eac5a9ece412edb5457e3450bd67420e85ba8e3
4
+ sha256: 8b37cbbfd3afbad16d9b2f1a1a63c6f6db50e63573cdb7c0166ab2a7ea4d5f9a
package/dist/index.js CHANGED
@@ -175581,7 +175581,7 @@ import { join as join15, resolve as resolve5 } from "node:path";
175581
175581
  // package.json
175582
175582
  var package_default = {
175583
175583
  name: "docbridge",
175584
- version: "0.13.1",
175584
+ version: "0.15.0",
175585
175585
  description: "A CLI that links Markdown specifications and code declarations. Runs on Node.js and Bun.",
175586
175586
  keywords: [
175587
175587
  "bun",
@@ -177171,6 +177171,51 @@ function pluralize(word, count) {
177171
177171
  return count === 1 ? word : `${word}s`;
177172
177172
  }
177173
177173
 
177174
+ // src/shared/suggest.ts
177175
+ function nearestMatch(input, candidates) {
177176
+ const ranked = candidates.map((candidate, index) => ({
177177
+ candidate,
177178
+ distance: editDistance(input, candidate),
177179
+ index
177180
+ })).toSorted((left, right) => left.distance - right.distance || left.index - right.index);
177181
+ const abbreviation = ranked.find(({ candidate }) => isOrderedAbbreviation(input, candidate));
177182
+ if (abbreviation !== undefined) {
177183
+ return abbreviation.candidate;
177184
+ }
177185
+ const best = ranked[0];
177186
+ if (best === undefined) {
177187
+ return;
177188
+ }
177189
+ const closeEnough = best.distance <= Math.max(1, Math.floor(best.candidate.length / 2));
177190
+ return closeEnough ? best.candidate : undefined;
177191
+ }
177192
+ function isOrderedAbbreviation(input, candidate) {
177193
+ if (input.length < 3 || input.length >= candidate.length) {
177194
+ return false;
177195
+ }
177196
+ let candidateIndex = 0;
177197
+ for (const character of input) {
177198
+ const matchIndex = candidate.indexOf(character, candidateIndex);
177199
+ if (matchIndex === -1) {
177200
+ return false;
177201
+ }
177202
+ candidateIndex = matchIndex + 1;
177203
+ }
177204
+ return true;
177205
+ }
177206
+ function editDistance(left, right) {
177207
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
177208
+ for (let leftIndex = 1;leftIndex <= left.length; leftIndex += 1) {
177209
+ const current = [leftIndex];
177210
+ for (let rightIndex = 1;rightIndex <= right.length; rightIndex += 1) {
177211
+ const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
177212
+ current[rightIndex] = Math.min((current[rightIndex - 1] ?? Number.POSITIVE_INFINITY) + 1, (previous[rightIndex] ?? Number.POSITIVE_INFINITY) + 1, (previous[rightIndex - 1] ?? Number.POSITIVE_INFINITY) + substitutionCost);
177213
+ }
177214
+ previous = current;
177215
+ }
177216
+ return previous[right.length] ?? 0;
177217
+ }
177218
+
177174
177219
  // src/link/resolver.ts
177175
177220
  function resolveLinks(input) {
177176
177221
  const diagnostics = [];
@@ -177178,10 +177223,12 @@ function resolveLinks(input) {
177178
177223
  const docFilePaths = new Set(input.docFiles.map((file) => file.filePath));
177179
177224
  const codeFilePaths = new Set(input.codeFiles.map((file) => file.filePath));
177180
177225
  const docAnchorEndpoints = new Set;
177226
+ const docAnchorsByFile = new Map;
177181
177227
  for (const file of input.docFiles) {
177182
177228
  for (const anchor of file.anchors) {
177183
177229
  docAnchorEndpoints.add(anchor.endpoint);
177184
177230
  }
177231
+ docAnchorsByFile.set(file.filePath, file.anchors.map((anchor) => anchor.anchor));
177185
177232
  }
177186
177233
  const docToCodePairs = new Set;
177187
177234
  for (const file of input.docFiles) {
@@ -177216,7 +177263,7 @@ function resolveLinks(input) {
177216
177263
  continue;
177217
177264
  }
177218
177265
  if (!docAnchorEndpoints.has(docEndpoint)) {
177219
- diagnostics.push(relationshipDiagnostic("doc_anchor_not_found", link.source, docEndpoint, `Doc anchor ${docEndpoint} referenced by ${link.source} does not exist.`, link));
177266
+ diagnostics.push(relationshipDiagnostic("doc_anchor_not_found", link.source, docEndpoint, withAnchorSuggestion(`Doc anchor ${docEndpoint} referenced by ${link.source} does not exist.`, docEndpoint, docAnchorsByFile), link));
177220
177267
  continue;
177221
177268
  }
177222
177269
  if (!docToCodePairs.has(pairKey(docEndpoint, link.source))) {
@@ -177330,6 +177377,11 @@ function unlinkedDocSectionDiagnostic(node, anchor) {
177330
177377
  }
177331
177378
  return diagnostic;
177332
177379
  }
177380
+ function withAnchorSuggestion(message, docEndpoint, docAnchorsByFile) {
177381
+ const docFilePath = filePathOf(docEndpoint);
177382
+ const suggestion = nearestMatch(fragmentOf(docEndpoint), docAnchorsByFile.get(docFilePath) ?? []);
177383
+ return suggestion === undefined ? message : `${message} Did you mean \`${docFilePath}#${suggestion}\`?`;
177384
+ }
177333
177385
  function pairKey(source, target) {
177334
177386
  return `${source}->${target}`;
177335
177387
  }
@@ -177348,51 +177400,6 @@ function relationshipDiagnostic(code, source, target, message, link) {
177348
177400
  return diagnostic;
177349
177401
  }
177350
177402
 
177351
- // src/shared/suggest.ts
177352
- function nearestMatch(input, candidates) {
177353
- const ranked = candidates.map((candidate, index) => ({
177354
- candidate,
177355
- distance: editDistance(input, candidate),
177356
- index
177357
- })).toSorted((left, right) => left.distance - right.distance || left.index - right.index);
177358
- const abbreviation = ranked.find(({ candidate }) => isOrderedAbbreviation(input, candidate));
177359
- if (abbreviation !== undefined) {
177360
- return abbreviation.candidate;
177361
- }
177362
- const best = ranked[0];
177363
- if (best === undefined) {
177364
- return;
177365
- }
177366
- const closeEnough = best.distance <= Math.max(1, Math.floor(best.candidate.length / 2));
177367
- return closeEnough ? best.candidate : undefined;
177368
- }
177369
- function isOrderedAbbreviation(input, candidate) {
177370
- if (input.length < 3 || input.length >= candidate.length) {
177371
- return false;
177372
- }
177373
- let candidateIndex = 0;
177374
- for (const character of input) {
177375
- const matchIndex = candidate.indexOf(character, candidateIndex);
177376
- if (matchIndex === -1) {
177377
- return false;
177378
- }
177379
- candidateIndex = matchIndex + 1;
177380
- }
177381
- return true;
177382
- }
177383
- function editDistance(left, right) {
177384
- let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
177385
- for (let leftIndex = 1;leftIndex <= left.length; leftIndex += 1) {
177386
- const current = [leftIndex];
177387
- for (let rightIndex = 1;rightIndex <= right.length; rightIndex += 1) {
177388
- const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
177389
- current[rightIndex] = Math.min((current[rightIndex - 1] ?? Number.POSITIVE_INFINITY) + 1, (previous[rightIndex] ?? Number.POSITIVE_INFINITY) + 1, (previous[rightIndex - 1] ?? Number.POSITIVE_INFINITY) + substitutionCost);
177390
- }
177391
- previous = current;
177392
- }
177393
- return previous[right.length] ?? 0;
177394
- }
177395
-
177396
177403
  // src/link/manifest-apply.ts
177397
177404
  function applyLinkManifest(input) {
177398
177405
  if (input.manifest.entries.length === 0) {
@@ -177476,7 +177483,7 @@ class ApplyState {
177476
177483
  return false;
177477
177484
  }
177478
177485
  if (!state.anchors.has(entry.docEndpoint)) {
177479
- this.report("doc_anchor_not_found", entry.docEndpoint, `Doc anchor ${entry.docEndpoint} declared in the link manifest does not exist.`, entry, "doc");
177486
+ this.report("doc_anchor_not_found", entry.docEndpoint, this.docAnchorNotFoundMessage(entry, state), entry, "doc");
177480
177487
  return false;
177481
177488
  }
177482
177489
  this.markAnnotated(state, entry.docEndpoint);
@@ -177546,6 +177553,12 @@ class ApplyState {
177546
177553
  const suggestion = nearestMatch(entry.code.fragment, candidates);
177547
177554
  return suggestion === undefined ? base : `${base} Did you mean \`${suggestion}\`?`;
177548
177555
  }
177556
+ docAnchorNotFoundMessage(entry, state) {
177557
+ const base = `Doc anchor ${entry.docEndpoint} declared in the link manifest does not exist.`;
177558
+ const candidates = state.file.anchors.map((anchor) => anchor.anchor);
177559
+ const suggestion = nearestMatch(entry.doc.fragment, candidates);
177560
+ return suggestion === undefined ? base : `${base} Did you mean \`${entry.doc.filePath}#${suggestion}\`?`;
177561
+ }
177549
177562
  report(code, target, message, entry, side, language) {
177550
177563
  const range = side === "code" ? entry.codeRange : entry.docRange;
177551
177564
  const diagnostic = {
@@ -177597,24 +177610,13 @@ function manifestLink(source, target, entry, side) {
177597
177610
  }
177598
177611
 
177599
177612
  // src/shared/cancelable.ts
177600
- function deferred() {
177601
- let settle;
177602
- const promise = new Promise((resolve2, reject) => {
177603
- settle = { resolve: resolve2, reject };
177604
- });
177605
- return {
177606
- promise,
177607
- resolve: (value) => settle?.resolve(value),
177608
- reject: (error) => settle?.reject(error)
177609
- };
177610
- }
177611
177613
  function settledCancelable(value) {
177612
177614
  return { promise: Promise.resolve(value), cancel: () => {
177613
177615
  return;
177614
177616
  } };
177615
177617
  }
177616
177618
  function mapCancelable(task, map) {
177617
- const mapped = deferred();
177619
+ const mapped = Promise.withResolvers();
177618
177620
  let cancelled = false;
177619
177621
  task.promise.then((value) => {
177620
177622
  if (cancelled) {
@@ -177640,7 +177642,7 @@ function isAbortError(error) {
177640
177642
  return error instanceof Error && error.name === "AbortError";
177641
177643
  }
177642
177644
  function cancelableSequence(body) {
177643
- const outcome = deferred();
177645
+ const outcome = Promise.withResolvers();
177644
177646
  let cancelled = false;
177645
177647
  let running;
177646
177648
  const step = async (next) => {
@@ -178539,7 +178541,7 @@ function workerTimeoutMs(fileCount) {
178539
178541
  return 30000 + 1000 * fileCount;
178540
178542
  }
178541
178543
  function runScannerWorkerProcess(input) {
178542
- const run = deferred();
178544
+ const run = Promise.withResolvers();
178543
178545
  let settled = false;
178544
178546
  let timer;
178545
178547
  let grace;
@@ -179495,7 +179497,7 @@ function stripClosingHashes(text) {
179495
179497
  }
179496
179498
  function toAnchor(headingText) {
179497
179499
  const lowered = headingText.toLowerCase();
179498
- const replaced = lowered.replace(/[^\p{L}\p{N}]+/gu, "-");
179500
+ const replaced = lowered.replace(/[^\p{L}\p{N}_]+/gu, "-");
179499
179501
  return replaced.replace(/^-+/, "").replace(/-+$/, "");
179500
179502
  }
179501
179503
  function matchCodeComment(line, filePath, lineNumber) {
@@ -255,8 +255,9 @@ Use the scanner-produced canonical symbol ID exactly.
255
255
  ## Heading anchors and reciprocity
256
256
 
257
257
  DocBridge creates anchors from ATX headings only. It lowercases the heading,
258
- collapses runs of whitespace and punctuation to `-`, preserves Unicode letters
259
- and numbers, and removes leading and trailing hyphens. `## Login Spec (v2)` is
258
+ collapses runs of whitespace and punctuation other than `_` to `-`, keeps `_`
259
+ as GitHub's heading slugs do, preserves Unicode letters and numbers, and removes
260
+ leading and trailing hyphens. `## Login Spec (v2)` is
260
261
  `#login-spec-v2`.
261
262
 
262
263
  Empty headings have no anchor; a `@code` annotation attached to an empty
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docbridge",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "A CLI that links Markdown specifications and code declarations. Runs on Node.js and Bun.",
5
5
  "keywords": [
6
6
  "bun",