From d3a201d1bdca103569d1b387b6684526ee1b0ceb Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:46:42 +0800 Subject: [PATCH 01/16] docs: add audit bugfix implementation plan --- .../plans/2026-09-08-audit-bugfix-plan.md | 494 ++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md diff --git a/docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md b/docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md new file mode 100644 index 0000000..54f2fc1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md @@ -0,0 +1,494 @@ +# Audit Bugfix Implementation Plan + +**Goal:** 修复 2026-09-08 审查确认的全部 10 类问题,优先消除错误文件操作,再修复扫描误报、漏报、CLI 判定及报告展示。 + +**Architecture:** 保留八个扫描器、集中 action policy、修复确认和验证链。引用索引修复在公共入口完成;Markdown 修复按真实语法位置操作,CLI 复用必要的纯解析能力;YAML 使用标准解析器。展示修复不改变 fingerprint 或扫描判定。 + +**Tech Stack:** TypeScript、Obsidian API、Vitest、esbuild、CSS;拟新增构建依赖 `mdast-util-from-markdown`,将已有间接依赖 `js-yaml` 声明为直接构建依赖。 + +**Baseline:** `main` at `f1fa6502b9d50c2e848ebd6aed0230910497daa7`。PR #166 已合并;当前产品版本 0.8.0。基线完整门禁通过:56 个测试文件、780 项测试、8 个 npm 打包文件。 + +**Status:** 仅计划;下列执行项均未实施。本计划不授权自动发布版本或操作真实库中的内容。 + +## 1. 完整问题清单与提交映射 + +编号沿用审查的逻辑顺序。10 类问题展开为 11 个修复提交;最后 1 个文档与整体验收提交,总计 12 个实现提交。计划文档自身如需提交,单独使用 `docs: add audit bugfix implementation plan`,不计入实现提交。 + +| 问题 | 优先级 | 明确覆盖的根因/场景 | 提交 | +|---|---|---|---| +| B01 引用索引遗漏片段引用 | P1 | PDF `#page`、笔记 `#heading`、块 `#^id`、同文件片段;body link/embed/frontmatter 三种入口 | C01 | +| B02 修复链接越界改写 | P1 | 缩进代码块、转义字面量;保留已有围栏、行内代码、HTML 注释保护 | C02 | +| B03 有效块引用误报 | P2 | `cache.blocks` 已存在却只查 headings;CLI 同样需要提供 blocks | C03 | +| B04 同文件标题链接漏报 | P2 | Wiki/Markdown 的空文件部分没有回到 sourcePath | C04 | +| B05 外链合法右括号被截断 | P2 | 平衡括号保留、句末多余括号去除、metadata/body 重复提取 | C08 | +| B06 CLI fail-on 优先级错误 | P2 | 显式 `--fail-on any` 与默认 any 无法区分 | C05 | +| B07 CLI 合法链接误报 | P2 | B07a URL 编码路径;B07b 平衡括号目标,两个独立根因 | C06、C07 | +| B08 CLI YAML 类型误报 | P2 | B08a 行尾注释;B08b 无缩进 sequence;兼顾标量、嵌套结构和错误输入 | C09 | +| B09 新增摘要口径错误 | P2 | headline 少算 candidate/unverified/info;CTA 与摘要、既存筛选一致 | C10 | +| B10 含逗号路径拆坏 | P2 | 展示、点击跳转、Markdown 导出均误拆 evidence.paths | C11 | + +不把以下事项混入已确认 bug:并发编辑覆盖窗口、真实桌面 metadata 更新时序、Canvas 损坏与 Resolved 同时出现。这些仅作为针对性观察项,不能靠推测修改领域行为。CSS/Dataview 等明确未支持的引用来源不扩展到本轮。 + +## 2. 修复顺序与边界 + +按 C01 → C02 → C03 → C04 → C05 → C06 → C07 → C08 → C09 → C10 → C11 → C12 顺序集成。 + +- 第一阶段 C01–C04:文件操作安全与插件链接正确性。C02 新建解析模块,C03 的 CLI 块提取、C07 的 Markdown 提取可复用它。 +- 第二阶段 C05–C09:CLI 退出码、路径和 YAML;外链仍保持独立 scanner commit。 +- 第三阶段 C10–C11:摘要和路径展示。两个展示提交不掺入 scanner 修改。 +- 第四阶段 C12:全问题复现、包验证、真实 Obsidian 验收及文档收尾。 +- 测试设计可以并行,但修改 `cli/local-vault.ts` 的 C03/C06/C07/C09 必须串行集成,禁止多人同时重写该文件。 +- 不做大规模架构重构,不增加扫描器、设置模式、修复命令或网络默认开关。 +- 不更改公开 `Issue` / CLI JSON 字段类型;尤其不把 `evidence.paths` 从字符串改为数组。 +- 不放宽 blocked/review-required、duplicate keep choice、preflight、最终验证和 CLI read-only 边界。 +- 本轮不直接 bump 产品版本、打 tag、发布 npm 或 release;发布应在这些修复合并并验收后单独规划。 + +### 扫描基线兼容性 + +B01/B03/B04/B05/B07/B08 会改变发现集合,必须防止旧结果被当作可靠的 new/resolved 对照。C01 将 `COMPARISON_VERSION` 从当前 2 提升到 3,并补充本轮语义变化注释;保持 `SNAPSHOT_SCHEMA_VERSION = 1` 和 CLI `schemaVersion = 1`。 + +```ts +export const COMPARISON_VERSION = 3; +``` + +必须验证旧 comparisonVersion=2 的插件 snapshot 得到 `semantics-changed`,不输出假的 resolved;旧 CLI profile baseline 返回 2,包含明确重新生成提示,`--fail-on none` 也不能覆盖不兼容错误。legacy baseline 维持现有 fingerprint-only 警告和行为。 + +版本 3 覆盖同一未发布修复系列。若拆分 PR 且中途有版本发布,后续新的检测语义必须再提升 comparisonVersion,不能复用已经对外发布的语义编号。执行前核对最新 main 的编号,避免覆盖并行变更。 + +## 3. 公共执行和验证约定 + +每个 Cxx 都执行以下检查条目;任务内另外列出专属 fixture 和命令: + +- [ ] 确认本次修改的 source/test/doc 文件范围和前置提交。 +- [ ] 将下面列出的输入与预期写入持久回归测试,不依赖审查时 `/tmp` 文件。 +- [ ] 先运行专属测试,确认失败原因就是该 bug,而非 fixture/import 错误。 +- [ ] 实施方案,运行专属测试并确认通过。 +- [ ] 复查本次 diff,运行完整提交门禁并提交本任务涉及的文件。 + +每个实现提交前的完整门禁: + +```bash +npm run lint && npm run lint:obsidian-warnings && npm run build && npm test && npm pack --dry-run +``` + +预期全部退出 0;`npm pack --dry-run` 仍为既定 8 个文件。测试数量会增加,不能以“仍为 780”作为验收标准。缓存不可写时可用独立临时 npm cache;loopback 测试因权限失败时须在允许本地监听的环境重跑,不得删测试或禁用规则。 + +所有提交 conventional commits,英文、描述实际修改。不得 `eslint-disable obsidianmd/*`。工作分支建议 `fix/audit-correctness`;当前请求只产生计划,不创建实现分支。 + +## 4. C01 — 修复引用索引并建立新的比较基线 + +**Commit:** `fix: preserve fragment references in the reference index` + +**Files:** `src/scanner/reference-index.ts`;`src/snapshot/scan-snapshot.ts`;`src/tests/reference-index.test.ts`、`scanner-precision.test.ts`、`action-policy.test.ts`、`result-diff.test.ts`、`scan-snapshot.test.ts`、`cli.test.ts`。 + +**方案:** + +1. 在 `resolveTarget` 中剥离 `#` 子路径后,再调用 desktop `getFirstLinkpathDest`,不让每个 scanner 各自补救。 +2. 先判断原始值是否 URI,保留相对路径、大小写解析交给 Obsidian;不要对普通文件名盲目 URI decode。 +3. 对 `#Heading`/`#^block`,使用 sourcePath 作为当前文件引用。空字符串仍不构造引用。 +4. Obsidian resolver 存在而返回 null 时,不转入自定义 basename 猜测;fallback 只服务没有该 API 的适配器。 +5. test double 必须像 desktop 一样拒绝未剥离的片段,不能自动帮被测代码修复参数。 + +核心改法: + +```ts +const fragmentAt = link.indexOf("#"); +const linkpath = fragmentAt === -1 ? link : link.slice(0, fragmentAt); +const target = linkpath || (fragmentAt === 0 ? sourcePath : ""); +if (!target) return null; +return ctx.metadataCache.getFirstLinkpathDest(target, sourcePath)?.path ?? null; +``` + +保留现有 URI guard 和无 API fallback 分支;将比较版本修改与本次第一个检测修复原子提交。 + +**回归输入/断言:** + +- `![[manual.pdf#page=2]]` → `inboundByPath.get("manual.pdf").count === 1`,不再报告可删除孤立附件。 +- `[[Target#Heading]]`、`[[Target#^block]]`、frontmatter 中同样引用 → 指向 Target.md,reference kind 正确。 +- 同页 `[[#Heading]]` → sourcePath;不能计到空路径。 +- 包含普通链接和片段链接的重复组 → 被引用副本保留策略与 impact 计数正确。 +- 缺失缓存/坏 Canvas → coverageComplete 仍为 false;不能因片段归一化把失败当成功。 +- snapshot v2→v3、CLI baseline v2→v3 的不兼容行为按第 2 节验证。 + +```bash +npm test -- src/tests/reference-index.test.ts src/tests/scanner-precision.test.ts src/tests/action-policy.test.ts src/tests/result-diff.test.ts src/tests/scan-snapshot.test.ts src/tests/cli.test.ts +``` + +**验收:** 真实 Obsidian 的 PDF 页面嵌入 fixture 不出现 trash action;错误引用数为 0 的原始用例先红后绿。 + +## 5. C02 — 将链接修复限制在当前文本中的有效语法范围 + +**Commit:** `fix: restrict link fixes to parsed source ranges` + +**Files:** 新建 `src/utils/markdown-source.ts`、`src/tests/markdown-source.test.ts`;修改 `src/fix/fix-executor.ts`、`src/tests/fix-executor.test.ts`、`src/tests/fix-runner.test.ts`、`package.json`、`package-lock.json`。 + +**方案选择:** 不继续叠加全文 regex 的代码块例外。使用 `mdast-util-from-markdown` 解析当前读取的原文,获得源位置;直接按源区间拼接替换,绝不 AST stringify 整篇笔记。该库提供 Markdown AST,支持浏览器构建,但需由现有 esbuild 打包,不能把 ESM 外部依赖留给 Obsidian 运行时。 + +```ts +export type SourceRange = { start: number; end: number }; +export type MarkdownSourceLink = SourceRange & { + kind: "link" | "image"; + original: string; + destination: string; +}; +// 三个导出均在本任务实现并独立测试。 +export function markdownLinks(content: string): MarkdownSourceLink[]; +export function wikiLinkRanges(content: string): SourceRange[]; +export function blockIds(content: string): string[]; +``` + +接口职责和算法: + +- `markdownLinks`:遍历 AST 中标准 `link`/`image` 节点,读取 offset 与 URL;只返回 offset 完整且 `content.slice(start,end)` 是完整源语法的节点。暂不把引用式链接变成新的可修复种类。 +- `wikiLinkRanges`:在 AST 普通文本节点对应的原文范围内匹配 Wiki 语法;排除奇数个反斜杠转义的起始括号,保持 `![[...]]` 与 `[[...]]` 区分。不从 code、inlineCode、html、已有标准链接目标中识别 Wiki。 +- `blockIds`:从有效正文节点的行尾/独立块 ID 标记提取 ID,用于 C03;不从 code/html/comment/frontmatter 取 ID。不改变 Markdown 本身。 +- frontmatter 在解析前以保长空白遮罩,换行、字符 offset 必须不变;采用现有 splitFrontmatter 的同等边界,包括 BOM/CRLF。HTML 注释和原有保护测试必须继续成立。 +- 解析器不能可靠确认的语法不修改,返回 0/现有 skipped outcome;不回退全文匹配。 +- `original` 精确匹配、alias replacement、embed 删除仍按既有动作 metadata 决定。legacy `linkText` 路径也必须经过合法 Wiki ranges,不能保留旧全局替换漏洞。 +- 同一目标多处有效链接仍可按既有行为一起替换;只限制位置,不改变替换为显示文本的规则。 + +替换阶段核心算法(ranges 先按 start 排序并保证不重叠): + +```ts +let cursor = 0; +let updated = ""; +for (const { start, end } of ranges) { + updated += content.slice(cursor, start) + replacement; + cursor = end; +} +updated += content.slice(cursor); +if (updated === content) return 0; +``` + +**精确回归:** + +```ts +const original = "[Missing](missing.md)"; +const before = `${original}\n\n ${original}\n\n\\${original}`; +const expected = `Missing\n\n ${original}\n\n\\${original}`; +``` + +用现有 `makeApp`/`executeFixAction` 断言 `modify` 写入 expected。再分别覆盖 tab 缩进、列表/引用内代码、奇偶反斜杠、普通 Wiki、Wiki alias、embed、Markdown image、围栏/行内 code、HTML 注释、CRLF、无匹配和多处真实匹配。修复后原文非目标区间逐字节保持一致。 + +```bash +npm test -- src/tests/markdown-source.test.ts src/tests/fix-executor.test.ts src/tests/fix-runner.test.ts +``` + +**验收:** AST 只用于查源位置;插件包没有新增 Node runtime import。记录 main.js/cli.js 增量及大笔记解析耗时,不引入每个候选链接重复解析整篇文本的循环。C03/C07 复用本任务的解析输出。 + +## 6. C03 — 正确检查块引用,补齐 CLI blocks + +**Commit:** `fix: validate block references against block metadata` + +**Files:** `src/scanner/scanners/broken-links.ts`、`cli/local-vault.ts`、`src/utils/markdown-source.ts`;测试 `broken-links.test.ts`、`local-vault.test.ts`、`cli.test.ts`、`fix-executor.test.ts`。 + +**方案:** + +- `headingPart` 以 `^` 开头时走块 ID 判定,查 `cache.blocks`,不再送入标题 slugifier。 +- 沿用 Obsidian 实际块 ID 匹配的大小写行为;不能用标题 slugifier 删除 ID 字符后匹配。 +- 已有块不输出 finding,更不能生成 remove-link action;缺失块使用准确的块提示语。非块的标题逻辑独立保留。 +- CLI `LocalMetadata` 增加 blocks,并用 C02 的 `blockIds` 构造有效 ID lookup;适配类型不伪造未知源码位置给修复执行器。CLI 没有 mutation 路径。 +- 不更改无关 fingerprint 结构;缺失块沿用现有字段类型,语义差异由新的 comparisonVersion 隔离。 + +```ts +const hasBlock = Object.keys(cache.blocks ?? {}).some( + (id) => id.toLowerCase() === headingPart.slice(1).toLowerCase(), +); +``` + +**回归:** `[[Target#^valid-block]]`、alias、embed;缺失块;块 ID 位于代码示例时不算有效;Target 同时有同名标题也不能替代块。CLI 创建 `Target.md` 正文 `Body ^valid-block` 后运行 broken-links,预期不存在有效块误报。纯标题有效/缺失用例继续通过。 + +```bash +npm test -- src/tests/broken-links.test.ts src/tests/local-vault.test.ts src/tests/cli.test.ts src/tests/fix-executor.test.ts +``` + +## 7. C04 — 同文件片段链接使用源笔记校验 + +**Commit:** `fix: validate same-note heading links` + +**Files:** `src/scanner/scanners/broken-links.ts`;测试 `broken-links.test.ts`、`cli.test.ts`。 + +**方案:** 在空目标提前返回前判断是否存在片段;空文件部分加非空片段时,resolvedPath 使用 sourcePath,复用 C03 的块/标题判定。不得通过简单改 `getLinkTarget` 改坏其它调用方。 + +```ts +const sameNote = rawTarget === "" && linkText.startsWith("#"); +if ((!rawTarget && !sameNote) || hasUriScheme(rawTarget)) return []; +``` + +**回归:** `[[#Missing]]`、`[jump](#Missing)`、`![[#Missing]]` 均应报缺失标题;有效同页标题不报;`[[#^valid-block]]` 复用有效块判定;`#` 空锚点不当作缺失标题;外部 URL 的 `#fragment` 不查库内标题。`ignoreUnresolvedNoteLinks` 开启也不能隐藏缺失标题/块。 + +```bash +npm test -- src/tests/broken-links.test.ts src/tests/cli.test.ts +``` + +## 8. C05 — 显式 fail-on 参数优先于配置 + +**Commit:** `fix: honor explicit CLI fail-on thresholds` + +**Files:** `cli/cli.ts`、`src/tests/cli.test.ts`。 + +**方案:** 给内部 ParsedArgs 增加布尔字段记录是否显式输入 fail-on,默认 false;parseArgs 遇到参数时设 true,loadConfig 依据该字段选值。该内部字段不得进入 JSON 输出或 profile。 + +```ts +// ParsedArgs 增加 failOnExplicit: boolean,初始化为 false。 +options.failOn = value; +options.failOnExplicit = true; +// loadConfig 合并: +failOn: args.failOnExplicit ? args.failOn : config.failOn ?? args.failOn, +``` + +**回归矩阵:** 配置 none + 显式 any + 真实断链 → exit 1;配置 error + 显式 any + 仅 warning → exit 1;省略参数时遵循配置;无配置默认 any;显式 none 返回 0;旧 baseline 不兼容仍优先 exit 2。原 CLI 稳定字段快照不增加内部 flag。 + +```bash +npm test -- src/tests/cli.test.ts src/tests/scan-profile.test.ts +``` + +## 9. C06 — CLI 解码 Markdown 文件目标 + +**Commit:** `fix: resolve encoded Markdown paths in the CLI` + +**Files:** `cli/local-vault.ts`、`src/tests/local-vault.test.ts`、`src/tests/cli.test.ts`。 + +**方案:** URI decoding 只针对 Markdown destination 的路径和片段组件,原始 Wiki 文件名不统一 decode;先分离字面 `#fragment`,再各解码一次,避免 `%23` 被误当分隔符、`%25` 被重复解码。保持 `original` 原样,不能让修复文本变成解码后的字符串。 + +```ts +function decodeDestinationPart(value: string): string { + try { return decodeURIComponent(value); } + catch { return value; } +} +``` + +在 adapter 内用源文件 + 原始 destination 保存解析结果,路径 lookup 使用解码后的 path,片段校验使用解码后的 fragment;不要把已解码包含 `#` 的文件名重新拼回字符串再 `split('#')`。如当前 scanner API 需要通过字符串传递,增加内部结构化解析/lookup helper,使 path/subpath 只拆一次,不修改公开 JSON schema。 + +**回归:** `My%20Note.md`、百分号编码中文路径、`./`/`../`、路径内 `%23`/`%25`、编码标题、angle destination;合法 Wiki `[[My%20Note]]` 应仍能指向字面 `%20` 文件名;坏 `%` 序列不抛异常、不崩溃,按现有未解析链接处理。JSON evidence/original 的兼容性有明确断言。 + +```bash +npm test -- src/tests/local-vault.test.ts src/tests/cli.test.ts src/tests/broken-links.test.ts +``` + +## 10. C07 — CLI 使用完整 Markdown 链接节点 + +**Commit:** `fix: parse balanced Markdown link destinations in the CLI` + +**Files:** `cli/local-vault.ts`、`src/utils/markdown-source.ts`;测试 `markdown-source.test.ts`、`local-vault.test.ts`、`cli.test.ts`。 + +**方案:** 删除 `([^)]+)` 的标准 Markdown 链接提取分支,用 C02 的 `markdownLinks(content)` 遍历完整 link/image 节点,接入 C06 的 destination 归一化。不得重复扫描旧 regex 产生第二条截断记录。保留 Wiki、frontmatter、bare URLs 的独立现有入口;去重由原始目标和链接种类决定。 + +```ts +for (const parsed of markdownLinks(content)) { + const entry = { + link: parsed.destination, + original: parsed.original, + sourceRelative: true, + }; + if (parsed.kind === "image") embeds.push(entry); + else links.push(entry); +} +``` + +实际集成时 `entry.link` 经过 C06 约定的结构化 target adapter;不能重复 decode AST 已解析的转义字符。 + +**回归:** `Note(1).md`、嵌套括号、转义括号、angle target、可选标题、image/embed、空格编码;原始语法完整不截尾;indented/fenced/inline code 与转义链接不进入 metadata;缺失真实目标仍报错。CLI 孤立附件用同样链接 fixture,确保解析改善实际进入引用索引。 + +```bash +npm test -- src/tests/markdown-source.test.ts src/tests/local-vault.test.ts src/tests/cli.test.ts src/tests/reference-index.test.ts +``` + +## 11. C08 — 外链尾部括号按平衡关系裁剪 + +**Commit:** `fix: preserve balanced parentheses in external URLs` + +**Files:** `src/scanner/scanners/external-links.ts`、`src/tests/external-links.test.ts`。 + +**方案:** 保留现有句末标点策略,单独调整括号:尾部 `)` 只有在右括号数量大于左括号时才去除。重复去除时重新检查尾部,保留平衡括号和 `%28/%29`。不要为了此 bug 重写网络请求或扩大抓取范围。 + +```ts +while (/[),.;:!?]$/.test(trimmed)) { + if (trimmed.endsWith(")")) { + const opens = (trimmed.match(/\(/g) ?? []).length; + const closes = (trimmed.match(/\)/g) ?? []).length; + if (closes <= opens) break; + } + trimmed = trimmed.slice(0, -1); +} +``` + +**回归:** `https://en.wikipedia.org/wiki/Function_(mathematics)` 保持完整;`(https://example.com/a)` 去掉外部括号;`.../a_(b)).` 只去掉多余括号和句号;嵌套括号保留。提供 metadata 正确链接 + body 相同 URL 时,mock HTTP 只收到完整地址一次。测试禁止访问真实网站。 + +```bash +npm test -- src/tests/external-links.test.ts src/tests/cli.test.ts +``` + +## 12. C09 — CLI 使用标准 YAML 解析 + +**Commit:** `fix: parse CLI frontmatter with YAML semantics` + +**Files:** `cli/local-vault.ts`、`package.json`、`package-lock.json`、`src/tests/local-vault.test.ts`、`src/tests/cli.test.ts`;必要类型依赖只放 devDependencies。 + +**方案:** 使用 `js-yaml` 的 `load` 与 `CORE_SCHEMA` 替换手写逐行标量/数组解析,保留现有 frontmatter 分段逻辑。将当前 lockfile 已有的 4.2.0 声明为直接构建依赖,不能依赖 eslint 的间接依赖恰好存在。仅 CLI import,由 esbuild 打进 cli.js;main.js 不引入 Node YAML 路径。 + +```ts +import { CORE_SCHEMA, load } from "js-yaml"; +const value = load(section.frontmatter, { schema: CORE_SCHEMA }); +if (value === undefined || value === null) return {}; +if (typeof value !== "object" || Array.isArray(value)) { + throw new Error("Frontmatter must be a mapping"); +} +return value as Record; +``` + +- CORE_SCHEMA 保留 timestamp 为字符串、不启用自定义 JS 类型;布尔、数值、null、sequence、mapping 使用标准语义。 +- 无 frontmatter 返回 undefined;空 frontmatter 返回空对象。重复 key、非法 YAML、非 mapping 顶层明确失败,CLI 返回 setup error 2,不伪造干净扫描。 +- 错误信息仅带 vault-relative 文件名、行列及简短原因,不打印 YAML 原文、属性值或解析器含敏感内容的 snippet。 +- 不借本任务重构 frontmatterLinks 的提取,但必须确认 tag/property/large-file exclusions 在新值类型下仍正确。 + +**必测等价输入:** + +```yaml +# A.md frontmatter +flag: true # comment +tags: +- alpha +- beta +``` + +```yaml +# B.md frontmatter +flag: true +tags: [alpha, beta] +``` + +以上 frontmatter-types 结果应为 0。再覆盖引号中的 `#`、包含逗号的字符串数组元素、缩进 sequence、嵌套 mapping、null、空文件头、BOM/CRLF、非法/重复 key 和合法多行字符串。确认错误 stdout 为空,stderr 不泄露 fixture 中的哨兵敏感值。 + +```bash +npm test -- src/tests/local-vault.test.ts src/tests/frontmatter-types.test.ts src/tests/tag-usage.test.ts src/tests/cli.test.ts src/tests/cli-package.test.ts +``` + +## 13. C10 — 统一摘要与 Review new findings 的口径 + +**Commit:** `fix: align new finding summaries and review filters` + +**Files:** `src/report/render-summary.ts`、`src/report/InspectorView.ts`;测试 `render-summary.test.ts`、`inspector-view-filters.test.ts`;README 对应使用说明。 + +**决策:** 保留简洁文案 `N new findings`,让 N 表示所有未忽略的新增发现,包括 confirmed/candidate/unverified 和 info;不再把优先级子集冒充全部新增。自动扫描通知继续使用原有 `countNewConfirmedFindings`,避免扩大通知范围。 + +```ts +const newCount = result.issues.filter( + (issue) => comparison.statuses.get(issue.fingerprint) === "new", +).length; +``` + +CTA 改为幂等的“查看全部新增”:设置 status=new,清空 scanner、severity、classification;不再第二次点击反向取消。退出通过已有 Clear filters。这样点击前任何筛选不会悄悄缩小 headline 的结果。控件中仍可自行筛 confirmed,保留原功能。 + +**回归:** 单个 candidate/info → `1 new finding` 且 CTA 可用;混合 3 类、3 严重度 → headline 等于点击后实际显示数;ignored 不计;0 new 无 CTA;首次扫描/不兼容不伪造生命周期;重复点击 CTA 结果稳定;旧 scanner/severity/classification 全部清除;自动扫描通知测试完全保持原口径。 + +```bash +npm test -- src/tests/render-summary.test.ts src/tests/inspector-view-filters.test.ts src/tests/report-filters.test.ts src/tests/scan-scheduler.test.ts src/tests/main.test.ts +``` + +## 14. C11 — 重复文件展示使用结构化路径数组 + +**Commit:** `fix: preserve comma-containing paths in duplicate reports` + +**Files:** `src/report/render-issues.ts`、`src/report/markdown-export.ts`;测试 `render-issue-actions.test.ts`、`markdown-export.test.ts`、`cli.test.ts`。 + +**方案:** duplicate scanner 已将全部路径放入 `relatedPaths`。两个 renderer 直接使用该数组;不再把展示型 `evidence.paths` 按逗号反解析。保留 evidence.paths 的现有字符串形态,避免 CLI 协议漂移;不增加第三套 path 字段。 + +```ts +function getEvidencePaths(issue: Issue): string[] { + return issue.relatedPaths; +} +``` + +先核对此 helper 的所有调用仅服务 duplicate detail;如已有测试把 relatedPaths 填空、仅传 evidence.paths,改为真实 scanner fixture,不保留不可靠的逗号 fallback。 + +**回归:** `a,one.png`、`b.png` → 两个列表项;点击第一个触发 primaryPath=`a,one.png`;Markdown Files 仅两个完整路径;中文逗号、空格、三个副本排序不变;JSON `relatedPaths`、fingerprint、fixAction.targetPaths 不变。用实际 scanner→Markdown 的测试覆盖,避免只测人为组装正确数据。 + +```bash +npm test -- src/tests/render-issue-actions.test.ts src/tests/markdown-export.test.ts src/tests/cli.test.ts src/tests/duplicate-files.test.ts +``` + +## 15. C12 — 整体验收与用户文档 + +**Commit:** `docs: document corrected scan and CLI behavior` + +**Files:** `README.md`、`docs/cli.md`、本计划;集成测试若有缺口必须归回对应修复提交,不将多扫描器代码混进文档提交。 + +- [ ] README/CLI reference 准确说明块/同页链接、新增发现 CTA、旧 baseline 需要重新生成、YAML 错误退出 2。 +- [ ] 修正文档中 CLI HTTP transport 的失真说明:以 `cli/public-http.ts` 的实际 HEAD、405/501 Range GET fallback 为准;不声称 CLI 使用 runtime fetch。此为审查附带的低优先级文档问题,也在本轮收尾覆盖。 +- [ ] 运行完整门禁以及 `npm run test:coverage`,阈值不降低。 +- [ ] 将 `npm pack` 生成的包安装到临时目录,以包内命令测试 C05/C06/C07/C09 的 fixture;不只运行 TS 测试。确认 cli.js 不要求额外未打包依赖、Node 18 可启动。 +- [ ] 检查 `main.js` 的外部依赖仍在既定 Obsidian/Electron 边界;两种 parser 均不以运行时网络下载方式加载。 +- [ ] 记录最终构建大小和性能。至少用一份普通笔记、一份大量链接笔记确认不会对每次命中重复整篇解析;只读扫描 benchmark 对比 baseline,不用单次噪声宣称提升。 +- [ ] 使用 `/Users/Roger/my-vault` 下隔离测试目录进行以下验收;仅修改明确创建的 fixture,真实用户笔记不执行修复。必要文件操作遵守宿主权限。 + +### Obsidian 手工验收表 + +| 场景 | 必须观察的结果 | +|---|---| +| PDF page embed、note heading/block 引用 | 不报告孤立/无引用,不提供不安全批量删除入口 | +| 单个真断链 + 相同缩进代码/转义文本 | 只改真链接,其它内容逐字保留 | +| 有效/缺失块和同页标题 | valid 不报;missing 正确报,提示具体对象 | +| 混合 classification/severity 新增发现 | 摘要与 CTA 展示数量完全相同 | +| 重复文件名含逗号 | 名称不拆,点击打开正确文件,导出路径正确 | +| Duplicate reviewed fix、批量排除、preflight、最终验证 | 原有所有安全检查依然执行 | +| Light/dark、窄侧栏、Resolved 标签 | 文本可读、不溢出,#166 的对比度修复保持 | +| 升级前 snapshot | 明确 comparison restarted,不产生虚假的 resolved | + +### 完成标准 + +- [ ] B01–B10 全部有持久回归测试;B07a/B07b、B08a/B08b 均单独覆盖。 +- [ ] 两个 P1 有执行链验证,而不仅是函数返回值测试。 +- [ ] CLI 与 desktop 的 resolver 差异有契约测试,测试替身不再自动剥片段掩盖问题。 +- [ ] 修复后 12 个提交各自范围清晰,所有 scanner 修改独立于报告 UI commit。 +- [ ] 工作区干净;PR 的确切 head 已验证;CI verify 通过。 +- [ ] 未完成的手工验收明确标为未完成,不把单元测试通过当作完整上线验收。 + +## 16. 提交一览 + +| 顺序 | Commit message | 主要作用 | +|---|---|---| +| C01 | `fix: preserve fragment references in the reference index` | B01 + comparisonVersion 兼容性 | +| C02 | `fix: restrict link fixes to parsed source ranges` | B02 + 纯 Markdown 源位置解析 | +| C03 | `fix: validate block references against block metadata` | B03 + CLI blocks | +| C04 | `fix: validate same-note heading links` | B04 | +| C05 | `fix: honor explicit CLI fail-on thresholds` | B06 | +| C06 | `fix: resolve encoded Markdown paths in the CLI` | B07a | +| C07 | `fix: parse balanced Markdown link destinations in the CLI` | B07b | +| C08 | `fix: preserve balanced parentheses in external URLs` | B05 | +| C09 | `fix: parse CLI frontmatter with YAML semantics` | B08a + B08b | +| C10 | `fix: align new finding summaries and review filters` | B09 | +| C11 | `fix: preserve comma-containing paths in duplicate reports` | B10 | +| C12 | `docs: document corrected scan and CLI behavior` | 文档、覆盖清单、验收记录 | + +默认一个修复分支、12 个可独立审查的实现提交,CI 全绿后开 PR。若需要优先交付安全修复,可在 C04 后先开第一组 PR;剩余工作仍须完成 C05–C12,不得因为首组发布而缩减范围。实际提交/推送/PR/发布按后续明确执行请求处理。 + +## 17. 解析依赖依据 + +- [mdast-util-from-markdown 官方文档](https://github.com/syntax-tree/mdast-util-from-markdown):使用 `fromMarkdown` 获得 AST,支持浏览器,ESM 由现有构建打包;本轮只消费源位置和链接节点,不引入完整渲染框架。 +- [js-yaml 官方文档](https://github.com/nodeca/js-yaml):`load` 与 schema 配置用于标准 YAML 解析。实施时使用锁定依赖并测试实际 schema 行为,不依赖环境里偶然存在的包。 + +## 18. Execution log + +Execution baseline verified against origin/main: `f1fa650`. Work occurs on `fix/audit-correctness` in the existing checkout. + +Clarifications before implementation: + +- C02 source-range parsing must prove preservation with executable fixtures before replacing the executor's existing protection logic. Unsupported syntax must fail closed, never fall back to global replacement. +- C06 keeps raw link identity and original source intact. Decoding belongs to the CLI adapter; decoded file paths containing `#` must not be reparsed as fragments. Internal destination metadata may be added without changing public report fields. +- C09 invalid YAML is a setup failure with exit 2 and a sanitized filename/position message. This behavior must be explicitly tested and documented. + +| Task | State | Verification | +|---|---|---| +| C01 | pending | | +| C02 | pending | | +| C03 | pending | | +| C04 | pending | | +| C05 | pending | | +| C06 | pending | | +| C07 | pending | | +| C08 | pending | | +| C09 | pending | | +| C10 | pending | | +| C11 | pending | | +| C12 | pending | | From 79945fd1b4bc66cbfdcddcc5cc5b00b44c538183 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:50:18 +0800 Subject: [PATCH 02/16] fix: preserve fragment references in the reference index --- src/scanner/reference-index.ts | 5 +- src/snapshot/scan-snapshot.ts | 5 +- src/tests/cli.test.ts | 16 +++--- src/tests/main.test.ts | 2 +- src/tests/reference-index.test.ts | 89 ++++++++++++++++++++++++++++++- src/tests/result-diff.test.ts | 10 ++-- src/tests/scan-history.test.ts | 2 +- src/tests/scan-snapshot.test.ts | 4 +- 8 files changed, 113 insertions(+), 20 deletions(-) diff --git a/src/scanner/reference-index.ts b/src/scanner/reference-index.ts index 03311e5..68595bd 100644 --- a/src/scanner/reference-index.ts +++ b/src/scanner/reference-index.ts @@ -103,8 +103,11 @@ export async function buildReferenceIndex( // Guard the getFirstLinkpathDest branch; the fallback path re-checks // internally via hasUriScheme. if (!link || hasUriScheme(link)) return null; + const linkPath = link.split("#", 1)[0]; + // Same-note headings and blocks refer to the source file itself. + if (!linkPath) return sourcePath; if (typeof ctx.metadataCache.getFirstLinkpathDest === "function") { - return ctx.metadataCache.getFirstLinkpathDest(link, sourcePath)?.path ?? null; + return ctx.metadataCache.getFirstLinkpathDest(linkPath, sourcePath)?.path ?? null; } return resolveVaultLinkTargets(ctx, link, sourcePath)[0] ?? null; }; diff --git a/src/snapshot/scan-snapshot.ts b/src/snapshot/scan-snapshot.ts index 65a8238..4389d30 100644 --- a/src/snapshot/scan-snapshot.ts +++ b/src/snapshot/scan-snapshot.ts @@ -10,12 +10,15 @@ import { export const SNAPSHOT_SCHEMA_VERSION = 1; /** + * 3 — audited link, reference, and YAML detection corrections change which + * findings exist. Older baselines must not fabricate resolved/new findings. + * * 2 — external-link outcomes are classified per status (404/410 dead-link * candidates, 401/403 access-restricted, 429 rate-limited, 5xx server * error). Fingerprints for the reclassified findings changed identity, so * pre-2 snapshots cannot be compared without false resolved/new claims. */ -export const COMPARISON_VERSION = 2; +export const COMPARISON_VERSION = 3; export type SnapshotIssue = { fingerprint: string; diff --git a/src/tests/cli.test.ts b/src/tests/cli.test.ts index e01185c..7899025 100644 --- a/src/tests/cli.test.ts +++ b/src/tests/cli.test.ts @@ -92,7 +92,7 @@ describe("runCli", () => { persistingIssues: 0, resolvedIssues: 0, scanProfile: expect.any(String), - comparisonVersion: 2, + comparisonVersion: 3, fingerprints: expect.any(Array), }); // The identity set is the complete unfiltered set, not just the @@ -451,7 +451,7 @@ describe("runCli", () => { persistingIssues: 0, resolvedIssues: 0, scanProfile: expect.any(String), - comparisonVersion: 2, + comparisonVersion: 3, fingerprints: expect.any(Array), }); }); @@ -1206,7 +1206,7 @@ describe("runCli", () => { persistingIssues: 1, resolvedIssues: 0, scanProfile: expect.any(String), - comparisonVersion: 2, + comparisonVersion: 3, fingerprints: expect.any(Array), }); // The identity set is the complete unfiltered set. @@ -1253,7 +1253,7 @@ describe("runCli", () => { persistingIssues: 1, resolvedIssues: 1, scanProfile: expect.any(String), - comparisonVersion: 2, + comparisonVersion: 3, fingerprints: expect.any(Array), }); // The identity set is the complete unfiltered set: sorted and unique. @@ -1313,7 +1313,7 @@ describe("runCli", () => { persistingIssues: 1, resolvedIssues: 1, scanProfile: expect.any(String), - comparisonVersion: 2, + comparisonVersion: 3, fingerprints: expect.any(Array), }); expect(payload.issues.find( @@ -1426,7 +1426,7 @@ describe("runCli", () => { persistingIssues: 0, resolvedIssues: 0, scanProfile: expect.any(String), - comparisonVersion: 2, + comparisonVersion: 3, fingerprints: expect.any(Array), }); // No lifecycle annotations are fabricated from an incompatible baseline. @@ -1447,7 +1447,7 @@ describe("runCli", () => { "none", ]); const baseline = JSON.parse(first.stdout); - baseline.comparison.comparisonVersion = 3; + baseline.comparison.comparisonVersion = 2; const baselinePath = join(vaultPath, "baseline.json"); await writeFile(baselinePath, JSON.stringify(baseline), "utf8"); @@ -1473,7 +1473,7 @@ describe("runCli", () => { persistingIssues: 0, resolvedIssues: 0, scanProfile: expect.any(String), - comparisonVersion: 2, + comparisonVersion: 3, fingerprints: expect.any(Array), }); expect(payload.issues.every( diff --git a/src/tests/main.test.ts b/src/tests/main.test.ts index 07a216e..27c5aeb 100644 --- a/src/tests/main.test.ts +++ b/src/tests/main.test.ts @@ -257,7 +257,7 @@ describe("VaultInspectorPlugin", () => { const result = makeScanResult([makeLifecycleIssue("current")]); const { plugin, view } = makeScanSubject(result); const previous = createScanSnapshot(result, "current-profile", "0.4.13", 100); - previous.comparisonVersion++; + previous.comparisonVersion = 2; plugin.lastSuccessfulSnapshot = previous; await (plugin as any).scanAndRender(view); diff --git a/src/tests/reference-index.test.ts b/src/tests/reference-index.test.ts index 2f98449..fc17b2c 100644 --- a/src/tests/reference-index.test.ts +++ b/src/tests/reference-index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ScanContext } from "../scanner/ScanContext"; import { buildReferenceIndex, @@ -9,6 +9,10 @@ import { import { makeScanContext, makeTestFile } from "./helpers/scan-context"; import { loadFixtureVaultContext } from "./helpers/fixture-vault"; +import { orphanAttachmentsScanner } from "../scanner/scanners/orphan-attachments"; +import { duplicateFilesScanner } from "../scanner/scanners/duplicate-files"; +import { emptyNotesScanner } from "../scanner/scanners/empty-notes"; + function mdLink(link: string) { return { link, original: "", position: {} as any }; } @@ -342,3 +346,86 @@ describe("makeEmptyReferenceIndex", () => { expect(isReferenced(index, "anything.png")).toBe(false); }); }); + +// Obsidian resolves link paths, not full links with heading/block fragments. +function nativeReferenceContext(links: string[], embeds: string[] = [], frontmatter: string[] = []) { + const ctx = makeScanContext({ + files: [ + makeTestFile({ path: "source.md", size: 10 }), + makeTestFile({ path: "manual.pdf", size: 3 }), + makeTestFile({ path: "copy.pdf", size: 3 }), + makeTestFile({ path: "stub.md", size: 0 }), + ], + metadataByPath: { + "source.md": { + links: links.map(mdLink), + embeds: embeds.map(mdLink), + frontmatterLinks: frontmatter.map((link) => ({ key: "ref", ...mdLink(link) })), + }, + }, + overrides: { + vault: { + cachedRead: async () => "", + readBinary: async () => new Uint8Array([1, 2, 3]).buffer, + } as any, + }, + }); + ctx.metadataCache.getFirstLinkpathDest = vi.fn((link: string) => { + if (link.includes("#")) return null; + return ctx.allFiles.find((file) => file.path === link) ?? null; + }); + return ctx; +} + +describe("native reference resolution", () => { + it.each(["links", "embeds", "frontmatter"])("protects fragment-only PDF references from %s", async (kind) => { + const targets = ["manual.pdf#page=2"]; + const ctx = nativeReferenceContext( + kind === "links" ? targets : [], + kind === "embeds" ? targets : [], + kind === "frontmatter" ? targets : [], + ); + ctx.referenceIndex = await buildReferenceIndex(ctx); + expect((await orphanAttachmentsScanner.scan(ctx)).some((issue) => issue.primaryPath === "manual.pdf")).toBe(false); + expect(getInboundReference(ctx.referenceIndex, "manual.pdf")?.count).toBe(1); + expect(ctx.metadataCache.getFirstLinkpathDest).toHaveBeenCalledWith("manual.pdf", "source.md"); + }); + + it("combines ordinary and fragment counts and protects the referenced duplicate", async () => { + const ctx = nativeReferenceContext(["copy.pdf", "manual.pdf#page=2", "manual.pdf#page=3"]); + ctx.referenceIndex = await buildReferenceIndex(ctx); + expect(getInboundReference(ctx.referenceIndex, "manual.pdf")?.count).toBe(2); + expect(getInboundReference(ctx.referenceIndex, "copy.pdf")?.count).toBe(1); + const issues = await duplicateFilesScanner.scan(ctx); + expect(issues[0].fixAction).toMatchObject({ selection: { automaticKeepPath: "manual.pdf" } }); + }); + + it("protects referenced stubs and resolves same-note heading and block fragments", async () => { + const ctx = nativeReferenceContext(["stub.md#Heading", "#Heading", "#^block"]); + ctx.referenceIndex = await buildReferenceIndex(ctx); + expect(getInboundReference(ctx.referenceIndex, "source.md")?.count).toBe(2); + const issues = await emptyNotesScanner.scan(ctx); + expect(issues.find((issue) => issue.primaryPath === "stub.md")?.fixAction).toBeUndefined(); + expect(getInboundReference(ctx.referenceIndex, "stub.md")?.count).toBe(1); + }); + + it("preserves URI exclusion and does not guess after a native resolver miss", async () => { + const ctx = nativeReferenceContext(["https://example.com/manual.pdf#page=2", "manual.pdf#page=2"]); + ctx.metadataCache.getFirstLinkpathDest = vi.fn(() => null); + const index = await buildReferenceIndex(ctx); + expect(index.inboundByPath.size).toBe(0); + expect(ctx.metadataCache.getFirstLinkpathDest).toHaveBeenCalledTimes(1); + expect(ctx.metadataCache.getFirstLinkpathDest).toHaveBeenCalledWith("manual.pdf", "source.md"); + }); + + it.each(["missing-cache", "malformed-canvas"])("keeps orphan deletion fail-closed for %s", async (failure) => { + const ctx = nativeReferenceContext(["manual.pdf#page=2"]); + if (failure === "missing-cache") ctx.metadataCache.getFileCache = () => null; + else ctx.allFiles.push(makeTestFile("bad.canvas")); + ctx.referenceIndex = await buildReferenceIndex(ctx); + expect(ctx.referenceIndex.coverageComplete).toBe(false); + const issues = await orphanAttachmentsScanner.scan(ctx); + expect(issues.length).toBeGreaterThan(0); + expect(issues.every((issue) => issue.fixAction === undefined)).toBe(true); + }); +}); diff --git a/src/tests/result-diff.test.ts b/src/tests/result-diff.test.ts index 1cc957f..a2eabf3 100644 --- a/src/tests/result-diff.test.ts +++ b/src/tests/result-diff.test.ts @@ -76,7 +76,7 @@ describe("compareScanResult", () => { it("rejects changed comparison semantics before checking settings", () => { const snapshot = { ...makeSnapshot([makeIssue("previous")], [], "old-profile"), - comparisonVersion: 3, + comparisonVersion: 2, } as unknown as ScanSnapshot; expect(isScanSnapshot(snapshot)).toBe(true); @@ -186,23 +186,23 @@ describe("compareScanResult", () => { describe("resolveBaselineCompatibility", () => { it("accepts a matching comparison version and scan profile", () => { - expect(resolveBaselineCompatibility(2, "profile", "profile")).toBeNull(); + expect(resolveBaselineCompatibility(COMPARISON_VERSION, "profile", "profile")).toBeNull(); }); it("rejects a changed comparison version before checking settings", () => { - expect(resolveBaselineCompatibility(3, "profile", "profile")).toBe( + expect(resolveBaselineCompatibility(2, "profile", "profile")).toBe( "semantics-changed", ); }); it("prefers semantics-changed when both version and profile differ", () => { - expect(resolveBaselineCompatibility(3, "old-profile", "new-profile")).toBe( + expect(resolveBaselineCompatibility(2, "old-profile", "new-profile")).toBe( "semantics-changed", ); }); it("rejects a changed scan profile", () => { - expect(resolveBaselineCompatibility(2, "old-profile", "new-profile")).toBe( + expect(resolveBaselineCompatibility(COMPARISON_VERSION, "old-profile", "new-profile")).toBe( "settings-changed", ); }); diff --git a/src/tests/scan-history.test.ts b/src/tests/scan-history.test.ts index 2ec677b..75b49cc 100644 --- a/src/tests/scan-history.test.ts +++ b/src/tests/scan-history.test.ts @@ -116,7 +116,7 @@ describe("createScanHistoryEntry", () => { createdAt: 1_725_000_000_000, toolVersion: "0.7.0", scanProfile: "profile-abc", - comparisonVersion: 2, + comparisonVersion: 3, trigger: "manual", filesScanned: 3, scannersRun: ["broken-links", "empty-notes"], diff --git a/src/tests/scan-snapshot.test.ts b/src/tests/scan-snapshot.test.ts index 65f6853..a024ba3 100644 --- a/src/tests/scan-snapshot.test.ts +++ b/src/tests/scan-snapshot.test.ts @@ -63,10 +63,10 @@ describe("scan snapshots", () => { ); expect(SNAPSHOT_SCHEMA_VERSION).toBe(1); - expect(COMPARISON_VERSION).toBe(2); + expect(COMPARISON_VERSION).toBe(3); expect(snapshot).toEqual({ schemaVersion: 1, - comparisonVersion: 2, + comparisonVersion: 3, toolVersion: "0.5.0", createdAt: 1_725_000_000_000, scanProfile: "profile-abc", From 4096055ac9ede143e00fac4166b3e386c6e6973d Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:57:01 +0800 Subject: [PATCH 03/16] fix: restrict link fixes to parsed source ranges --- package-lock.json | 600 ++++++++++++++++++++++++++++++ package.json | 1 + src/fix/fix-executor.ts | 189 +--------- src/tests/fix-executor.test.ts | 49 ++- src/tests/fix-runner.test.ts | 20 + src/tests/markdown-source.test.ts | 41 ++ src/utils/markdown-source.ts | 89 +++++ 7 files changed, 813 insertions(+), 176 deletions(-) create mode 100644 src/tests/markdown-source.test.ts create mode 100644 src/utils/markdown-source.ts diff --git a/package-lock.json b/package-lock.json index f747a0b..43cfdaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@vitest/coverage-v8": "^4.1.9", "esbuild": "^0.28.1", "eslint-plugin-obsidianmd": "^0.4.1", + "mdast-util-from-markdown": "^2.0.3", "obsidian": "^1.13.1", "typescript": "^5.8.0", "vitest": "^4.1.9" @@ -1357,6 +1358,16 @@ "@types/tern": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1394,6 +1405,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.18", "dev": true, @@ -1410,6 +1438,13 @@ "@types/estree": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", @@ -2165,6 +2200,17 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2292,6 +2338,20 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2335,6 +2395,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2345,6 +2415,20 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4870,6 +4954,508 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -6234,6 +6820,20 @@ "dev": true, "license": "MIT" }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 1f21996..a97b5d3 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@vitest/coverage-v8": "^4.1.9", "esbuild": "^0.28.1", "eslint-plugin-obsidianmd": "^0.4.1", + "mdast-util-from-markdown": "^2.0.3", "obsidian": "^1.13.1", "typescript": "^5.8.0", "vitest": "^4.1.9" diff --git a/src/fix/fix-executor.ts b/src/fix/fix-executor.ts index f8119f1..0b2b161 100644 --- a/src/fix/fix-executor.ts +++ b/src/fix/fix-executor.ts @@ -1,5 +1,6 @@ import { App, TFile } from "obsidian"; import type { FixAction } from "../scanner/Issue"; +import { markdownLinks, wikiLinkRanges } from "../utils/markdown-source"; export async function executeFixAction(app: App, action: FixAction): Promise { switch (action.kind) { @@ -30,196 +31,36 @@ async function trashFiles(app: App, paths: string[]): Promise { } async function removeLinkText(app: App, sourcePath: string, linkText: string): Promise { - const file = app.vault.getAbstractFileByPath(sourcePath); - if (!(file instanceof TFile)) return 0; - - const content = await app.vault.read(file); - const pattern = new RegExp(`!?\\[\\[${escapeRegex(linkText)}\\]\\]`, "g"); - const protectedRanges = findProtectedMarkdownRanges(content); - let cursor = 0; - let updated = ""; - let removed = false; - - for (const match of content.matchAll(pattern)) { - const start = match.index; - const end = start + match[0].length; - if (protectedRanges.some((range) => start < range.end && end > range.start)) { - continue; - } - updated += content.slice(cursor, start); - cursor = end; - removed = true; - } - if (removed) updated += content.slice(cursor); - else updated = content; - if (updated === content) return 0; - - await app.vault.modify(file, updated); - return 1; + return replaceLinkText(app, sourcePath, undefined, "", linkText); } -/** - * Replace every unprotected occurrence of the literal `original` syntax with - * `replacement` ("" removes the range). Preferred over the legacy wiki - * pattern when the fix action carries exact source metadata. - */ +/** Parse once per action, then splice only complete, matching source ranges. */ async function replaceLinkText( app: App, sourcePath: string, - original: string, + original: string | undefined, replacement: string, + legacyLinkText?: string, ): Promise { const file = app.vault.getAbstractFileByPath(sourcePath); if (!(file instanceof TFile)) return 0; - const content = await app.vault.read(file); - // Negative lookbehind: a wiki original "[[x]]" is a substring of the embed - // "![[x]]" (and a markdown original of its image form "![](x)"). Non-embed - // actions must never consume an embed occurrence; embed actions carry the - // "!" in their original and match exactly. - const pattern = new RegExp(`(? { + const source = content.slice(start, end); + return original !== undefined + ? source === original + : source === `[[${legacyLinkText}]]` || source === `![[${legacyLinkText}]]`; + }).sort((left, right) => left.start - right.start); let cursor = 0; let updated = ""; - let replaced = false; - - for (const match of content.matchAll(pattern)) { - const start = match.index; - const end = start + match[0].length; - if (protectedRanges.some((range) => start < range.end && end > range.start)) { - continue; - } + for (const { start, end } of ranges) { + if (start < cursor) continue; updated += content.slice(cursor, start) + replacement; cursor = end; - replaced = true; } - if (replaced) updated += content.slice(cursor); - else updated = content; + updated += content.slice(cursor); if (updated === content) return 0; - await app.vault.modify(file, updated); return 1; } - -type TextRange = { - start: number; - end: number; -}; - -function findProtectedMarkdownRanges(content: string): TextRange[] { - const ranges = [ - ...findFencedCodeRanges(content), - ...findHtmlCommentRanges(content), - ]; - ranges.push(...findInlineCodeRanges(content, ranges)); - return mergeRanges(ranges); -} - -function findFencedCodeRanges(content: string): TextRange[] { - const ranges: TextRange[] = []; - let lineStart = 0; - let fence: { char: "`" | "~"; length: number; start: number } | null = null; - - while (lineStart < content.length) { - const newline = content.indexOf("\n", lineStart); - const lineEnd = newline === -1 ? content.length : newline + 1; - const line = content.slice(lineStart, newline === -1 ? content.length : newline); - - if (fence) { - const closingFence = new RegExp( - `^ {0,3}${escapeRegex(fence.char)}{${fence.length},}[\\t ]*$`, - ); - if (closingFence.test(line)) { - ranges.push({ start: fence.start, end: lineEnd }); - fence = null; - } - } else { - const openingFence = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); - if ( - openingFence - && (openingFence[1][0] === "~" || !openingFence[2].includes("`")) - ) { - fence = { - char: openingFence[1][0] as "`" | "~", - length: openingFence[1].length, - start: lineStart, - }; - } - } - lineStart = lineEnd; - } - - if (fence) ranges.push({ start: fence.start, end: content.length }); - return ranges; -} - -function findHtmlCommentRanges(content: string): TextRange[] { - const ranges: TextRange[] = []; - let searchFrom = 0; - - while (searchFrom < content.length) { - const start = content.indexOf("", start + 4); - const end = closing === -1 ? content.length : closing + 3; - ranges.push({ start, end }); - searchFrom = end; - } - return ranges; -} - -function findInlineCodeRanges(content: string, excludedRanges: TextRange[]): TextRange[] { - const ranges: TextRange[] = []; - let index = 0; - - while (index < content.length) { - if (content[index] !== "`" || containsIndex(excludedRanges, index)) { - index++; - continue; - } - - const start = index; - while (content[index] === "`") index++; - const marker = content.slice(start, index); - let closing = content.indexOf(marker, index); - while ( - closing !== -1 - && ( - content[closing - 1] === "`" - || content[closing + marker.length] === "`" - || containsIndex(excludedRanges, closing) - ) - ) { - closing = content.indexOf(marker, closing + marker.length); - } - if (closing === -1) continue; - - const end = closing + marker.length; - ranges.push({ start, end }); - index = end; - } - return ranges; -} - -function containsIndex(ranges: TextRange[], index: number): boolean { - return ranges.some((range) => index >= range.start && index < range.end); -} - -function mergeRanges(ranges: TextRange[]): TextRange[] { - const sorted = [...ranges].sort((left, right) => left.start - right.start); - const merged: TextRange[] = []; - - for (const range of sorted) { - const previous = merged[merged.length - 1]; - if (previous && range.start <= previous.end) { - previous.end = Math.max(previous.end, range.end); - } else { - merged.push({ ...range }); - } - } - return merged; -} - -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/src/tests/fix-executor.test.ts b/src/tests/fix-executor.test.ts index b9abcac..132d856 100644 --- a/src/tests/fix-executor.test.ts +++ b/src/tests/fix-executor.test.ts @@ -83,8 +83,7 @@ describe("executeFixAction", () => { "missing", "[[Target#Other heading|other]]", "[[Target|plain]]", - // The embed occurrence is NOT consumed: the literal pattern is - // anchored with a negative lookbehind for "!". + // A complete embed range cannot match a non-embed original. "![[Target#Missing heading|missing]]", ].join("\n"), ); @@ -199,3 +198,49 @@ describe("executeFixAction", () => { ); }); }); + + +describe("parsed source safety", () => { + it.each(["[Missing](missing.md)", "[[Missing]]", "[[Missing|Alias]]", "![[Missing]]", "![Missing](missing.md)"])("only replaces valid occurrences of %s", async (original) => { + const protectedText = [ + ` ${original}`, `\t${original}`, + `- item\n\n ${original}`, `> ${original}`, + `\\${original}`, `\\\\\\${original}`, + `\`${original}\``, `~~~md\n${original}\n~~~`, ``, + ].join("\n\n"); + const prefix = `\uFEFF---\r\nref: '${original}'\r\n---\r\n`; + const content = prefix + original + "\n\n" + protectedText + "\n\n" + original; + const { app, file, modify } = makeApp(content); + expect(await executeFixAction(app as any, { + kind: "remove-link-text", label: "Remove", description: "", targetPaths: ["Source.md"], original, replacement: "Shown", + })).toBe(1); + expect(modify).toHaveBeenCalledWith(file, prefix + "Shown\n\n" + protectedText + "\n\nShown"); + }); + + it("restricts legacy wiki removal to parsed ranges", async () => { + const content = "[[Missing]]\n\n [[Missing]]\n\n\\[[Missing]]"; + const { app, file, modify } = makeApp(content); + expect(await executeFixAction(app as any, { + kind: "remove-link-text", label: "Remove", description: "", targetPaths: ["Source.md"], linkText: "Missing", + })).toBe(1); + expect(modify).toHaveBeenCalledWith(file, "\n\n [[Missing]]\n\n\\[[Missing]]"); + }); +}); + + +it.each(["[Missing](missing.md)", "[[Missing]]"])("replaces links after even backslashes: %s", async (original) => { + const content = "\\\\" + original + "\r\n"; + const { app, file, modify } = makeApp(content); + expect(await executeFixAction(app as any, { + kind: "remove-link-text", label: "Remove", description: "", targetPaths: ["Source.md"], original, replacement: "Shown", + })).toBe(1); + expect(modify).toHaveBeenCalledWith(file, "\\\\Shown\r\n"); +}); + +it.each(["[[Missing|**bold**]]", "plain text", " [[Missing]]"])("fails closed for unsupported or non-link source: %s", async (content) => { + const { app, modify } = makeApp(content); + expect(await executeFixAction(app as any, { + kind: "remove-link-text", label: "Remove", description: "", targetPaths: ["Source.md"], original: content.trim(), replacement: "Shown", + })).toBe(0); + expect(modify).not.toHaveBeenCalled(); +}); diff --git a/src/tests/fix-runner.test.ts b/src/tests/fix-runner.test.ts index c7c90fe..032b86b 100644 --- a/src/tests/fix-runner.test.ts +++ b/src/tests/fix-runner.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type { FixAction, Issue, ScanResult } from "../scanner/Issue"; import { DEFAULT_SETTINGS } from "../settings/settings"; +import { TFile } from "obsidian"; +import { executeFixAction } from "../fix/fix-executor"; import { runFixBatch } from "../fix/fix-runner"; function action( @@ -388,3 +390,21 @@ describe("runFixBatch", () => { expect(execute).not.toHaveBeenCalled(); }); }); + + +it("verifies a parsed link fix without changing identical code examples", async () => { + const original = "[Missing](missing.md)"; + let content = `${original}\n\n ${original}\n\n\\${original}`; + const file = Object.assign(new TFile(), { path: "Source.md" }); + const modify = vi.fn(async (_file: TFile, updated: string) => { content = updated; }); + const app = { vault: { getAbstractFileByPath: () => file, read: async () => content, modify } }; + const requested = issue("link", action("Source.md", { kind: "remove-link-text", original, replacement: "Missing" })); + const scan = vi.fn().mockResolvedValueOnce(result([requested])).mockResolvedValueOnce(result([])); + const batch = await runFixBatch([requested], [{ fingerprint: "link" }], { + settings: () => DEFAULT_SETTINGS, scan, execute: (fix) => executeFixAction(app as any, fix), + }); + expect(content).toBe(`Missing\n\n ${original}\n\n\\${original}`); + expect(batch.outcomes[0].outcome).toBe("fixed"); + expect(scan).toHaveBeenCalledTimes(2); + expect(modify).toHaveBeenCalledTimes(1); +}); diff --git a/src/tests/markdown-source.test.ts b/src/tests/markdown-source.test.ts new file mode 100644 index 0000000..2d4d92b --- /dev/null +++ b/src/tests/markdown-source.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { blockIds, markdownLinks, wikiLinkRanges } from "../utils/markdown-source"; + +describe("Markdown source ranges", () => { + it("returns complete balanced links and images with exact offsets", () => { + const content = '\uFEFF---\r\nref: "[hidden](x)"\r\n---\r\n[x](Note(1).md "title") ![a]() [e](a\\(b\\).md)'; + const links = markdownLinks(content); + expect(links.map(({ destination }) => destination)).toEqual(["Note(1).md", "a b.png", "a(b).md"]); + expect(links.map(({ kind }) => kind)).toEqual(["link", "image", "link"]); + for (const link of links) expect(content.slice(link.start, link.end)).toBe(link.original); + expect(links[0].start).toBe(content.indexOf("[x]")); + }); + + it("excludes references, code, escaped links and HTML", () => { + const content = '[ref][id]\n\n[id]: dest\n\n [x](x)\n\n`[x](x)`\n\n\\[x](x)\n\n\n\n```\n[x](x)\n```'; + expect(markdownLinks(content)).toEqual([]); + }); + + it("recognizes plain Wiki text, aliases, embeds and even escapes only", () => { + const content = String.raw`[[One]] ![[Two|Alias]] \[[Odd]] \\[[Even]] \\\[[Odd3]] \![[NoEmbed]] [[Split|**bold**]] [label](<[[destination]]>) [with [[nested]]](target)`; + expect(wikiLinkRanges(content).map(({ start, end }) => content.slice(start, end))) + .toEqual(["[[One]]", "![[Two|Alias]]", "[[Even]]"]); + }); + + it("extracts only unescaped body block markers at the physical line end", () => { + const content = '\uFEFF---\r\nkey: value ^yaml\r\n---\r\nBody ^valid-block\r\n\r\n^standalone\n\n- item ^list\n\n> quote ^quote\n\n code ^code\n\n`inline ^inline`\n\n\n\nBody ^middle text\n\nBody \\^escaped\n\nBody \\\\^even\n\n[x ^label](dest)\n\nBody ^end \n\n```\nBody ^fenced\n```'; + expect(blockIds(content)).toEqual(["valid-block", "standalone", "list", "quote", "even", "end"]); + }); + + it("preserves offsets with a BOM but without frontmatter", () => { + const content = '\uFEFF[[Wiki]] [link](x)'; + expect(wikiLinkRanges(content)).toEqual([{ start: 1, end: 9 }]); + expect(markdownLinks(content)[0].start).toBe(10); + }); +}); + +it("includes both destinations of a linked image without treating its label as Wiki text", () => { + const content = '[![[[literal]]](image.png)](target.md)'; + expect(markdownLinks(content).map(({ destination }) => destination)).toEqual(["target.md", "image.png"]); + expect(wikiLinkRanges(content)).toEqual([]); +}); diff --git a/src/utils/markdown-source.ts b/src/utils/markdown-source.ts new file mode 100644 index 0000000..cec37ba --- /dev/null +++ b/src/utils/markdown-source.ts @@ -0,0 +1,89 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; +import type { Nodes } from "mdast"; + +export type SourceRange = { start: number; end: number }; +export type MarkdownSourceLink = SourceRange & { + kind: "link" | "image"; + original: string; + destination: string; +}; + +/** Mask file metadata without shifting UTF-16 offsets or changing line endings. */ +function parseBody(content: string) { + const frontmatter = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content); + const end = frontmatter?.[0].length ?? (content.startsWith("\uFEFF") ? 1 : 0); + return fromMarkdown(content.slice(0, end).replace(/[^\r\n]/g, " ") + content.slice(end)); +} + +function rangeOf(node: Nodes): SourceRange | undefined { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + return start !== undefined && end !== undefined && end > start ? { start, end } : undefined; +} + +/** Only ordinary body text is eligible for Obsidian syntax extensions. */ +function visit(node: Nodes, onLink: (node: Nodes) => void, onText: (range: SourceRange) => void): void { + if (node.type === "link" || node.type === "image") { + onLink(node); + // Linked images also carry a destination, but labels are not extension text. + if ("children" in node) for (const child of node.children) visit(child, onLink, () => {}); + return; + } + if (["code", "inlineCode", "html", "definition", "linkReference", "imageReference"].includes(node.type)) return; + if (node.type === "text") { + const range = rangeOf(node); + if (range) onText(range); + } + if ("children" in node) for (const child of node.children) visit(child, onLink, onText); +} + +function escaped(content: string, start: number): boolean { + let slashes = 0; + while (start > 0 && content[--start] === "\\") slashes++; + return slashes % 2 === 1; +} + +export function markdownLinks(content: string): MarkdownSourceLink[] { + const links: MarkdownSourceLink[] = []; + visit(parseBody(content), (node) => { + if (node.type !== "link" && node.type !== "image") return; + const range = rangeOf(node); + if (!range) return; + const original = content.slice(range.start, range.end); + // Autolinks and reference syntax are not supported fix kinds. + if (!original.startsWith(node.type === "image" ? "![" : "[") || !original.endsWith(")")) return; + links.push({ ...range, kind: node.type, original, destination: node.url }); + }, () => {}); + return links; +} + +export function wikiLinkRanges(content: string): SourceRange[] { + const ranges: SourceRange[] = []; + visit(parseBody(content), () => {}, (range) => { + const text = content.slice(range.start, range.end); + for (const match of text.matchAll(/!?\[\[[^[\]\r\n]+\]\]/g)) { + const start = range.start + match.index; + if (escaped(content, start) || content[start - 1] === "!") continue; + ranges.push({ start, end: start + match[0].length }); + } + }); + return ranges; +} + +export function blockIds(content: string): string[] { + const ids: string[] = []; + visit(parseBody(content), () => {}, (range) => { + for (const match of content.slice(range.start, range.end).matchAll(/\^([A-Za-z0-9-]+)/g)) { + const start = range.start + match.index; + if (escaped(content, start)) continue; + let before = start; + while (before > 0 && content[before - 1] === "\\") before--; + if (before > 0 && !/\s/.test(content[before - 1])) continue; + const end = start + match[0].length; + const newline = content.indexOf("\n", end); + if (!/^[\t \r]*$/.test(content.slice(end, newline === -1 ? content.length : newline))) continue; + ids.push(match[1]); + } + }); + return ids; +} From 7a03fa02689e3e34a931273caae35ad1067a6eaf Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:02:01 +0800 Subject: [PATCH 04/16] fix: validate block references against block metadata --- cli/local-vault.ts | 3 ++ src/scanner/scanners/broken-links.ts | 23 +++++++++------ src/tests/broken-links.test.ts | 43 ++++++++++++++++++++++++++++ src/tests/cli.test.ts | 17 +++++++++++ src/tests/local-vault.test.ts | 13 +++++++++ 5 files changed, 90 insertions(+), 9 deletions(-) diff --git a/cli/local-vault.ts b/cli/local-vault.ts index 0526f25..662983b 100644 --- a/cli/local-vault.ts +++ b/cli/local-vault.ts @@ -2,6 +2,7 @@ import { readdir, readFile, stat } from "node:fs/promises"; import { basename, extname, join, posix, relative, sep } from "node:path"; import type { App, MetadataCache, TFile, Vault } from "obsidian"; import { extractBareUrls } from "../src/scanner/scanners/external-links"; +import { blockIds } from "../src/utils/markdown-source"; type LocalFile = TFile & { path: string; @@ -25,6 +26,7 @@ type LocalMetadata = { embeds?: LinkCacheEntry[]; frontmatterLinks?: LinkCacheEntry[]; headings?: HeadingCacheEntry[]; + blocks?: Record; tags?: TagCacheEntry[]; frontmatter?: Record; }; @@ -198,6 +200,7 @@ function parseMarkdownMetadata(content: string): LocalMetadata { embeds, frontmatterLinks, headings, + blocks: Object.fromEntries(blockIds(content).map((id) => [id, { id }])), tags, frontmatter, }; diff --git a/src/scanner/scanners/broken-links.ts b/src/scanner/scanners/broken-links.ts index bbcceee..0a6261d 100644 --- a/src/scanner/scanners/broken-links.ts +++ b/src/scanner/scanners/broken-links.ts @@ -177,14 +177,17 @@ function resolveLinkIssues( } if (headingPart) { - const headingCache = ctx.metadataCache.getFileCache( + const targetCache = ctx.metadataCache.getFileCache( ctx.markdownFiles.find((file) => file.path === resolvedPath)!, ); - const headings = headingCache?.headings ?? []; - const headingSlug = slugifyHeading(headingPart); - const found = headings.some( - (heading) => slugifyHeading(heading.heading) === headingSlug, - ); + const isBlock = headingPart.startsWith("^"); + const found = isBlock + ? Object.keys(targetCache?.blocks ?? {}).some( + (id) => id.toLowerCase() === headingPart.slice(1).toLowerCase(), + ) + : (targetCache?.headings ?? []).some( + (heading) => slugifyHeading(heading.heading) === slugifyHeading(headingPart), + ); if (!found) { issues.push( makeIssue( @@ -192,12 +195,13 @@ function resolveLinkIssues( candidate, resolvedPath, "warning", - `Heading "#${headingPart}" not found in ${resolvedPath}`, + `${isBlock ? "Block" : "Heading"} "#${headingPart}" not found in ${resolvedPath}`, candidate.isEmbed ? "embed" : candidate.isMarkdown ? "markdown-link" : "heading", + isBlock ? "block" : "heading", ), ); } @@ -304,6 +308,7 @@ function makeIssue( severity: "error" | "warning" | "info", message: string, linkKind: "note-link" | "markdown-link" | "attachment" | "heading" | "embed", + referenceKind: "block" | "heading" = "heading", ): Issue { const issue: Issue = { scannerId: "broken-links", @@ -317,10 +322,10 @@ function makeIssue( "confirmed", severity === "error" ? "The link target could not be resolved in the vault." - : "The target note exists, but the referenced heading was not found.", + : `The target note exists, but the referenced ${referenceKind} was not found.`, severity === "error" ? "Correct the target or remove the link from the source note." - : "Correct the heading reference or remove it from the source note.", + : `Correct the ${referenceKind} reference or remove it from the source note.`, ), fingerprint: generateFingerprint("broken-links", sourcePath, { link: candidate.linkText, diff --git a/src/tests/broken-links.test.ts b/src/tests/broken-links.test.ts index d63a01d..7896e18 100644 --- a/src/tests/broken-links.test.ts +++ b/src/tests/broken-links.test.ts @@ -826,3 +826,46 @@ describe("brokenLinksScanner", () => { expect(issues[0].severity).toBe("error"); }); }); + + +describe("block references", () => { + it.each([ + ["[[Target#^Known-id]]", "Target#^Known-id", false], + ["[[Target#^KNOWN-ID|Alias]]", "Target#^KNOWN-ID", false], + ["![[Target#^known-ID]]", "Target#^known-ID", true], + ["[Block](Target.md#^Known-id)", "Target.md#^Known-id", false], + ])("keeps valid block reference %s without a removal action", async (original, link, embed) => { + const ctx = makeScanContext({ + files: [{ path: "Source.md" }, { path: "Target.md" }], + metadataByPath: { + "Source.md": { [embed ? "embeds" : "links"]: [{ link, original }] } as any, + "Target.md": { blocks: { "Known-id": { id: "Known-id" } } } as any, + }, + }); + expect(await brokenLinksScanner.scan(ctx)).toEqual([]); + }); + + it.each(["missing", "same-name", "Known_id"])("reports missing block %s without heading normalization", async (id) => { + const ctx = makeScanContext({ + files: [{ path: "Source.md" }, { path: "Target.md" }], + metadataByPath: { + "Source.md": { links: [{ link: `Target#^${id}`, original: `[[Target#^${id}|Alias]]` }] } as any, + "Target.md": { + headings: [{ heading: "same-name" }], + blocks: { "Known-id": { id: "Known-id" } }, + } as any, + }, + }); + const issues = await brokenLinksScanner.scan(ctx); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: "warning", + message: `Block "#^${id}" not found in Target.md`, + explanation: { + why: "The target note exists, but the referenced block was not found.", + nextStep: "Correct the block reference or remove it from the source note.", + }, + fixAction: { replacement: "Alias" }, + }); + }); +}); diff --git a/src/tests/cli.test.ts b/src/tests/cli.test.ts index 7899025..3e68fdf 100644 --- a/src/tests/cli.test.ts +++ b/src/tests/cli.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { dirname, join } from "node:path"; @@ -28,6 +29,22 @@ describe("runCli", () => { vi.restoreAllMocks(); }); + it("preserves valid block links in the actual CLI bundle", async () => { + await withVault({ + "Source.md": "[[Target#^KNOWN-ID|Alias]]\n![[Target#^Known-id]]\n[[Target#^missing]]\n", + "Target.md": "Body ^Known-id\n\n# missing\n", + }, async (vaultPath) => { + const stdout = execFileSync(process.execPath, [ + join(process.cwd(), "cli.js"), vaultPath, "--format", "json", + "--scanner", "broken-links", "--fail-on", "none", + ], { encoding: "utf8" }); + const payload = JSON.parse(stdout); + expect(payload.issues).toHaveLength(1); + expect(payload.issues[0].message).toBe('Block "#^missing" not found in Target.md'); + expect(payload.issues[0].evidence.link).toBe("Target#^missing"); + }); + }); + it("shows the short command alias in usage output", async () => { const result = await runCli([]); diff --git a/src/tests/local-vault.test.ts b/src/tests/local-vault.test.ts index aaeee99..e52c8f8 100644 --- a/src/tests/local-vault.test.ts +++ b/src/tests/local-vault.test.ts @@ -31,6 +31,19 @@ describe("createLocalApp adapter semantics", () => { const buildApp = async () => (await createLocalApp(vaultDir)) as unknown as LocalApp; + it("indexes only real body block markers without fabricated positions", async () => { + await writeFile(join(vaultDir, "Target.md"), [ + "---", "property: text ^metadata", "---", "Body ^Known-id", "", + "^standalone", "", " code ^indent", "", "```", "code ^fenced", "```", + "", "`code ^inline`", "# same-name", "", + ].join("\n")); + const app = await createLocalApp(vaultDir); + const target = app.vault.getMarkdownFiles()[0]; + expect(app.metadataCache.getFileCache(target)?.blocks).toEqual({ + "Known-id": { id: "Known-id" }, standalone: { id: "standalone" }, + }); + }); + it("never collects dot-prefixed files or directories as vault files", async () => { await writeFile(join(vaultDir, "Target.md"), "# Target\n"); await writeFile(join(vaultDir, ".hidden.cfg"), "config"); From 5bd8a3b5419f41912e232dcb3d756b26ce2782d6 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:05:30 +0800 Subject: [PATCH 05/16] fix: validate same-note heading links --- src/scanner/scanners/broken-links.ts | 14 ++++---- src/tests/broken-links.test.ts | 54 +++++++++++++++++++++++++++- src/tests/cli.test.ts | 27 +++++++++++++- 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/scanner/scanners/broken-links.ts b/src/scanner/scanners/broken-links.ts index 0a6261d..e62e5b3 100644 --- a/src/scanner/scanners/broken-links.ts +++ b/src/scanner/scanners/broken-links.ts @@ -127,8 +127,13 @@ function resolveLinkIssues( const linkText = candidate.linkText; const rawTarget = getLinkTarget(linkText); + const linkDestination = linkText.split("|")[0]; + const headingPart = linkDestination.includes("#") + ? linkDestination.split("#").slice(1).join("#") + : null; + const sameNote = rawTarget === "" && linkDestination.startsWith("#") && Boolean(headingPart); - if (!rawTarget || hasUriScheme(rawTarget)) return issues; + if ((!rawTarget && !sameNote) || hasUriScheme(rawTarget)) return issues; // Attachment link (has a known non-md extension) if (isAttachmentLink(rawTarget)) { @@ -148,12 +153,7 @@ function resolveLinkIssues( } // Markdown or heading link - const linkDestination = linkText.split("|")[0]; - const headingPart = linkDestination.includes("#") - ? linkDestination.split("#").slice(1).join("#") - : null; - - const resolvedPath = findMarkdownPath(ctx, rawTarget, sourcePath); + const resolvedPath = sameNote ? sourcePath : findMarkdownPath(ctx, rawTarget, sourcePath); if (!resolvedPath) { if (ctx.ignoreUnresolvedNoteLinks && candidate.ignorableUnresolvedNote) { diff --git a/src/tests/broken-links.test.ts b/src/tests/broken-links.test.ts index 7896e18..745da6b 100644 --- a/src/tests/broken-links.test.ts +++ b/src/tests/broken-links.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { brokenLinksScanner } from "../scanner/scanners/broken-links"; import type { ScanContext } from "../scanner/ScanContext"; import { makeScanContext } from "./helpers/scan-context"; @@ -828,6 +828,58 @@ describe("brokenLinksScanner", () => { }); +describe("same-note fragments", () => { + it.each([ + ["[[#Missing]]", "#Missing", "#Missing", "heading"], + ["[[#Missing|Alias]]", "#Missing", "Alias", "heading"], + ["[jump](#Missing)", "#Missing", "jump", "markdown-link"], + ["![[#Missing]]", "#Missing", "", "embed"], + ["[[#^missing|Block alias]]", "#^missing", "Block alias", "heading"], + ["![block](#^missing)", "#^missing", "", "embed"], + ])("reports %s against the source note with exact fix metadata", async (original, link, replacement, linkKind) => { + const ctx = makeScanContext({ + files: [{ path: "nested/Source.md" }], + metadataByPath: { + "nested/Source.md": { + [original.startsWith("!") ? "embeds" : "links"]: [{ original, link }], + } as any, + }, + overrides: { ignoreUnresolvedNoteLinks: true }, + }); + const resolver = vi.fn(() => null); + ctx.metadataCache.getFirstLinkpathDest = resolver; + const issues = await brokenLinksScanner.scan(ctx); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: "warning", + message: `${link.startsWith("#^") ? "Block" : "Heading"} "${link}" not found in nested/Source.md`, + primaryPath: "nested/Source.md", + relatedPaths: ["nested/Source.md"], + evidence: { link, target: "nested/Source.md", linkKind }, + fixAction: { original, replacement, targetPaths: ["nested/Source.md"] }, + }); + expect(resolver).not.toHaveBeenCalled(); + }); + + it.each(["#Existing", "#^VALID-block", "#", "", "https://example.com/#Missing", "obsidian://open#Missing"])("leaves valid or excluded target %s alone", async (link) => { + const ctx = makeScanContext({ + files: [{ path: "nested/Source.md" }], + metadataByPath: { + "nested/Source.md": { + links: [{ link, original: `[[${link}]]` }], + headings: [{ heading: "Existing" }], + blocks: { "valid-block": { id: "valid-block" } }, + } as any, + }, + unresolvedLinks: { "nested/Source.md": { [link]: 1 } }, + }); + const resolver = vi.fn(() => null); + ctx.metadataCache.getFirstLinkpathDest = resolver; + expect(await brokenLinksScanner.scan(ctx)).toEqual([]); + expect(resolver).not.toHaveBeenCalled(); + }); +}); + describe("block references", () => { it.each([ ["[[Target#^Known-id]]", "Target#^Known-id", false], diff --git a/src/tests/cli.test.ts b/src/tests/cli.test.ts index 3e68fdf..7422926 100644 --- a/src/tests/cli.test.ts +++ b/src/tests/cli.test.ts @@ -45,6 +45,32 @@ describe("runCli", () => { }); }); + it("validates same-note headings and blocks through the CLI", async () => { + await withVault({ + "nested/Source.md": [ + "# Existing", "Body ^valid-block", "[[#Existing]]", "[valid](#Existing)", + "[[#^valid-block]]", "[[#]]", "[external](https://example.com/#Missing)", + "[[#Missing|Alias]]", "[jump](#MissingMarkdown)", "![[#MissingEmbed]]", + "[[#^missing-block]]", + ].join("\n\n"), + }, async (vaultPath) => { + const result = await runCli([ + vaultPath, "--format", "json", "--scanner", "broken-links", + "--ignore-unresolved-note-links", + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(""); + const payload = JSON.parse(result.stdout); + expect(payload.issues).toHaveLength(4); + expect(payload.issues.map((issue: { message: string }) => issue.message).sort()).toEqual([ + 'Heading "#Missing" not found in nested/Source.md', + 'Heading "#MissingMarkdown" not found in nested/Source.md', + 'Heading "#MissingEmbed" not found in nested/Source.md', + 'Block "#^missing-block" not found in nested/Source.md', + ].sort()); + }); + }); + it("shows the short command alias in usage output", async () => { const result = await runCli([]); @@ -1531,4 +1557,3 @@ describe("runCli", () => { }); }); }); - From f818f90ec29e78ef698d3a5104e760a8b316a8d8 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:08:34 +0800 Subject: [PATCH 06/16] fix: honor explicit CLI fail-on thresholds --- cli/cli.ts | 6 ++++-- src/tests/cli.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/cli/cli.ts b/cli/cli.ts index 451ac3a..e26046d 100644 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -112,7 +112,7 @@ type CliOptions = { ignoredProperties?: string[]; }; -type ParsedArgs = CliOptions & { configPath?: string }; +type ParsedArgs = CliOptions & { configPath?: string; failOnExplicit: boolean }; type CliRuntime = { writeStderr?: (text: string) => void; @@ -248,6 +248,7 @@ function parseArgs(args: string[]): ParsedArgs | { error: string } { ignoredFoldersByScanner: createEmptyIgnoredFoldersByScanner(), ignoreUnresolvedNoteLinks: false, failOn: "any", + failOnExplicit: false, fix: false, progress: false, }; @@ -304,6 +305,7 @@ function parseArgs(args: string[]): ParsedArgs | { error: string } { const value = args[++index]; if (!isFailOn(value)) return { error: usage(`Unsupported --fail-on value: ${value ?? ""}`) }; options.failOn = value; + options.failOnExplicit = true; } else if (arg === "--progress") { options.progress = true; } else if (arg === "--fix") { @@ -366,7 +368,7 @@ async function loadConfig(args: ParsedArgs): Promise { }); }); + it.each([ + { configured: "none", explicit: "any", scanner: "broken-links", content: "[[missing]]", severity: "errors", exitCode: 1 }, + { configured: "error", explicit: "any", scanner: "empty-notes", content: "", severity: "warnings", exitCode: 1 }, + { configured: "none", explicit: undefined, scanner: "broken-links", content: "[[missing]]", severity: "errors", exitCode: 0 }, + { configured: "error", explicit: undefined, scanner: "empty-notes", content: "", severity: "warnings", exitCode: 0 }, + { configured: undefined, explicit: undefined, scanner: "empty-notes", content: "", severity: "warnings", exitCode: 1 }, + { configured: "any", explicit: "none", scanner: "broken-links", content: "[[missing]]", severity: "errors", exitCode: 0 }, + ])("resolves fail-on config $configured and explicit $explicit for $severity", async ({ + configured, explicit, scanner, content, severity, exitCode, + }) => { + await withVault({ "note.md": content }, async (vaultPath) => { + const args = [vaultPath, "--scanner", scanner]; + if (configured !== undefined) { + const configPath = join(vaultPath, "config.json"); + await writeFile(configPath, JSON.stringify({ failOn: configured }), "utf8"); + args.push("--config", configPath); + } + if (explicit !== undefined) args.push("--fail-on", explicit); + + const result = await runCli(args); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout).summary[severity]).toBe(1); + expect(result.exitCode).toBe(exitCode); + expect(result.stdout).not.toContain("failOnExplicit"); + + const defaultResult = await runCli([vaultPath, "--scanner", scanner]); + expect(JSON.parse(result.stdout).comparison.scanProfile).toBe( + JSON.parse(defaultResult.stdout).comparison.scanProfile, + ); + }); + }); + it("uses fail-on to control exit status", async () => { await withVault({ "empty.md": "" }, async (vaultPath) => { const belowThreshold = await runCli([ From bd444d9aac9724a4f5ea692f007782a02ed0e025 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:14:41 +0800 Subject: [PATCH 07/16] fix: resolve encoded Markdown paths in the CLI --- cli/local-vault.ts | 60 +++++----- src/scanner/link-reference.ts | 14 +++ src/scanner/reference-index.ts | 10 +- src/scanner/scanners/broken-links.ts | 42 +++++-- src/tests/cli-encoded-links.test.ts | 163 +++++++++++++++++++++++++++ 5 files changed, 243 insertions(+), 46 deletions(-) create mode 100644 src/scanner/link-reference.ts create mode 100644 src/tests/cli-encoded-links.test.ts diff --git a/cli/local-vault.ts b/cli/local-vault.ts index 662983b..da3a17e 100644 --- a/cli/local-vault.ts +++ b/cli/local-vault.ts @@ -2,6 +2,7 @@ import { readdir, readFile, stat } from "node:fs/promises"; import { basename, extname, join, posix, relative, sep } from "node:path"; import type { App, MetadataCache, TFile, Vault } from "obsidian"; import { extractBareUrls } from "../src/scanner/scanners/external-links"; +import type { LinkReference } from "../src/scanner/link-reference"; import { blockIds } from "../src/utils/markdown-source"; type LocalFile = TFile & { @@ -13,11 +14,7 @@ type LocalFile = TFile & { }; }; -type LinkCacheEntry = { - link: string; - original?: string; - sourceRelative?: boolean; -}; +type LinkCacheEntry = LinkReference & { sourceRelative?: boolean }; type HeadingCacheEntry = { heading: string }; type TagCacheEntry = { tag: string }; @@ -49,7 +46,6 @@ export async function createLocalApp(vaultPath: string): Promise { const filesByPath = new Map(files.map((file) => [file.path, file])); const metadataByPath = new Map(); const resolvedLinks: Record> = {}; - const resolvedDestinations: Record> = {}; const unresolvedLinks: Record> = {}; for (const file of files.filter((item) => item.path.endsWith(".md"))) { @@ -57,10 +53,21 @@ export async function createLocalApp(vaultPath: string): Promise { const metadata = parseMarkdownMetadata(content); metadataByPath.set(file.path, metadata); - for (const link of [...metadata.links ?? [], ...metadata.embeds ?? []]) { + for (const link of [...metadata.links ?? [], ...metadata.embeds ?? [], ...metadata.frontmatterLinks ?? []]) { if (hasUriScheme(link.link)) continue; - const target = normalizeLinkTarget(link.link); - if (!target) continue; + const fragmentAt = link.link.indexOf("#"); + const path = fragmentAt === -1 ? link.link : link.link.slice(0, fragmentAt); + const fragment = fragmentAt === -1 ? null : link.link.slice(fragmentAt + 1); + const target = link.sourceRelative ? decodeDestination(path) : path.trim(); + link.destination = { + path: target, + fragment: fragment !== null && link.sourceRelative ? decodeDestination(fragment) : fragment, + resolvedPath: null, + }; + if (!target) { + if (fragment !== null) link.destination.resolvedPath = file.path; + continue; + } const resolved = resolveVaultPath( target, @@ -68,16 +75,12 @@ export async function createLocalApp(vaultPath: string): Promise { file.path, link.sourceRelative ?? false, ); + link.destination.resolvedPath = resolved; if (resolved) { resolvedLinks[file.path] = { ...resolvedLinks[file.path], [resolved]: (resolvedLinks[file.path]?.[resolved] ?? 0) + 1, }; - resolvedDestinations[file.path] = { - ...resolvedDestinations[file.path], - [link.link]: resolved, - [target]: resolved, - }; } else { unresolvedLinks[file.path] = { ...unresolvedLinks[file.path], @@ -102,17 +105,10 @@ export async function createLocalApp(vaultPath: string): Promise { unresolvedLinks, getFileCache: (file: TFile) => metadataByPath.get(file.path) ?? null, getFirstLinkpathDest: (linkPath: string, sourcePath: string) => { - const target = normalizeLinkTarget(linkPath); - if (!target || hasUriScheme(target)) return null; - const resolved = - resolvedDestinations[sourcePath]?.[linkPath] ?? - resolvedDestinations[sourcePath]?.[target] ?? - resolveVaultPath( - target, - filePathIndex, - sourcePath, - /^\.{1,2}\//.test(target), - ); + if (!linkPath || hasUriScheme(linkPath)) return null; + const resolved = resolveVaultPath( + linkPath, filePathIndex, sourcePath, /^\.{1,2}\//.test(linkPath), + ); return resolved ? filesByPath.get(resolved) ?? null : null; }, } as LocalMetadataCache; @@ -210,8 +206,7 @@ function extractFrontmatterWikiLinks(content: string): LinkCacheEntry[] { const section = splitFrontmatter(content); if (!section.frontmatter) return []; - // Aliases are stripped here for symmetry with the body parser; frontmatterLinks - // are not consumed by link resolution today. + // Wiki aliases are display text, including in frontmatter. return [...section.frontmatter.matchAll(/\[\[([^\]]+)\]\]/g)].map((match) => ({ link: match[1].split("|")[0], })); @@ -269,10 +264,13 @@ function stripFrontmatter(content: string): string { return splitFrontmatter(content).body; } -function normalizeLinkTarget(link: string): string { - // The alias strip is defensive for non-adapter callers; the adapter already - // strips aliases at parse time. - return link.split("|")[0].split("#")[0].trim(); +function decodeDestination(value: string): string { + try { + return decodeURIComponent(value); + } catch { + // Invalid percent escapes remain literal filenames instead of aborting scans. + return value; + } } function resolveVaultPath( diff --git a/src/scanner/link-reference.ts b/src/scanner/link-reference.ts new file mode 100644 index 0000000..8a926d0 --- /dev/null +++ b/src/scanner/link-reference.ts @@ -0,0 +1,14 @@ +/** Adapter-only resolution, kept separate from verbatim link and original syntax. */ +export type LinkDestination = { + path: string; + fragment: string | null; + /** Null is authoritative: consumers must not retry using another syntax. */ + resolvedPath: string | null; +}; + +/** Native Obsidian metadata omits destination and uses its normal resolver. */ +export type LinkReference = { + link: string; + original?: string; + destination?: LinkDestination; +}; diff --git a/src/scanner/reference-index.ts b/src/scanner/reference-index.ts index 68595bd..f3fd433 100644 --- a/src/scanner/reference-index.ts +++ b/src/scanner/reference-index.ts @@ -1,3 +1,4 @@ +import type { LinkReference } from "./link-reference"; import type { ScanContext } from "./ScanContext"; import { hasUriScheme, resolveVaultLinkTargets } from "../utils/vault-links"; @@ -112,6 +113,9 @@ export async function buildReferenceIndex( return resolveVaultLinkTargets(ctx, link, sourcePath)[0] ?? null; }; + const resolveReference = (reference: LinkReference, sourcePath: string): string | null => + reference.destination ? reference.destination.resolvedPath : resolveTarget(reference.link, sourcePath); + for (const file of ctx.markdownFiles) { const cache = ctx.metadataCache.getFileCache(file); if (!cache) { @@ -122,15 +126,15 @@ export async function buildReferenceIndex( continue; } for (const link of cache.links ?? []) { - const resolved = resolveTarget(link.link, file.path); + const resolved = resolveReference(link, file.path); if (resolved) addReference(resolved, file.path, "note-link"); } for (const embed of cache.embeds ?? []) { - const resolved = resolveTarget(embed.link, file.path); + const resolved = resolveReference(embed, file.path); if (resolved) addReference(resolved, file.path, "embed"); } for (const link of cache.frontmatterLinks ?? []) { - const resolved = resolveTarget(link.link, file.path); + const resolved = resolveReference(link, file.path); if (resolved) addReference(resolved, file.path, "frontmatter"); } } diff --git a/src/scanner/scanners/broken-links.ts b/src/scanner/scanners/broken-links.ts index e62e5b3..ab12f9d 100644 --- a/src/scanner/scanners/broken-links.ts +++ b/src/scanner/scanners/broken-links.ts @@ -1,3 +1,4 @@ +import type { LinkDestination, LinkReference as MetadataLinkReference } from "../link-reference"; import type { Issue } from "../Issue"; import type { ScanContext } from "../ScanContext"; import { describeFinding } from "../finding-presentation"; @@ -17,6 +18,7 @@ type LinkFix = { }; type LinkCandidate = { + destination?: LinkDestination; linkText: string; fixLinkText?: string; fix?: LinkFix; @@ -26,10 +28,7 @@ type LinkCandidate = { }; type LinkReference = { - reference: { - link: string; - original?: string; - }; + reference: MetadataLinkReference; isEmbed: boolean; }; @@ -62,13 +61,18 @@ export const brokenLinksScanner = { ]; const linkCandidates = new Map(); const addCandidate = (candidate: LinkCandidate) => { - const existing = linkCandidates.get(candidate.linkText); + const destination = candidate.destination; + const key = destination + ? JSON.stringify([candidate.linkText, destination.path, destination.fragment, destination.resolvedPath]) + : candidate.linkText; + const existing = linkCandidates.get(key); if (!existing) { - linkCandidates.set(candidate.linkText, candidate); + linkCandidates.set(key, candidate); return; } - linkCandidates.set(candidate.linkText, { + linkCandidates.set(key, { linkText: candidate.linkText, + destination: candidate.destination, fixLinkText: existing.fixLinkText ?? candidate.fixLinkText, // A fix targets one exact source range. When merged references // disagree on the original syntax (plain vs aliased, wiki vs @@ -126,18 +130,19 @@ function resolveLinkIssues( const issues: Issue[] = []; const linkText = candidate.linkText; - const rawTarget = getLinkTarget(linkText); + const destination = candidate.destination; + const rawTarget = destination ? destination.path : getLinkTarget(linkText); const linkDestination = linkText.split("|")[0]; - const headingPart = linkDestination.includes("#") + const headingPart = destination ? destination.fragment : linkDestination.includes("#") ? linkDestination.split("#").slice(1).join("#") : null; - const sameNote = rawTarget === "" && linkDestination.startsWith("#") && Boolean(headingPart); + const sameNote = rawTarget === "" && Boolean(headingPart); if ((!rawTarget && !sameNote) || hasUriScheme(rawTarget)) return issues; // Attachment link (has a known non-md extension) if (isAttachmentLink(rawTarget)) { - if (!findResolvedPath(ctx, rawTarget, sourcePath)) { + if (!(destination ? destination.resolvedPath : findResolvedPath(ctx, rawTarget, sourcePath))) { issues.push( makeIssue( sourcePath, @@ -153,7 +158,9 @@ function resolveLinkIssues( } // Markdown or heading link - const resolvedPath = sameNote ? sourcePath : findMarkdownPath(ctx, rawTarget, sourcePath); + const resolvedPath = destination + ? destination.resolvedPath?.endsWith(".md") ? destination.resolvedPath : null + : sameNote ? sourcePath : findMarkdownPath(ctx, rawTarget, sourcePath); if (!resolvedPath) { if (ctx.ignoreUnresolvedNoteLinks && candidate.ignorableUnresolvedNote) { @@ -219,6 +226,7 @@ function getLinkCandidate({ reference, isEmbed }: LinkReference): LinkCandidate // Obsidian's LinkCache.link already strips the alias, so the candidate // key must use it — the full inner text survives only as fix text. linkText: reference.link, + destination: reference.destination, fixLinkText: inner, fix: { original, @@ -235,6 +243,7 @@ function getLinkCandidate({ reference, isEmbed }: LinkReference): LinkCandidate if (markdownMatch) { return { linkText: reference.link, + destination: reference.destination, fix: { original, replacement: markdownMatch[1] ? "" : markdownMatch[2], @@ -246,6 +255,7 @@ function getLinkCandidate({ reference, isEmbed }: LinkReference): LinkCandidate } return { linkText: reference.link, + destination: reference.destination, isEmbed, isMarkdown: !isEmbed && original.startsWith("["), ignorableUnresolvedNote: false, @@ -310,6 +320,13 @@ function makeIssue( linkKind: "note-link" | "markdown-link" | "attachment" | "heading" | "embed", referenceKind: "block" | "heading" = "heading", ): Issue { + const fragmentAt = candidate.linkText.indexOf("#"); + const rawFragment = fragmentAt === -1 ? null : candidate.linkText.slice(fragmentAt + 1); + const resolvedFragment = candidate.destination?.fragment; + // Preserve native/raw identities, distinguishing only fragments decoded by the adapter. + const fragmentIdentity: Record = resolvedFragment != null && resolvedFragment !== rawFragment + ? { resolvedFragment } + : {}; const issue: Issue = { scannerId: "broken-links", severity, @@ -330,6 +347,7 @@ function makeIssue( fingerprint: generateFingerprint("broken-links", sourcePath, { link: candidate.linkText, target: targetPath, + ...fragmentIdentity, }), }; if (candidate.fix) { diff --git a/src/tests/cli-encoded-links.test.ts b/src/tests/cli-encoded-links.test.ts new file mode 100644 index 0000000..f7a096d --- /dev/null +++ b/src/tests/cli-encoded-links.test.ts @@ -0,0 +1,163 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { runCli } from "../../cli/cli"; +import { createLocalApp } from "../../cli/local-vault"; +import { generateFingerprint } from "../scanner/issue-fingerprint"; +import { buildReferenceIndex } from "../scanner/reference-index"; + +async function withVault(files: Record, check: (path: string) => Promise) { + const path = await mkdtemp(join(tmpdir(), "vi-encoded-")); + try { + for (const [name, content] of Object.entries(files)) { + await mkdir(dirname(join(path, name)), { recursive: true }); + await writeFile(join(path, name), content); + } + await check(path); + } finally { + await rm(path, { recursive: true, force: true }); + } +} + +async function scan(path: string) { + const result = await runCli([path, "--scanner", "broken-links", "--format", "json", "--fail-on", "none"]); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + return JSON.parse(result.stdout); +} + +describe("CLI encoded Markdown destinations", () => { + it.each([ + ["My%20Note.md", "My Note.md"], + ["%E4%B8%AD%E6%96%87.md", "中文.md"], + ["./My%20Note.md", "nested/My Note.md"], + ["../My%20Note.md", "My Note.md"], + ["", "My Note.md"], + ["Name%23part.md#A%20heading", "Name#part.md"], + ["Name%23part.md", "Name#part.md"], + ["Rate%25.md", "Rate%.md"], + ["My%2520Note.md", "My%20Note.md"], + ["invalid%.md", "invalid%.md"], + ["Pipe%7Cname.md", "Pipe|name.md"], + ["Pipe|name.md", "Pipe|name.md"], + ])("resolves %s exactly once", async (destination, target) => { + await withVault({ "nested/Source.md": `[label](${destination})`, [target]: "# A heading\n" }, async (path) => { + expect((await scan(path)).issues).toEqual([]); + }); + }); + + it.each([ [true, true], [true, false], [false, true], [false, false] ])( + "keeps Markdown and Wiki destinations independent (space=%s literal=%s)", async (space, literal) => { + await withVault({ + "Source.md": "[label](My%20Note.md)\n[[My%20Note.md]]", + ...(space ? { "My Note.md": "# Space" } : {}), + ...(literal ? { "My%20Note.md": "# Literal" } : {}), + }, async (path) => { + const payload = await scan(path); + expect(payload.schemaVersion).toBe(1); + expect(payload.issues).toHaveLength(Number(!space) + Number(!literal)); + expect(payload.issues.map((issue: { evidence: { target: string } }) => issue.evidence.target).sort()) + .toEqual([...(space ? [] : ["My Note.md"]), ...(literal ? [] : ["My%20Note.md"])].sort()); + for (const issue of payload.issues) { + expect(issue.evidence.link).toBe("My%20Note.md"); + expect(issue.fingerprint).toBe(generateFingerprint("broken-links", "Source.md", { + link: "My%20Note.md", target: issue.evidence.target, + })); + expect(issue.fixAction.original).toBe(issue.evidence.target === "My Note.md" ? "[label](My%20Note.md)" : "[[My%20Note.md]]"); + expect(issue).not.toHaveProperty("destination"); + expect(issue.evidence).not.toHaveProperty("resolvedPath"); + } + const app = await createLocalApp(path); + const allFiles = app.vault.getFiles(); + const index = await buildReferenceIndex({ ...app, markdownFiles: app.vault.getMarkdownFiles(), allFiles, + filePathIndex: new Set(allFiles.map((file) => file.path)), + }); + expect(index.inboundByPath.get("My Note.md")?.count ?? 0).toBe(Number(space)); + expect(index.inboundByPath.get("My%20Note.md")?.count ?? 0).toBe(Number(literal)); + }); + }, + ); + + it("indexes encoded attachments, literal-hash notes, blocks and frontmatter with correct kinds", async () => { + await withVault({ + "Source.md": '---\nref: "[[Target#^known]]"\n---\n![page](My%20file.pdf#page=2)\n[hash](Hash%23name.md#Heading)\n[[Target#^known]]', + "My file.pdf": "pdf", "Hash#name.md": "# Heading", "Target.md": "Body ^known", + }, async (path) => { + const app = await createLocalApp(path); + const allFiles = app.vault.getFiles(); + const index = await buildReferenceIndex({ ...app, allFiles, markdownFiles: app.vault.getMarkdownFiles(), + filePathIndex: new Set(allFiles.map((file) => file.path)), + }); + expect(index.inboundByPath.get("My file.pdf")).toMatchObject({ count: 1, kinds: ["embed"] }); + expect(index.inboundByPath.get("Hash#name.md")).toMatchObject({ count: 1, kinds: ["note-link"] }); + expect(index.inboundByPath.get("Target.md")).toMatchObject({ count: 2, kinds: ["frontmatter", "note-link"] }); + const result = await runCli([path, "--scanner", "orphan-attachments", "--format", "json", "--fail-on", "none"]); + expect(JSON.parse(result.stdout).issues).toEqual([]); + }); + }); + + it("does not inspect note metadata for an extensionless non-Markdown target", async () => { + await withVault({ "Source.md": "[license](LICENSE#Heading)", LICENSE: "License text" }, async (path) => { + const payload = await scan(path); + expect(payload.issues).toHaveLength(1); + expect(payload.issues[0].message).toBe("Linked file not found: LICENSE"); + }); + }); + + it("checks decoded fragments without changing original syntax or literal hash paths", async () => { + await withVault({ + "Source.md": "[jump](Hash%23name.md#Missing%20heading)\n[block](Hash%23name.md#%5Eknown)", + "Hash#name.md": "Body ^known", + }, async (path) => { + const payload = await scan(path); + expect(payload.issues).toHaveLength(1); + expect(payload.issues[0]).toMatchObject({ + message: 'Heading "#Missing heading" not found in Hash#name.md', + evidence: { link: "Hash%23name.md#Missing%20heading", target: "Hash#name.md" }, + fixAction: { original: "[jump](Hash%23name.md#Missing%20heading)" }, + }); + }); + }); + + it("distinguishes decoded heading fingerprints when comparing a literal Wiki baseline", async () => { + await withVault({ "Source.md": "[[#Missing%20heading]]" }, async (path) => { + const baseline = await scan(path); + const baselinePath = join(path, "baseline.json"); + await writeFile(baselinePath, JSON.stringify(baseline)); + await writeFile(join(path, "Source.md"), "[jump](#Missing%20heading)\n[[#Missing%20heading]]"); + const payload = await scan(path); + expect(payload.issues).toHaveLength(2); + expect(new Set(payload.issues.map((issue: { fingerprint: string }) => issue.fingerprint)).size).toBe(2); + expect(payload.comparison.fingerprints).toHaveLength(2); + const wiki = payload.issues.find((issue: { fixAction: { original: string } }) => issue.fixAction.original.startsWith("[[")); + expect(wiki.fingerprint).toBe(generateFingerprint("broken-links", "Source.md", { + link: "#Missing%20heading", target: "Source.md", + })); + const compared = await runCli([path, "--scanner", "broken-links", "--format", "json", "--baseline", baselinePath, "--fail-on", "new"]); + expect(compared.exitCode).toBe(1); + expect(compared.stderr).toBe(""); + const result = JSON.parse(compared.stdout); + expect(result.comparison).toMatchObject({ newIssues: 1, persistingIssues: 1, resolvedIssues: 0 }); + expect(result.issues.find((issue: { isNew: boolean }) => issue.isNew).fixAction.original).toBe("[jump](#Missing%20heading)"); + }); + }); + + it.each(["Missing", "invalid%"])("retains existing fingerprints for unchanged fragment %s", async (fragment) => { + await withVault({ "Source.md": `[jump](#${fragment})` }, async (path) => { + const payload = await scan(path); + expect(payload.issues[0].fingerprint).toBe(generateFingerprint("broken-links", "Source.md", { + link: `#${fragment}`, target: "Source.md", + })); + }); + }); + + it("keeps getFirstLinkpathDest a pure literal-path resolver", async () => { + await withVault({ "My Note.md": "", "My%20Note.md": "", "Hash#name.md": "", "Pipe|name.md": "" }, async (path) => { + const { metadataCache } = await createLocalApp(path); + for (const target of ["My Note.md", "My%20Note.md", "Hash#name.md", "Pipe|name.md"]) { + expect(metadataCache.getFirstLinkpathDest(target, "Source.md")?.path).toBe(target); + } + }); + }); +}); From 344b36f259d760be5bfdcc090fbc3163c3186e3f Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:23:54 +0800 Subject: [PATCH 08/16] fix: parse balanced Markdown link destinations in the CLI --- cli/local-vault.ts | 23 +++------ src/tests/cli-balanced-links.test.ts | 71 ++++++++++++++++++++++++++++ src/utils/markdown-source.ts | 34 ++++++------- 3 files changed, 96 insertions(+), 32 deletions(-) create mode 100644 src/tests/cli-balanced-links.test.ts diff --git a/cli/local-vault.ts b/cli/local-vault.ts index da3a17e..bd2159f 100644 --- a/cli/local-vault.ts +++ b/cli/local-vault.ts @@ -3,7 +3,7 @@ import { basename, extname, join, posix, relative, sep } from "node:path"; import type { App, MetadataCache, TFile, Vault } from "obsidian"; import { extractBareUrls } from "../src/scanner/scanners/external-links"; import type { LinkReference } from "../src/scanner/link-reference"; -import { blockIds } from "../src/utils/markdown-source"; +import { parseMarkdownSource } from "../src/utils/markdown-source"; type LocalFile = TFile & { path: string; @@ -153,6 +153,7 @@ async function collectFiles(vaultPath: string): Promise { function parseMarkdownMetadata(content: string): LocalMetadata { const frontmatter = parseFrontmatter(content); + const source = parseMarkdownSource(content); const body = stripIgnoredMarkdownRegions(stripFrontmatter(content)); const links: LinkCacheEntry[] = []; const embeds: LinkCacheEntry[] = []; @@ -166,15 +167,13 @@ function parseMarkdownMetadata(content: string): LocalMetadata { else links.push(entry); } - for (const match of body.matchAll(/(!?)\[[^\]]*]\(\s*(?:<([^>]+)>|([^)]+))\)/g)) { - const target = match[2] ?? parseMarkdownDestination(match[3]); - if (!target) continue; + for (const link of source.links) { const entry = { - link: target, - original: match[0], + link: link.destination, + original: link.original, sourceRelative: true, }; - if (match[1] === "!") embeds.push(entry); + if (link.kind === "image") embeds.push(entry); else links.push(entry); } @@ -196,7 +195,7 @@ function parseMarkdownMetadata(content: string): LocalMetadata { embeds, frontmatterLinks, headings, - blocks: Object.fromEntries(blockIds(content).map((id) => [id, { id }])), + blocks: Object.fromEntries(source.blockIds.map((id) => [id, { id }])), tags, frontmatter, }; @@ -320,14 +319,6 @@ function hasUriScheme(text: string): boolean { return /^[a-z][a-z\d+.-]*:/i.test(text); } -function parseMarkdownDestination(rawDestination: string | undefined): string | null { - if (!rawDestination) return null; - const match = /^(\S+?)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*$/.exec( - rawDestination, - ); - return match?.[1] ?? null; -} - function splitFrontmatter(content: string): { frontmatter?: string; body: string; diff --git a/src/tests/cli-balanced-links.test.ts b/src/tests/cli-balanced-links.test.ts new file mode 100644 index 0000000..596317a --- /dev/null +++ b/src/tests/cli-balanced-links.test.ts @@ -0,0 +1,71 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { runCli } from "../../cli/cli"; +import { createLocalApp } from "../../cli/local-vault"; + +async function withVault(files: Record, check: (path: string) => Promise) { + const path = await mkdtemp(join(tmpdir(), "vi-balanced-")); + try { + for (const [name, content] of Object.entries(files)) { + await mkdir(dirname(join(path, name)), { recursive: true }); + await writeFile(join(path, name), content); + } + await check(path); + } finally { + await rm(path, { recursive: true, force: true }); + } +} + +describe("CLI parsed Markdown links", () => { + it.each([ + ['[label](Note(1).md)', 'Note(1).md'], + ['[label](Note(one(two)).md)', 'Note(one(two)).md'], + [String.raw`[label](Note\(1\).md)`, 'Note(1).md'], + ['[label]( "a title")', 'A note(1).md'], + ['[label](Note(1).md "a title")', 'Note(1).md'], + ['[label](Note%281%29.md)', 'Note(1).md'], + ['[label](A&B.md)', 'A&B.md'], + ])("resolves %s and preserves its source", async (original, target) => { + await withVault({ "Source.md": original, [target]: "# Target" }, async (path) => { + const app = await createLocalApp(path); + const source = app.vault.getMarkdownFiles().find((file) => file.path === "Source.md")!; + expect(app.metadataCache.getFileCache(source)?.links?.[0]).toMatchObject({ original }); + expect(app.metadataCache.resolvedLinks["Source.md"]).toEqual({ [target]: 1 }); + const result = await runCli([path, "--scanner", "broken-links", "--format", "json"]); + expect(JSON.parse(result.stdout).issues).toEqual([]); + }); + }); + + it("indexes linked images and standalone images for the orphan scanner", async () => { + await withVault({ + "Source.md": '[![preview](image(1).png)](Note(1).md)\n![another]( "title")', + "Note(1).md": "# Target", "image(1).png": "image", "another image.png": "another", "orphan.png": "orphan", + }, async (path) => { + const app = await createLocalApp(path); + expect(app.metadataCache.resolvedLinks["Source.md"]).toEqual({ "Note(1).md": 1, "image(1).png": 1, "another image.png": 1 }); + const result = await runCli([path, "--scanner", "broken-links,orphan-attachments", "--format", "json"]); + expect(JSON.parse(result.stdout).issues.map((issue: { primaryPath: string }) => issue.primaryPath)).toEqual(["orphan.png"]); + const stdout = execFileSync(process.execPath, [ + join(process.cwd(), "cli.js"), path, "--scanner", "broken-links,orphan-attachments", + "--format", "json", "--fail-on", "none", + ], { encoding: "utf8" }); + expect(JSON.parse(stdout).issues.map((issue: { primaryPath: string }) => issue.primaryPath)).toEqual(["orphan.png"]); + }); + }); + + it("retains a real missing destination and ignores protected Markdown text", async () => { + const original = '[missing](Missing(1).md "title")'; + await withVault({ "Source.md": [original, '', ' [indented](fake.md)', '', '- ```', ' [list](fake.md)', ' ```', '', '> ```', '> [quote](fake.md)', '> ```', '', '```', '[fence](fake.md)', '```', '', '`[inline](fake.md)`', '', String.raw`\[escaped](fake.md)`, '', '[[Existing|**bold alias**]]'].join('\n'), "Existing.md": "# Target" }, async (path) => { + const app = await createLocalApp(path); + const source = app.vault.getMarkdownFiles().find((file) => file.path === "Source.md")!; + expect(app.metadataCache.getFileCache(source)?.links).toHaveLength(2); + expect(app.metadataCache.getFileCache(source)?.links).toEqual(expect.arrayContaining([expect.objectContaining({ original, link: "Missing(1).md" })])); + const result = await runCli([path, "--scanner", "broken-links", "--format", "json"]); + expect(JSON.parse(result.stdout).issues).toHaveLength(1); + expect(app.metadataCache.resolvedLinks["Source.md"]).toEqual({ "Existing.md": 1 }); + }); + }); +}); diff --git a/src/utils/markdown-source.ts b/src/utils/markdown-source.ts index cec37ba..1110eb0 100644 --- a/src/utils/markdown-source.ts +++ b/src/utils/markdown-source.ts @@ -43,8 +43,11 @@ function escaped(content: string, start: number): boolean { return slashes % 2 === 1; } -export function markdownLinks(content: string): MarkdownSourceLink[] { +/** Parse once for consumers that need multiple source-derived metadata fields. */ +export function parseMarkdownSource(content: string) { const links: MarkdownSourceLink[] = []; + const ranges: SourceRange[] = []; + const ids: string[] = []; visit(parseBody(content), (node) => { if (node.type !== "link" && node.type !== "image") return; const range = rangeOf(node); @@ -53,26 +56,13 @@ export function markdownLinks(content: string): MarkdownSourceLink[] { // Autolinks and reference syntax are not supported fix kinds. if (!original.startsWith(node.type === "image" ? "![" : "[") || !original.endsWith(")")) return; links.push({ ...range, kind: node.type, original, destination: node.url }); - }, () => {}); - return links; -} - -export function wikiLinkRanges(content: string): SourceRange[] { - const ranges: SourceRange[] = []; - visit(parseBody(content), () => {}, (range) => { + }, (range) => { const text = content.slice(range.start, range.end); for (const match of text.matchAll(/!?\[\[[^[\]\r\n]+\]\]/g)) { const start = range.start + match.index; if (escaped(content, start) || content[start - 1] === "!") continue; ranges.push({ start, end: start + match[0].length }); } - }); - return ranges; -} - -export function blockIds(content: string): string[] { - const ids: string[] = []; - visit(parseBody(content), () => {}, (range) => { for (const match of content.slice(range.start, range.end).matchAll(/\^([A-Za-z0-9-]+)/g)) { const start = range.start + match.index; if (escaped(content, start)) continue; @@ -85,5 +75,17 @@ export function blockIds(content: string): string[] { ids.push(match[1]); } }); - return ids; + return { links, wikiRanges: ranges, blockIds: ids }; +} + +export function markdownLinks(content: string): MarkdownSourceLink[] { + return parseMarkdownSource(content).links; +} + +export function wikiLinkRanges(content: string): SourceRange[] { + return parseMarkdownSource(content).wikiRanges; +} + +export function blockIds(content: string): string[] { + return parseMarkdownSource(content).blockIds; } From 5f1c7ed932ffcad222d6eabd85e75132abd45ba7 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:27:38 +0800 Subject: [PATCH 09/16] fix: preserve balanced parentheses in external URLs --- src/scanner/scanners/external-links.ts | 5 ++++ src/tests/external-links.test.ts | 33 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/scanner/scanners/external-links.ts b/src/scanner/scanners/external-links.ts index 947297b..b908593 100644 --- a/src/scanner/scanners/external-links.ts +++ b/src/scanner/scanners/external-links.ts @@ -140,6 +140,11 @@ function stripIgnoredMarkdownRegions(content: string): string { function trimUrlBoundary(url: string): string { let trimmed = url; while (/[),.;:!?]$/.test(trimmed)) { + if (trimmed.endsWith(")")) { + const opens = (trimmed.match(/\(/g) ?? []).length; + const closes = (trimmed.match(/\)/g) ?? []).length; + if (closes <= opens) break; + } trimmed = trimmed.slice(0, -1); } return trimmed; diff --git a/src/tests/external-links.test.ts b/src/tests/external-links.test.ts index 1a1a2a1..00643f1 100644 --- a/src/tests/external-links.test.ts +++ b/src/tests/external-links.test.ts @@ -4,6 +4,7 @@ import { EXTERNAL_LINK_SCAN_BUDGET_MS, EXTERNAL_LINK_TIMEOUT_MS, externalLinksScanner, + extractBareUrls, } from "../scanner/scanners/external-links"; import type { ScanContext } from "../scanner/ScanContext"; @@ -52,6 +53,38 @@ function makeFileCtx( } describe("externalLinksScanner", () => { + it.each([ + ["https://en.wikipedia.org/wiki/Function_(mathematics)", "https://en.wikipedia.org/wiki/Function_(mathematics)"], + ["(https://example.com/a)", "https://example.com/a"], + ["https://example.com/a_(b)).", "https://example.com/a_(b)"], + ["https://example.com/a_(b_(c))", "https://example.com/a_(b_(c))"], + ["https://example.com/a_(b_(c)))).,;:!?", "https://example.com/a_(b_(c))"], + ["https://example.com/a_%28b%29", "https://example.com/a_%28b%29"], + ["https://example.com/a_%28b)", "https://example.com/a_%28b"], + ["https://example.com/a_(b%29)", "https://example.com/a_(b%29)"], + ])("preserves URL parentheses while trimming prose boundaries: %s", (body, expected) => { + expect(extractBareUrls(body)).toEqual([expected]); + }); + + it("checks the complete URL once when metadata and body contain balanced parentheses", async () => { + const url = "https://en.wikipedia.org/wiki/Function_(mathematics)"; + const request = vi.fn(async (_url: string, method: "HEAD" | "GET") => ({ status: 404, method })); + const file = { path: "a.md", stat: { size: 100, mtime: 1000 } } as any; + const ctx = makeCtx({ + requestUrl: request, + markdownFiles: [file], + vault: { cachedRead: async () => `See (${url}).` } as any, + metadataCache: { getFileCache: () => ({ links: [{ link: url }] }) } as any, + }); + + const issues = await externalLinksScanner.scan(ctx); + + expect(request).toHaveBeenCalledTimes(1); + expect(request.mock.calls[0]?.slice(0, 2)).toEqual([url, "HEAD"]); + expect(issues).toHaveLength(1); + expect(issues[0]?.evidence.url).toBe(url); + }); + it("reports dead external links (HTTP 404)", async () => { vi.mocked(requestUrl).mockResolvedValue({ status: 404 } as any); // Routed through the mocked Obsidian requestUrl, like the plugin adapter. From 5c10351736d46309ca42a3218863f03b14ba31d7 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:32:48 +0800 Subject: [PATCH 10/16] fix: parse CLI frontmatter with YAML semantics --- cli/local-vault.ts | 63 ++++++++++------------------------- docs/cli.md | 7 ++++ package-lock.json | 9 +++++ package.json | 2 ++ src/tests/cli.test.ts | 34 ++++++++++++++++++- src/tests/local-vault.test.ts | 30 +++++++++++++++++ 6 files changed, 99 insertions(+), 46 deletions(-) diff --git a/cli/local-vault.ts b/cli/local-vault.ts index bd2159f..7e05b51 100644 --- a/cli/local-vault.ts +++ b/cli/local-vault.ts @@ -1,5 +1,6 @@ import { readdir, readFile, stat } from "node:fs/promises"; import { basename, extname, join, posix, relative, sep } from "node:path"; +import { CORE_SCHEMA, load, YAMLException } from "js-yaml"; import type { App, MetadataCache, TFile, Vault } from "obsidian"; import { extractBareUrls } from "../src/scanner/scanners/external-links"; import type { LinkReference } from "../src/scanner/link-reference"; @@ -50,7 +51,7 @@ export async function createLocalApp(vaultPath: string): Promise { for (const file of files.filter((item) => item.path.endsWith(".md"))) { const content = await readFile(join(vaultPath, file.path), "utf8"); - const metadata = parseMarkdownMetadata(content); + const metadata = parseMarkdownMetadata(content, file.path); metadataByPath.set(file.path, metadata); for (const link of [...metadata.links ?? [], ...metadata.embeds ?? [], ...metadata.frontmatterLinks ?? []]) { @@ -151,8 +152,8 @@ async function collectFiles(vaultPath: string): Promise { } } -function parseMarkdownMetadata(content: string): LocalMetadata { - const frontmatter = parseFrontmatter(content); +function parseMarkdownMetadata(content: string, filePath: string): LocalMetadata { + const frontmatter = parseFrontmatter(content, filePath); const source = parseMarkdownSource(content); const body = stripIgnoredMarkdownRegions(stripFrontmatter(content)); const links: LinkCacheEntry[] = []; @@ -211,52 +212,24 @@ function extractFrontmatterWikiLinks(content: string): LinkCacheEntry[] { })); } -function parseFrontmatter(content: string): Record | undefined { +function parseFrontmatter(content: string, filePath: string): Record | undefined { const section = splitFrontmatter(content); if (section.frontmatter === undefined) return undefined; - const parsed: Record = {}; - const lines = section.frontmatter.split(/\r?\n/); - for (let index = 0; index < lines.length; index++) { - const line = lines[index]; - const separator = line.indexOf(":"); - if (separator <= 0) continue; - const key = line.slice(0, separator).trim(); - const value = line.slice(separator + 1).trim(); - if (value === "") { - const items: unknown[] = []; - while (index + 1 < lines.length) { - const itemMatch = /^\s+-\s+(.+?)\s*$/.exec(lines[index + 1]); - if (!itemMatch) break; - items.push(parseFrontmatterValue(itemMatch[1])); - index++; - } - parsed[key] = items.length > 0 ? items : ""; - continue; - } - parsed[key] = parseFrontmatterValue(value); + let value: unknown; + try { + value = load(section.frontmatter, { schema: CORE_SCHEMA }); + } catch (error) { + // Parser messages/reasons/snippets can contain private property values. + // YAML marks are zero-based; frontmatter starts on the note's second line. + const mark = error instanceof YAMLException ? error.mark : undefined; + throw new Error(`${filePath}:${(mark?.line ?? 0) + 2}:${(mark?.column ?? 0) + 1}: Invalid frontmatter YAML`); } - return parsed; -} - -function parseFrontmatterValue(value: string): unknown { - if (value === "") return ""; - if (value === "true") return true; - if (value === "false") return false; - if (value === "null") return null; - if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value); - if (value.startsWith("[") && value.endsWith("]")) { - return value - .slice(1, -1) - .split(",") - .map((item) => stripQuotes(item.trim())) - .filter(Boolean); + if (value === undefined || value === null) return {}; + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${filePath}:2:1: Invalid frontmatter: expected a mapping`); } - return stripQuotes(value); -} - -function stripQuotes(value: string): string { - return value.replace(/^["']|["']$/g, ""); + return value as Record; } function stripFrontmatter(content: string): string { @@ -323,7 +296,7 @@ function splitFrontmatter(content: string): { frontmatter?: string; body: string; } { - const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content); + const match = /^\uFEFF?---\r?\n((?:[\s\S]*?\r?\n)?)---(?:\r?\n|$)/.exec(content); if (!match) return { body: content }; return { frontmatter: match[1], diff --git a/docs/cli.md b/docs/cli.md index dc12e6b..9c240f3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,6 +6,13 @@ as the Obsidian plugin in a separate runtime: the plugin uses Obsidian metadata and UI actions, while the CLI uses a local filesystem adapter for terminal, CI, and automation workflows. +Frontmatter is parsed as YAML using core scalar types; dates remain strings. +The top level must be a mapping (empty or null headers become empty mappings). +Invalid YAML, duplicate keys, unsupported tags, or a non-mapping header abort +setup with exit code `2`, even with `--fail-on none`. No report is written to +stdout. Errors identify the vault-relative note and line/column without +printing property values. Correct the indicated header and rerun the scan. + ## Installation Run it without a global install: diff --git a/package-lock.json b/package-lock.json index 43cfdaf..7453800 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,13 @@ "vinspect": "cli.js" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", "@typescript-eslint/parser": "^8.59.3", "@vitest/coverage-v8": "^4.1.9", "esbuild": "^0.28.1", "eslint-plugin-obsidianmd": "^0.4.1", + "js-yaml": "^4.2.0", "mdast-util-from-markdown": "^2.0.3", "obsidian": "^1.13.1", "typescript": "^5.8.0", @@ -1391,6 +1393,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/package.json b/package.json index a97b5d3..67e0a8f 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,13 @@ "url": "git+https://github.com/rogerdigital/vault-inspector.git" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", "@typescript-eslint/parser": "^8.59.3", "@vitest/coverage-v8": "^4.1.9", "esbuild": "^0.28.1", "eslint-plugin-obsidianmd": "^0.4.1", + "js-yaml": "^4.2.0", "mdast-util-from-markdown": "^2.0.3", "obsidian": "^1.13.1", "typescript": "^5.8.0", diff --git a/src/tests/cli.test.ts b/src/tests/cli.test.ts index 2ea2376..93c83c3 100644 --- a/src/tests/cli.test.ts +++ b/src/tests/cli.test.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { dirname, join } from "node:path"; @@ -29,6 +29,38 @@ describe("runCli", () => { vi.restoreAllMocks(); }); + it("treats equivalent YAML syntax equally through the actual CLI bundle", async () => { + await withVault({ + "A.md": "---\nflag: true # comment\ntags:\n- alpha\n- beta\n---\nBody", + "B.md": "---\nflag: true\ntags: [alpha, beta]\n---\nBody", + }, async (vaultPath) => { + const result = spawnSync(process.execPath, [join(process.cwd(), "cli.js"), vaultPath, + "--format", "json", "--scanner", "frontmatter-types"], { encoding: "utf8" }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout).issues).toEqual([]); + }); + }); + + it.each([ + "key: [SENSITIVE_SENTINEL", "key: first\nkey: SENSITIVE_SENTINEL", + "key: !SENSITIVE_SENTINEL value", "- SENSITIVE_SENTINEL", "SENSITIVE_SENTINEL", + ])("rejects invalid/non-mapping YAML without leaking note content: %j", async (yaml) => { + await withVault({ "Note.md": `---\n${yaml}\n---\nBody` }, async (vaultPath) => { + const args = [vaultPath, "--format", "json", "--scanner", "frontmatter-types", "--fail-on", "none"]; + const result = await runCli(args); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toMatch(/Note\.md:\d+:\d+: Invalid frontmatter/); + expect(result.stderr).not.toContain("SENSITIVE_SENTINEL"); + if (yaml.startsWith("key: first")) expect(result.stderr).toContain("Note.md:3:1:"); + const actual = spawnSync(process.execPath, [join(process.cwd(), "cli.js"), ...args], { encoding: "utf8" }); + expect(actual.status).toBe(2); + expect(actual.stdout).toBe(""); + expect(actual.stderr).toBe(result.stderr); + }); + }); + it("preserves valid block links in the actual CLI bundle", async () => { await withVault({ "Source.md": "[[Target#^KNOWN-ID|Alias]]\n![[Target#^Known-id]]\n[[Target#^missing]]\n", diff --git a/src/tests/local-vault.test.ts b/src/tests/local-vault.test.ts index e52c8f8..74d5945 100644 --- a/src/tests/local-vault.test.ts +++ b/src/tests/local-vault.test.ts @@ -31,6 +31,36 @@ describe("createLocalApp adapter semantics", () => { const buildApp = async () => (await createLocalApp(vaultDir)) as unknown as LocalApp; + it("parses YAML core values and preserves the original note text", async () => { + const content = ["\uFEFF---", "flag: true # comment", "count: 2.5", "nothing: null", + "date: 2026-09-08", 'quoted: "text # literal"', 'items: ["a,b", c]', + "tags:", "- alpha", "- beta", "indented:", " - one", " - two", + "nested:", " enabled: false", "literal: |", " first", " second", + "folded: >", " first", " second", "---", "# Body", ""].join("\r\n"); + await writeFile(join(vaultDir, "Note.md"), content); + const app = await createLocalApp(vaultDir); + const file = app.vault.getMarkdownFiles()[0]; + expect(app.metadataCache.getFileCache(file)?.frontmatter).toEqual({ + flag: true, count: 2.5, nothing: null, date: "2026-09-08", + quoted: "text # literal", items: ["a,b", "c"], tags: ["alpha", "beta"], + indented: ["one", "two"], nested: { enabled: false }, + literal: "first\nsecond\n", folded: "first second\n", + }); + expect(await app.vault.cachedRead(file)).toBe(content); + }); + + it.each(["", " \n", "null\n", "# comment\n"])("treats empty/null YAML %j as an empty mapping", async (yaml) => { + await writeFile(join(vaultDir, "Note.md"), `---\n${yaml}---\nBody`); + const app = await createLocalApp(vaultDir); + expect(app.metadataCache.getFileCache(app.vault.getMarkdownFiles()[0])?.frontmatter).toEqual({}); + }); + + it.each(["Body", "---\nkey: value\nBody"])("keeps absent or unclosed frontmatter absent: %j", async (content) => { + await writeFile(join(vaultDir, "Note.md"), content); + const app = await createLocalApp(vaultDir); + expect(app.metadataCache.getFileCache(app.vault.getMarkdownFiles()[0])?.frontmatter).toBeUndefined(); + }); + it("indexes only real body block markers without fabricated positions", async () => { await writeFile(join(vaultDir, "Target.md"), [ "---", "property: text ^metadata", "---", "Body ^Known-id", "", From 59d6d55b21e012a348cc209ddc9cebcef11e1aa2 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:38:13 +0800 Subject: [PATCH 11/16] fix: align new finding summaries and review filters --- README.md | 2 +- src/report/InspectorView.ts | 15 ++----- src/report/render-summary.ts | 4 +- src/tests/inspector-view-filters.test.ts | 48 +++++++++++++-------- src/tests/render-summary.test.ts | 55 +++++++++++++++++------- 5 files changed, 77 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 179a72b..0062d04 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ The core workflow is: run a scan, review new findings, then fix or ignore each o 1. Open the command palette and run **Vault Inspector: Run scan**. 2. The Inspector view opens in the right sidebar and shows scan progress while the scanners run. -3. The summary highlights how many findings are new since the last comparable scan. Click **Review new findings** to focus the list on confirmed new findings. +3. The summary highlights how many findings are new since the last comparable scan. Click **Review new findings** to focus the list on all new findings, clearing other filters. Use **Clear filters** to return to all findings. 4. Filter results by scanner, severity, lifecycle, or classification. Expand **Technical evidence** to inspect the raw scanner evidence behind the explanation. 5. Click paths, URLs, targets, properties, or tags to jump to the relevant location. 6. Open a finding's **Actions** menu to ignore it, choose **Exclude parent folder**, or open its scanner settings. Parent-folder exclusions apply only to that scanner and can be removed from **Scanner-specific ignored folders** in settings. diff --git a/src/report/InspectorView.ts b/src/report/InspectorView.ts index a66cbd1..5ca4bbc 100644 --- a/src/report/InspectorView.ts +++ b/src/report/InspectorView.ts @@ -252,17 +252,10 @@ export class InspectorView extends ItemView { renderSummary(container, this.model.result, { comparison: this.model.comparison, onReviewNewFindings: () => { - if ( - this.model.filterStatus === "new" - && this.model.filterClassification === "confirmed" - ) { - this.model.filterStatus = null; - this.model.filterClassification = null; - } else { - this.model.filterStatus = "new"; - this.model.filterClassification = "confirmed"; - this.model.filterSeverity = null; - } + this.model.filterStatus = "new"; + this.model.filterScanner = null; + this.model.filterSeverity = null; + this.model.filterClassification = null; this.render(); }, }); diff --git a/src/report/render-summary.ts b/src/report/render-summary.ts index e37601c..f09bf84 100644 --- a/src/report/render-summary.ts +++ b/src/report/render-summary.ts @@ -4,7 +4,6 @@ import type { CurrentFindingStatus, LifecycleComparison, } from "../scanner/result-diff"; -import { countNewConfirmedFindings } from "./report-model"; import { formatDuration } from "../utils/format"; export type SummaryOptions = { @@ -40,8 +39,7 @@ function renderChanges( return; } - const newConfirmed = countNewConfirmedFindings(result.issues, comparison.statuses); - const newCount = newConfirmed.errors + newConfirmed.warnings; + const newCount = countStatus(result, comparison, "new"); const persistingCount = countStatus(result, comparison, "persisting"); const resolvedCount = comparison.resolvedIssues.filter((issue) => !issue.ignored).length; diff --git a/src/tests/inspector-view-filters.test.ts b/src/tests/inspector-view-filters.test.ts index de57c9f..901c725 100644 --- a/src/tests/inspector-view-filters.test.ts +++ b/src/tests/inspector-view-filters.test.ts @@ -239,7 +239,7 @@ describe("InspectorView report filter wiring", () => { renderIssueListMock.mockClear(); renderReportControlsMock.mockClear(); renderResolvedChangesMock.mockClear(); - renderSummaryMock.mockClear(); + renderSummaryMock.mockReset(); showFolderExclusionModalMock.mockReset(); inspectorNoticeMessages.length = 0; vi.mocked(setTooltip).mockClear(); @@ -662,42 +662,56 @@ describe("InspectorView report filter wiring", () => { expect(controlsOrder).toBeGreaterThan(summaryOrder); }); - it("applies and releases the review-new preset without hiding other results", () => { + it("reviews all new findings idempotently and exits through Clear filters", async () => { + const { renderSummary } = await vi.importActual( + "../report/render-summary", + ); + renderSummaryMock.mockImplementation(renderSummary); const container = new FakeElement(); const view = new InspectorView(new WorkspaceLeaf()); (view as any).containerEl.children[1] = container; const newError = makeIssue("broken-links", "error", "new-confirmed"); - const newCandidate = makeIssue("broken-links", "error", "new-candidate", "candidate"); + const newCandidate = makeIssue("duplicate-files", "warning", "new-candidate", "candidate"); + const newUnverified = makeIssue("external-links", "info", "new-unverified", "unverified"); const persisting = makeIssue("duplicate-files", "warning", "persisting-confirmed"); (view as any).model.result = { ...result, - issues: [newError, newCandidate, persisting], + issues: [newError, newCandidate, newUnverified, persisting], }; (view as any).model.comparison = comparable([ ["new-confirmed", "new"], ["new-candidate", "new"], + ["new-unverified", "new"], ["persisting-confirmed", "persisting"], ]); + (view as any).model.filterScanner = "duplicate-files"; (view as any).model.filterSeverity = "warning"; - + (view as any).model.filterClassification = "confirmed"; + (view as any).model.filterStatus = "persisting"; (view as any).render(); - renderSummaryMock.mock.lastCall?.[2].onReviewNewFindings(); - expect((view as any).model.filterStatus).toBe("new"); - expect((view as any).model.filterClassification).toBe("confirmed"); - expect((view as any).model.filterSeverity).toBeNull(); - expect(renderIssueListMock).toHaveBeenLastCalledWith( - expect.any(FakeElement), - expect.objectContaining({ issues: [newError] }), - ); - - renderSummaryMock.mock.lastCall?.[2].onReviewNewFindings(); + for (let click = 0; click < 2; click++) { + expect(findByText(container, "3 new findings")).toBeDefined(); + const button = findByText(container, "Review new findings"); + expect(button).toBeDefined(); + button?.click(); + expect((view as any).model.filterStatus).toBe("new"); + expect((view as any).model.filterScanner).toBeNull(); + expect((view as any).model.filterSeverity).toBeNull(); + expect((view as any).model.filterClassification).toBeNull(); + expect(renderIssueListMock).toHaveBeenLastCalledWith( + expect.any(FakeElement), + expect.objectContaining({ issues: [newError, newCandidate, newUnverified] }), + ); + } + const clear = findByText(container, "Clear filters"); + expect(clear).toBeDefined(); + clear?.click(); expect((view as any).model.filterStatus).toBeNull(); - expect((view as any).model.filterClassification).toBeNull(); expect(renderIssueListMock).toHaveBeenLastCalledWith( expect.any(FakeElement), - expect.objectContaining({ issues: [newError, persisting, newCandidate] }), + expect.objectContaining({ issues: [newError, persisting, newCandidate, newUnverified] }), ); }); diff --git a/src/tests/render-summary.test.ts b/src/tests/render-summary.test.ts index 20f0cf4..55adff5 100644 --- a/src/tests/render-summary.test.ts +++ b/src/tests/render-summary.test.ts @@ -187,7 +187,7 @@ describe("renderSummary", () => { }); const text = flatten(container); - expect(text).toContain("2 new findings"); + expect(text).toContain("3 new findings"); expect(text).toContain("1 resolved"); expect(text).toContain("Review new findings"); expect(text).toContain("2 previously found"); @@ -195,21 +195,42 @@ describe("renderSummary", () => { expect(text).toContain("compared with"); expect(text).not.toContain("PERSISTING"); expect(text).toContain("8 files scanned1.0s2 scannersIgnored 2"); - expect(findByText(container, "2 new findings")?.cls).toContain("vi-changes-primary"); + expect(findByText(container, "3 new findings")?.cls).toContain("vi-changes-primary"); expect(findByText(container, "1 resolved")?.cls).toContain("vi-changes-resolved"); }); - it("counts only confirmed new findings in the headline", () => { + it("counts all new classifications and severities while excluding ignored findings", () => { + const issues = (["confirmed", "candidate", "unverified"] as const).flatMap( + (classification) => (["error", "warning", "info"] as const).map( + (severity) => activeIssue(`${classification}-${severity}`, severity, classification), + ), + ); + const comparison = compatibleComparison(); + comparison.statuses = new Map([...issues, ...resultWithLifecycle.ignoredIssues] + .map((issue) => [issue.fingerprint, "new"])); const container = new FakeElement(); - renderSummary(container as unknown as HTMLElement, resultWithLifecycle, { - comparison: compatibleComparison(), - }); - - const text = flatten(container); - expect(text).toContain("2 new findings"); - expect(text).not.toContain("3 new findings"); + renderSummary(container as unknown as HTMLElement, { + ...resultWithLifecycle, issues, + }, { comparison }); + expect(flatten(container)).toContain("9 new findings"); }); + it.each(["confirmed", "candidate", "unverified"] as const)( + "offers review for a single %s info finding", + (classification) => { + const container = new FakeElement(); + const onReviewNewFindings = vi.fn(); + renderSummary(container as unknown as HTMLElement, { + ...resultWithLifecycle, issues: [activeIssue("new-error", "info", classification)], + }, { comparison: compatibleComparison(), onReviewNewFindings }); + expect(flatten(container)).toContain("1 new finding"); + const button = findByText(container, "Review new findings"); + expect(button).toBeDefined(); + button?.click(); + expect(onReviewNewFindings).toHaveBeenCalledOnce(); + }, + ); + it("uses the singular form for one new finding", () => { const singleNew = { ...resultWithLifecycle, issues: [activeIssue("new-error")] }; const container = new FakeElement(); @@ -227,6 +248,7 @@ describe("renderSummary", () => { const container = new FakeElement(); renderSummary(container as unknown as HTMLElement, singleActive, { comparison: firstScanComparison(), + onReviewNewFindings: vi.fn(), }); const text = flatten(container); @@ -239,6 +261,7 @@ describe("renderSummary", () => { const container = new FakeElement(); renderSummary(container as unknown as HTMLElement, resultWithLifecycle, { comparison: firstScanComparison(), + onReviewNewFindings: vi.fn(), }); const text = flatten(container); @@ -255,6 +278,7 @@ describe("renderSummary", () => { const container = new FakeElement(); renderSummary(container as unknown as HTMLElement, resultWithLifecycle, { comparison: settingsChangedComparison(), + onReviewNewFindings: vi.fn(), }); const text = flatten(container); @@ -288,7 +312,7 @@ describe("renderSummary", () => { expect(text).toContain("previous successful scan:"); }); - it("offers a review control when there are new confirmed findings", () => { + it("offers a review control when there are new findings", () => { const container = new FakeElement(); const onReviewNewFindings = vi.fn(); renderSummary(container as unknown as HTMLElement, resultWithLifecycle, { @@ -306,18 +330,19 @@ describe("renderSummary", () => { expect(onReviewNewFindings).toHaveBeenCalledTimes(1); }); - it("omits the review control without new confirmed findings or a callback", () => { + it("omits the review control without new findings or a callback", () => { const persistingOnly = { ...resultWithLifecycle, issues: [activeIssue("persisting-a")] }; - const noNewConfirmed = new FakeElement(); - renderSummary(noNewConfirmed as unknown as HTMLElement, persistingOnly, { + const noNew = new FakeElement(); + renderSummary(noNew as unknown as HTMLElement, persistingOnly, { comparison: { ...compatibleComparison(), statuses: new Map([["persisting-a", "persisting"]]), }, onReviewNewFindings: vi.fn(), }); - expect(flatten(noNewConfirmed)).not.toContain("Review new findings"); + expect(flatten(noNew)).toContain("0 new findings"); + expect(flatten(noNew)).not.toContain("Review new findings"); const noCallback = new FakeElement(); renderSummary(noCallback as unknown as HTMLElement, resultWithLifecycle, { From c465480e44b7e2ada20977b431ff07241e2b3b30 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:43:24 +0800 Subject: [PATCH 12/16] fix: preserve comma-containing paths in duplicate reports --- src/report/markdown-export.ts | 8 +---- src/report/render-issues.ts | 8 +---- src/tests/helpers/duplicate-report.ts | 43 +++++++++++++++++++++++++++ src/tests/markdown-export.test.ts | 9 ++++++ src/tests/render-evidence.test.ts | 28 ++++++++++++++++- 5 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 src/tests/helpers/duplicate-report.ts diff --git a/src/report/markdown-export.ts b/src/report/markdown-export.ts index f8f1deb..5e2d16d 100644 --- a/src/report/markdown-export.ts +++ b/src/report/markdown-export.ts @@ -117,7 +117,7 @@ function getMarkdownDetails(issue: Issue): MarkdownDetail[] { if (count !== null) details.push({ label: "Count", value: String(count) }); const size = getNumber(issue.evidence.size); if (size !== null) details.push({ label: "Size", value: formatSize(size) }); - const paths = getEvidencePaths(issue); + const paths = issue.relatedPaths; if (paths.length > 0) { details.push({ label: "Files", @@ -197,12 +197,6 @@ function getTargetLabel(issue: Issue): string { return "Target"; } -function getEvidencePaths(issue: Issue): string[] { - const paths = issue.evidence.paths; - if (typeof paths !== "string") return issue.relatedPaths; - return paths.split(",").map((path) => path.trim()).filter(Boolean); -} - function getNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } diff --git a/src/report/render-issues.ts b/src/report/render-issues.ts index fac8b9b..a7024d0 100644 --- a/src/report/render-issues.ts +++ b/src/report/render-issues.ts @@ -290,7 +290,7 @@ function getIssueDetailRows(issue: Issue): IssueDetailRow[] { if (issue.scannerId === "duplicate-files") { const count = getNumber(issue.evidence.count); if (count !== null) rows.push({ label: "Count", value: String(count) }); - const paths = getEvidencePaths(issue); + const paths = issue.relatedPaths; if (paths.length > 0) { rows.push({ label: "Files", @@ -418,12 +418,6 @@ function getTargetLabel(issue: Issue): string { return "Target"; } -function getEvidencePaths(issue: Issue): string[] { - const paths = issue.evidence.paths; - if (typeof paths !== "string") return issue.relatedPaths; - return paths.split(",").map((path) => path.trim()).filter(Boolean); -} - function getNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } diff --git a/src/tests/helpers/duplicate-report.ts b/src/tests/helpers/duplicate-report.ts new file mode 100644 index 0000000..b6fcdb1 --- /dev/null +++ b/src/tests/helpers/duplicate-report.ts @@ -0,0 +1,43 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { runCli } from "../../../cli/cli"; +import type { Issue } from "../../scanner/Issue"; +import { generateFingerprint } from "../../scanner/issue-fingerprint"; + +export const duplicatePathCases = [ + ["b.png", "a,one.png"], + ["中文,副本.png", "b copy.png", "a, one.png"], +]; + +/** Exercise the real scanner and public JSON before rendering its finding. */ +export async function scanDuplicatePaths(paths: string[]): Promise { + const root = await mkdtemp(join(tmpdir(), "vi-duplicate-report-")); + try { + for (const path of paths) await writeFile(join(root, path), "identical bytes"); + // Give the first input two references so the comma-named copy is a trash target. + await writeFile(join(root, "Source.md"), [...paths, paths[0]!].map((path) => `![copy](<${path}#fragment>)`).join("\n")); + const output = await runCli([root, "--scanner", "duplicate-files", "--format", "json", "--fail-on", "none"]); + expect(output.stderr).toBe(""); + expect(output.exitCode).toBe(0); + const payload = JSON.parse(output.stdout) as { schemaVersion: number; issues: Issue[] }; + expect(payload.schemaVersion).toBe(1); + expect(payload.issues).toHaveLength(1); + const issue = payload.issues[0]!; + const sorted = [...paths].sort(); + expect(issue.relatedPaths).toEqual(sorted); + expect(typeof issue.evidence.paths).toBe("string"); + expect(issue.evidence.paths).toBe(sorted.join(", ")); + expect(Object.keys(issue.evidence).sort()).toEqual([ + "count", "hashState", "mtimes", "paths", "referenceCounts", "referencedPaths", + ]); + expect(issue.fingerprint).toBe(generateFingerprint("duplicate-files", undefined, { paths: sorted.join(",") })); + expect(issue.fixAction).toMatchObject({ targetPaths: sorted.filter((path) => path !== paths[0]), selection: { candidatePaths: sorted, automaticKeepPath: paths[0] } }); + expect(issue.evidence.referenceCounts).toBe(sorted.map((path) => path === paths[0] ? 2 : 1).join(",")); + expect(issue.impact).toMatchObject({ filesTrashed: paths.length - 1, inboundReferences: paths.length - 1, coverageComplete: true }); + return issue; + } finally { + await rm(root, { recursive: true, force: true }); + } +} diff --git a/src/tests/markdown-export.test.ts b/src/tests/markdown-export.test.ts index caf2459..c7373bc 100644 --- a/src/tests/markdown-export.test.ts +++ b/src/tests/markdown-export.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { generateMarkdownReport } from "../report/markdown-export"; import type { ScanResult } from "../scanner/Issue"; +import { duplicatePathCases, scanDuplicatePaths } from "./helpers/duplicate-report"; function makeResult(overrides: Partial = {}): ScanResult { return { @@ -15,6 +16,14 @@ function makeResult(overrides: Partial = {}): ScanResult { } describe("generateMarkdownReport", () => { + it.each(duplicatePathCases.map((paths) => [paths]))("preserves duplicate paths and ordering in Markdown for %j", async (paths) => { + const issue = await scanDuplicatePaths(paths); + const original = JSON.stringify(issue); + const report = generateMarkdownReport(makeResult({ issues: [issue], scannersRun: ["duplicate-files"] })); + expect(report.match(/^ - `.*`$/gm)).toEqual(issue.relatedPaths.map((path) => ` - \`${path}\``)); + expect(JSON.stringify(issue)).toBe(original); + }); + it("renders compact summaries without finding details", () => { const markers = [ "ACTIVE_TITLE_MARKER", diff --git a/src/tests/render-evidence.test.ts b/src/tests/render-evidence.test.ts index fbb3b5e..8a609ee 100644 --- a/src/tests/render-evidence.test.ts +++ b/src/tests/render-evidence.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Issue } from "../scanner/Issue"; import type { CurrentFindingStatus } from "../scanner/result-diff"; import { renderFindingEvidence } from "../report/render-evidence"; import { renderIssueList } from "../report/render-issues"; +import { duplicatePathCases, scanDuplicatePaths } from "./helpers/duplicate-report"; type ElementOptions = { cls?: string; @@ -206,6 +207,31 @@ describe("renderFindingEvidence", () => { }); describe("renderIssueList finding metadata", () => { + beforeEach(() => vi.stubGlobal("window", { getSelection: () => null })); + afterEach(() => vi.unstubAllGlobals()); + + it.each(duplicatePathCases.map((paths) => [paths]))("preserves duplicate file tokens and click targets for %j", async (paths) => { + const issue = await scanDuplicatePaths(paths); + const original = JSON.stringify(issue); + const container = new FakeElement(); + const onOpenIssue = vi.fn(); + renderIssueList(container as unknown as HTMLElement, { + issues: [issue], scannersRun: ["duplicate-files"], selectionMode: false, + selectedFingerprints: new Set(), onOpenIssue, onToggleSelect: () => {}, + }); + const tokens = findByClass(container, "vi-issue-path-token"); + expect(tokens.map((token) => token.text)).toEqual(issue.relatedPaths); + for (const [index, token] of tokens.entries()) { + token.click(); + expect(onOpenIssue).toHaveBeenNthCalledWith(index + 1, { + ...issue, primaryPath: issue.relatedPaths[index], + relatedPaths: issue.relatedPaths.filter((_, pathIndex) => pathIndex !== index), + }); + } + expect(onOpenIssue).toHaveBeenCalledTimes(paths.length); + expect(JSON.stringify(issue)).toBe(original); + }); + function render(statuses?: ReadonlyMap): FakeElement { const container = new FakeElement(); renderIssueList(container as unknown as HTMLElement, { From 11eb75c2220904cf51be96483a59ea70805ba726 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:46:56 +0800 Subject: [PATCH 13/16] fix: provide Web Crypto for Node 18 CLI scans --- cli/bin.ts | 5 +++++ src/tests/cli.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/cli/bin.ts b/cli/bin.ts index 2b4299c..0dc3de5 100644 --- a/cli/bin.ts +++ b/cli/bin.ts @@ -1,5 +1,10 @@ +import { webcrypto } from "node:crypto"; import { runCli } from "./cli"; +if (typeof globalThis.crypto === "undefined") { + Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true }); +} + runCli(process.argv.slice(2), { writeStderr: (text) => process.stderr.write(text), }).then((result) => { diff --git a/src/tests/cli.test.ts b/src/tests/cli.test.ts index 93c83c3..156c4b2 100644 --- a/src/tests/cli.test.ts +++ b/src/tests/cli.test.ts @@ -29,6 +29,37 @@ describe("runCli", () => { vi.restoreAllMocks(); }); + it.each([ + ["absent", "delete globalThis.crypto;\n"], + ["already available", "Object.defineProperty(globalThis, 'crypto', { value: require('node:crypto').webcrypto, configurable: false });\n"], + ])("hashes scan profiles and duplicate files when global Web Crypto is %s", async (_state, preload) => { + await withVault({ "A.md": "Same content", "B.md": "Same content" }, async (vaultPath) => { + const preloadDir = await mkdtemp(join(tmpdir(), "vault-inspector-preload-")); + try { + const preloadPath = join(preloadDir, "preload.cjs"); + await writeFile(preloadPath, preload, "utf8"); + const result = spawnSync(process.execPath, [ + "--require", preloadPath, join(process.cwd(), "cli.js"), vaultPath, + "--format", "json", "--scanner", "duplicate-files", "--fail-on", "none", + ], { encoding: "utf8" }); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.comparison.scanProfile).toMatch(/^[a-f0-9]{64}$/); + expect(payload.issues).toEqual([ + expect.objectContaining({ + scannerId: "duplicate-files", + severity: "warning", + relatedPaths: ["A.md", "B.md"], + evidence: expect.objectContaining({ hashState: "hash-confirmed" }), + }), + ]); + } finally { + await rm(preloadDir, { recursive: true, force: true }); + } + }); + }); + it("treats equivalent YAML syntax equally through the actual CLI bundle", async () => { await withVault({ "A.md": "---\nflag: true # comment\ntags:\n- alpha\n- beta\n---\nBody", From 9827ff96412ae1715ca0a9036489732161efa0ea Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:52:18 +0800 Subject: [PATCH 14/16] fix: honor the first frontmatter closing delimiter --- cli/local-vault.ts | 13 +------------ src/tests/cli.test.ts | 17 +++++++++++++++++ src/tests/frontmatter-section.test.ts | 22 ++++++++++++++++++++++ src/tests/markdown-source.test.ts | 8 ++++++++ src/utils/frontmatter-section.ts | 14 ++++++++++++++ src/utils/markdown-source.ts | 4 ++-- 6 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 src/tests/frontmatter-section.test.ts create mode 100644 src/utils/frontmatter-section.ts diff --git a/cli/local-vault.ts b/cli/local-vault.ts index 7e05b51..ea2088f 100644 --- a/cli/local-vault.ts +++ b/cli/local-vault.ts @@ -5,6 +5,7 @@ import type { App, MetadataCache, TFile, Vault } from "obsidian"; import { extractBareUrls } from "../src/scanner/scanners/external-links"; import type { LinkReference } from "../src/scanner/link-reference"; import { parseMarkdownSource } from "../src/utils/markdown-source"; +import { splitFrontmatter } from "../src/utils/frontmatter-section"; type LocalFile = TFile & { path: string; @@ -292,18 +293,6 @@ function hasUriScheme(text: string): boolean { return /^[a-z][a-z\d+.-]*:/i.test(text); } -function splitFrontmatter(content: string): { - frontmatter?: string; - body: string; -} { - const match = /^\uFEFF?---\r?\n((?:[\s\S]*?\r?\n)?)---(?:\r?\n|$)/.exec(content); - if (!match) return { body: content }; - return { - frontmatter: match[1], - body: content.slice(match[0].length), - }; -} - function stripIgnoredMarkdownRegions(content: string): string { return maskMarkdown( maskMarkdown( diff --git a/src/tests/cli.test.ts b/src/tests/cli.test.ts index 156c4b2..3fd44ff 100644 --- a/src/tests/cli.test.ts +++ b/src/tests/cli.test.ts @@ -73,6 +73,23 @@ describe("runCli", () => { }); }); + it.each(["---\n---\n", "\uFEFF---\r\n---\r\n", "---\nkey: value\n---\n"])("preserves body references after the first frontmatter closing delimiter: %j", async (header) => { + const content = header + "\n[missing](missing.md)\n\n![image](image.png)\n\nBody ^known\n\n---\n\nTail"; + await withVault({ "Note.md": content, "Source.md": "[[Note#^known]]", "image.png": "image" }, async (vaultPath) => { + const args = [vaultPath, "--format", "json", "--scanner", "broken-links,orphan-attachments", "--fail-on", "none"]; + const result = await runCli(args); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + const issues = JSON.parse(result.stdout).issues; + expect(issues).toHaveLength(1); + expect(issues[0].fixAction).toMatchObject({ kind: "remove-link-text", original: "[missing](missing.md)", replacement: "missing" }); + const actual = spawnSync(process.execPath, [join(process.cwd(), "cli.js"), ...args], { encoding: "utf8" }); + expect(actual.stderr).toBe(""); + expect(actual.status).toBe(0); + expect(JSON.parse(actual.stdout).issues).toEqual(issues); + }); + }); + it.each([ "key: [SENSITIVE_SENTINEL", "key: first\nkey: SENSITIVE_SENTINEL", "key: !SENSITIVE_SENTINEL value", "- SENSITIVE_SENTINEL", "SENSITIVE_SENTINEL", diff --git a/src/tests/frontmatter-section.test.ts b/src/tests/frontmatter-section.test.ts new file mode 100644 index 0000000..8b6a527 --- /dev/null +++ b/src/tests/frontmatter-section.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { splitFrontmatter } from "../utils/frontmatter-section"; + +describe("frontmatter section boundaries", () => { + it.each([ + ["---\n---\n", ""], + ["\uFEFF---\r\n---\r\n", ""], + ["---\nkey: value\n---\n", "key: value\n"], + ["\uFEFF---\r\nkey: value\r\n---\r\n", "key: value\r\n"], + ])("preserves every body character after %j", (header, frontmatter) => { + const body = "\r\n[link](missing.md)\r\n\r\n---\r\n\r\nTail\r\n"; + expect(splitFrontmatter(header + body)).toEqual({ frontmatter, body, bodyStart: header.length }); + }); + + it.each(["---\n---", "\uFEFF---\r\n---"])("accepts a closing delimiter at EOF: %j", (content) => { + expect(splitFrontmatter(content)).toEqual({ frontmatter: "", body: "", bodyStart: content.length }); + }); + + it.each(["Body", "\uFEFFBody", "---\nkey: value", "\uFEFF---\r\nkey: value", "---\nkey: value\n--- \nBody"])("preserves absent or unclosed frontmatter: %j", (content) => { + expect(splitFrontmatter(content)).toEqual({ body: content, bodyStart: 0 }); + }); +}); diff --git a/src/tests/markdown-source.test.ts b/src/tests/markdown-source.test.ts index 2d4d92b..22f7d87 100644 --- a/src/tests/markdown-source.test.ts +++ b/src/tests/markdown-source.test.ts @@ -2,6 +2,14 @@ import { describe, expect, it } from "vitest"; import { blockIds, markdownLinks, wikiLinkRanges } from "../utils/markdown-source"; describe("Markdown source ranges", () => { + it.each(["---\n---\n", "\uFEFF---\r\n---\r\n", "---\nkey: value\n---\n"])("ends metadata at the first closing delimiter: %j", (header) => { + const content = header + "\n[missing](missing.md) [[Target]]\n\nBody ^known\n\n---\n\nTail"; + const start = content.indexOf("[missing]"); + expect(markdownLinks(content)).toEqual([{ kind: "link", original: "[missing](missing.md)", destination: "missing.md", start, end: start + "[missing](missing.md)".length }]); + expect(wikiLinkRanges(content)).toEqual([{ start: content.indexOf("[[Target]]"), end: content.indexOf("[[Target]]") + 10 }]); + expect(blockIds(content)).toEqual(["known"]); + }); + it("returns complete balanced links and images with exact offsets", () => { const content = '\uFEFF---\r\nref: "[hidden](x)"\r\n---\r\n[x](Note(1).md "title") ![a]() [e](a\\(b\\).md)'; const links = markdownLinks(content); diff --git a/src/utils/frontmatter-section.ts b/src/utils/frontmatter-section.ts new file mode 100644 index 0000000..516ee77 --- /dev/null +++ b/src/utils/frontmatter-section.ts @@ -0,0 +1,14 @@ +/** Locate the first complete frontmatter section without interpreting its YAML. */ +export function splitFrontmatter(content: string): { frontmatter?: string; body: string; bodyStart: number } { + const opening = /^\uFEFF?---\r?\n/.exec(content); + if (opening) { + const rest = content.slice(opening[0].length); + const closing = /(?:^|\n)---(?:\r?\n|$)/.exec(rest); + if (closing) { + const bodyStart = opening[0].length + closing.index + closing[0].length; + const yamlEnd = closing.index + (closing[0].startsWith("\n") ? 1 : 0); + return { frontmatter: rest.slice(0, yamlEnd), body: content.slice(bodyStart), bodyStart }; + } + } + return { body: content, bodyStart: 0 }; +} diff --git a/src/utils/markdown-source.ts b/src/utils/markdown-source.ts index 1110eb0..f1517b4 100644 --- a/src/utils/markdown-source.ts +++ b/src/utils/markdown-source.ts @@ -1,5 +1,6 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import type { Nodes } from "mdast"; +import { splitFrontmatter } from "./frontmatter-section"; export type SourceRange = { start: number; end: number }; export type MarkdownSourceLink = SourceRange & { @@ -10,8 +11,7 @@ export type MarkdownSourceLink = SourceRange & { /** Mask file metadata without shifting UTF-16 offsets or changing line endings. */ function parseBody(content: string) { - const frontmatter = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content); - const end = frontmatter?.[0].length ?? (content.startsWith("\uFEFF") ? 1 : 0); + const end = splitFrontmatter(content).bodyStart || (content.startsWith("\uFEFF") ? 1 : 0); return fromMarkdown(content.slice(0, end).replace(/[^\r\n]/g, " ") + content.slice(end)); } From 9865480466a3210f748d4056dddf6dfdc95999dd Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:11:43 +0800 Subject: [PATCH 15/16] docs: document corrected scan and CLI behavior --- README.md | 10 +- docs/cli.md | 17 ++- .../plans/2026-09-08-audit-bugfix-plan.md | 107 +++++++++++++----- 3 files changed, 96 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 0062d04..3ce975b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Use it before publishing, exporting, migrating, or cleaning up a long-lived vaul ## What it checks -- **Broken Links** — Detect wiki links, markdown links, and embeds pointing to non-existent notes or headings. +- **Broken Links** — Detect wiki links, markdown links, and embeds pointing to non-existent notes, headings, or blocks. - **Orphan Attachments** — Find images, PDFs, audio/video, and archives not referenced by any note. - **Empty Notes** — Flag notes with no meaningful content beyond frontmatter and title. - **External Links** — Optionally check external URLs for availability (HTTP status). @@ -19,12 +19,12 @@ Use it before publishing, exporting, migrating, or cleaning up a long-lived vaul ### Broken Links -Supports wiki links (`[[Note]]`), aliased links (`[[Note|Display]]`), heading links (`[[Note#Section]]`), markdown links, and embeds (`![[image.png]]`). +Supports wiki links (`[[Note]]`), aliased links (`[[Note|Display]]`), heading links (`[[Note#Section]]`), block links (`[[Note#^block-id]]`), markdown links, and embeds (`![[image.png]]`). Same-note references such as `[[#Section]]` and `[[#^block-id]]` are checked against the current note. - `error` — unresolved link target -- `warning` — missing heading in existing note +- `warning` — missing heading or block in an existing note -Broken link detection relies on Obsidian's metadata cache; links inside code blocks or comments may be missed. +Detection uses Obsidian's metadata cache. Automatic link removal only edits parsed source ranges, preserving code blocks, comments, and escaped examples. Syntax that cannot be safely identified is left unchanged. ### Orphan Attachments @@ -211,7 +211,7 @@ codes, read the [CLI reference](docs/cli.md). ## Privacy and network access -Vault Inspector does not make network requests unless the External Links scanner is enabled. That scanner checks URLs you explicitly have in your notes. In Obsidian this uses Obsidian's `requestUrl`; in the CLI it uses HTTP HEAD requests through the runtime `fetch` API. No vault content leaves your device beyond those link-check requests. +Vault Inspector does not make network requests unless the External Links scanner is enabled. That scanner checks URLs you explicitly have in your notes. In Obsidian this uses Obsidian's `requestUrl`; the CLI uses Node's HTTP/HTTPS transport, starting with HEAD and retrying with a one-byte Range GET when the server rejects HEAD with 405 or 501. No vault content leaves your device beyond those link-check requests. Vault Inspector enumerates vault files and Markdown metadata so scanners can detect broken links, orphan attachments, duplicate files, large files, tag usage, and diff --git a/docs/cli.md b/docs/cli.md index 9c240f3..19cf4fe 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -13,6 +13,12 @@ setup with exit code `2`, even with `--fail-on none`. No report is written to stdout. Errors identify the vault-relative note and line/column without printing property values. Correct the indicated header and rerun the scan. +Markdown links support balanced or escaped parentheses and percent-encoded +file paths and fragments, such as `[note](My%20Note.md#Some%20heading)`. +Decoding occurs once; Wiki links retain literal percent sequences in filenames. +Heading and block references, including `[[#Section]]` and `[[#^block-id]]`, +are checked against the appropriate note. + ## Installation Run it without a global install: @@ -213,6 +219,12 @@ CLI baseline comparison is separate from the Obsidian plugin lifecycle. CLI output does not include plugin scan snapshots or the plugin's resolved-history view. +The corrected reference, link, and YAML handling uses comparison semantics +version `3`; the JSON schema remains version `1`. Regenerate older profile +baselines with the current command and the same detection settings, without +passing `--baseline` to that regeneration run. `--fail-on none` does not bypass +an incompatible baseline error. + ## Exit codes - `0` — scan completed and did not match the configured `--fail-on` threshold. @@ -221,6 +233,7 @@ resolved-history view. not comparable (`settings-changed` / `semantics-changed`). `--fail-on` accepts `any` (default), `warning`, `error`, `new`, and `none`. +An explicit flag, including `--fail-on any`, overrides the configuration file. ## Network access @@ -229,8 +242,8 @@ enabled. That scanner is disabled by default because it makes network requests and depends on external sites, DNS, and rate limits. When enabled with `--scanner external-links`, it checks URLs you explicitly have in your notes — Markdown links, frontmatter links, images/embeds, and bare -HTTP/HTTPS URLs in note bodies — using HTTP HEAD requests through the -runtime `fetch` API. +HTTP/HTTPS URLs in note bodies — using Node's HTTP/HTTPS transport. It starts +with HEAD and retries with a one-byte Range GET when HEAD returns 405 or 501. External link checks are opt-in and network-dependent; timeouts or blocked requests do not necessarily mean a URL is dead. `warning` is reported for diff --git a/docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md b/docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md index 54f2fc1..74c8867 100644 --- a/docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md +++ b/docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md @@ -8,11 +8,11 @@ **Baseline:** `main` at `f1fa6502b9d50c2e848ebd6aed0230910497daa7`。PR #166 已合并;当前产品版本 0.8.0。基线完整门禁通过:56 个测试文件、780 项测试、8 个 npm 打包文件。 -**Status:** 仅计划;下列执行项均未实施。本计划不授权自动发布版本或操作真实库中的内容。 +**Status:** 实施中,进度与验证见第 18 节。本轮不自动发布版本;桌面验证仅使用隔离测试内容。 ## 1. 完整问题清单与提交映射 -编号沿用审查的逻辑顺序。10 类问题展开为 11 个修复提交;最后 1 个文档与整体验收提交,总计 12 个实现提交。计划文档自身如需提交,单独使用 `docs: add audit bugfix implementation plan`,不计入实现提交。 +编号沿用审查的逻辑顺序。10 类问题展开为 11 个修复提交;实际 Node 18 验收新增 C12a 兼容性修复,集成审查新增 C12b frontmatter 边界修复,最后是 C12 文档提交,共 14 个执行提交。计划文档自身使用独立的 `docs: add audit bugfix implementation plan`,不计入执行提交。 | 问题 | 优先级 | 明确覆盖的根因/场景 | 提交 | |---|---|---|---| @@ -31,12 +31,12 @@ ## 2. 修复顺序与边界 -按 C01 → C02 → C03 → C04 → C05 → C06 → C07 → C08 → C09 → C10 → C11 → C12 顺序集成。 +按 C01 → C02 → C03 → C04 → C05 → C06 → C07 → C08 → C09 → C10 → C11 → C12a → C12b → C12 顺序集成。 - 第一阶段 C01–C04:文件操作安全与插件链接正确性。C02 新建解析模块,C03 的 CLI 块提取、C07 的 Markdown 提取可复用它。 - 第二阶段 C05–C09:CLI 退出码、路径和 YAML;外链仍保持独立 scanner commit。 - 第三阶段 C10–C11:摘要和路径展示。两个展示提交不掺入 scanner 修改。 -- 第四阶段 C12:全问题复现、包验证、真实 Obsidian 验收及文档收尾。 +- 第四阶段 C12a/C12b/C12:补齐实际发现的 Node 18 兼容性问题,完成全问题复现、包验证、真实 Obsidian 验收及文档收尾。 - 测试设计可以并行,但修改 `cli/local-vault.ts` 的 C03/C06/C07/C09 必须串行集成,禁止多人同时重写该文件。 - 不做大规模架构重构,不增加扫描器、设置模式、修复命令或网络默认开关。 - 不更改公开 `Issue` / CLI JSON 字段类型;尤其不把 `evidence.paths` 从字符串改为数组。 @@ -73,7 +73,7 @@ npm run lint && npm run lint:obsidian-warnings && npm run build && npm test && n 预期全部退出 0;`npm pack --dry-run` 仍为既定 8 个文件。测试数量会增加,不能以“仍为 780”作为验收标准。缓存不可写时可用独立临时 npm cache;loopback 测试因权限失败时须在允许本地监听的环境重跑,不得删测试或禁用规则。 -所有提交 conventional commits,英文、描述实际修改。不得 `eslint-disable obsidianmd/*`。工作分支建议 `fix/audit-correctness`;当前请求只产生计划,不创建实现分支。 +所有提交 conventional commits,英文、描述实际修改。不得 `eslint-disable obsidianmd/*`。实现分支为 `fix/audit-correctness`。 ## 4. C01 — 修复引用索引并建立新的比较基线 @@ -258,7 +258,7 @@ function decodeDestinationPart(value: string): string { } ``` -在 adapter 内用源文件 + 原始 destination 保存解析结果,路径 lookup 使用解码后的 path,片段校验使用解码后的 fragment;不要把已解码包含 `#` 的文件名重新拼回字符串再 `split('#')`。如当前 scanner API 需要通过字符串传递,增加内部结构化解析/lookup helper,使 path/subpath 只拆一次,不修改公开 JSON schema。 +在每条 metadata reference 上保存独立的 `{ path, fragment, resolvedPath }`,不能只用源文件 + 原始字符串作共享 lookup:同一源文件的 Markdown 与 Wiki 可以具有相同原始文本但不同语义。路径 lookup 使用解码后的 path,片段校验使用解码后的 fragment;不要把已解码包含 `#` 的文件名重新拼回字符串再 `split('#')`。scanner 和引用索引优先使用内部结果(包括权威 null),native metadata 没有内部字段时保持原有解析分支。仅当片段解码改变语义时给 fingerprint 增加内部判别信息,保留公开 evidence/original 和其它输入的既有身份。 **回归:** `My%20Note.md`、百分号编码中文路径、`./`/`../`、路径内 `%23`/`%25`、编码标题、angle destination;合法 Wiki `[[My%20Note]]` 应仍能指向字面 `%20` 文件名;坏 `%` 序列不抛异常、不崩溃,按现有未解析链接处理。JSON evidence/original 的兼容性有明确断言。 @@ -414,12 +414,12 @@ npm test -- src/tests/render-issue-actions.test.ts src/tests/markdown-export.tes **Files:** `README.md`、`docs/cli.md`、本计划;集成测试若有缺口必须归回对应修复提交,不将多扫描器代码混进文档提交。 -- [ ] README/CLI reference 准确说明块/同页链接、新增发现 CTA、旧 baseline 需要重新生成、YAML 错误退出 2。 -- [ ] 修正文档中 CLI HTTP transport 的失真说明:以 `cli/public-http.ts` 的实际 HEAD、405/501 Range GET fallback 为准;不声称 CLI 使用 runtime fetch。此为审查附带的低优先级文档问题,也在本轮收尾覆盖。 -- [ ] 运行完整门禁以及 `npm run test:coverage`,阈值不降低。 -- [ ] 将 `npm pack` 生成的包安装到临时目录,以包内命令测试 C05/C06/C07/C09 的 fixture;不只运行 TS 测试。确认 cli.js 不要求额外未打包依赖、Node 18 可启动。 -- [ ] 检查 `main.js` 的外部依赖仍在既定 Obsidian/Electron 边界;两种 parser 均不以运行时网络下载方式加载。 -- [ ] 记录最终构建大小和性能。至少用一份普通笔记、一份大量链接笔记确认不会对每次命中重复整篇解析;只读扫描 benchmark 对比 baseline,不用单次噪声宣称提升。 +- [x] README/CLI reference 准确说明块/同页链接、新增发现 CTA、旧 baseline 需要重新生成、YAML 错误退出 2。 +- [x] 修正文档中 CLI HTTP transport 的失真说明:以 `cli/public-http.ts` 的实际 HEAD、405/501 Range GET fallback 为准;不声称 CLI 使用 runtime fetch。此为审查附带的低优先级文档问题,也在本轮收尾覆盖。 +- [x] 运行完整门禁以及 `npm run test:coverage`,阈值不降低。 +- [x] 将 `npm pack` 生成的包安装到临时目录,以包内命令测试 C05/C06/C07/C09 的 fixture;不只运行 TS 测试。确认 cli.js 不要求额外未打包依赖、Node 18 可启动。 +- [x] 检查 `main.js` 的外部依赖仍在既定 Obsidian/Electron 边界;两种 parser 均不以运行时网络下载方式加载。 +- [x] 记录最终构建大小和性能。至少用一份普通笔记、一份大量链接笔记确认不会对每次命中重复整篇解析;只读扫描 benchmark 对比 baseline,不用单次噪声宣称提升。 - [ ] 使用 `/Users/Roger/my-vault` 下隔离测试目录进行以下验收;仅修改明确创建的 fixture,真实用户笔记不执行修复。必要文件操作遵守宿主权限。 ### Obsidian 手工验收表 @@ -437,12 +437,12 @@ npm test -- src/tests/render-issue-actions.test.ts src/tests/markdown-export.tes ### 完成标准 -- [ ] B01–B10 全部有持久回归测试;B07a/B07b、B08a/B08b 均单独覆盖。 -- [ ] 两个 P1 有执行链验证,而不仅是函数返回值测试。 -- [ ] CLI 与 desktop 的 resolver 差异有契约测试,测试替身不再自动剥片段掩盖问题。 -- [ ] 修复后 12 个提交各自范围清晰,所有 scanner 修改独立于报告 UI commit。 -- [ ] 工作区干净;PR 的确切 head 已验证;CI verify 通过。 -- [ ] 未完成的手工验收明确标为未完成,不把单元测试通过当作完整上线验收。 +- [x] B01–B10 全部有持久回归测试;B07a/B07b、B08a/B08b 均单独覆盖。 +- [x] 两个 P1 有执行链验证,而不仅是函数返回值测试。 +- [x] CLI 与 desktop 的 resolver 差异有契约测试,测试替身不再自动剥片段掩盖问题。 +- [x] 14 个执行提交各自范围清晰,所有 scanner 修改独立于报告 UI commit。 +- [x] 本地执行提交完成;提交后核对工作区干净。远端 PR/CI 不属于本轮授权范围,推送后另行验证 exact head。 +- [x] 未完成的手工验收明确标为未完成,不把单元测试通过当作完整上线验收。 ## 16. 提交一览 @@ -459,9 +459,11 @@ npm test -- src/tests/render-issue-actions.test.ts src/tests/markdown-export.tes | C09 | `fix: parse CLI frontmatter with YAML semantics` | B08a + B08b | | C10 | `fix: align new finding summaries and review filters` | B09 | | C11 | `fix: preserve comma-containing paths in duplicate reports` | B10 | +| C12a | `fix: provide Web Crypto for Node 18 CLI scans` | 实际最低版本运行时验收发现的兼容性问题 | +| C12b | `fix: honor the first frontmatter closing delimiter` | CLI YAML splitting and shared source masking use the same first closing delimiter | | C12 | `docs: document corrected scan and CLI behavior` | 文档、覆盖清单、验收记录 | -默认一个修复分支、12 个可独立审查的实现提交,CI 全绿后开 PR。若需要优先交付安全修复,可在 C04 后先开第一组 PR;剩余工作仍须完成 C05–C12,不得因为首组发布而缩减范围。实际提交/推送/PR/发布按后续明确执行请求处理。 +一个修复分支承载 14 个可独立审查的执行提交。本轮完成本地实现、提交与验证;推送、PR 和发布单独处理。不得因为先完成安全修复而缩减后续问题范围。 ## 17. 解析依赖依据 @@ -476,19 +478,62 @@ Clarifications before implementation: - C02 source-range parsing must prove preservation with executable fixtures before replacing the executor's existing protection logic. Unsupported syntax must fail closed, never fall back to global replacement. - C06 keeps raw link identity and original source intact. Decoding belongs to the CLI adapter; decoded file paths containing `#` must not be reparsed as fragments. Internal destination metadata may be added without changing public report fields. +- C06 implementation detail: store `{ path, fragment, resolvedPath }` per metadata reference, with authoritative null for unresolved targets. Split literal fragment before decoding Markdown components once; never decode Wiki paths. Consumers must distinguish references with identical raw text but different destinations. Remove the ambiguous shared raw-string destination lookup, preserving native metadata fallback. - C09 invalid YAML is a setup failure with exit 2 and a sanitized filename/position message. This behavior must be explicitly tested and documented. +Integration follow-ups: explicitly assert cross-note block reference kinds and duplicate impact counts (C01 reviewer suggested stronger direct coverage); rerun the 400-note/150-attachment benchmark against the recorded baseline in `/private/tmp/vi-audit-baseline-benchmark.json`. Desktop acceptance uses the authorized my-vault with an isolated fixture directory; original plugin assets and preferences were backed up before testing. + | Task | State | Verification | |---|---|---| -| C01 | pending | | -| C02 | pending | | -| C03 | pending | | -| C04 | pending | | -| C05 | pending | | -| C06 | pending | | -| C07 | pending | | -| C08 | pending | | -| C09 | pending | | -| C10 | pending | | -| C11 | pending | | -| C12 | pending | | +| C01 | complete — `79945fd` | RED reproduced; full gate passed, 788 tests, 8 package files; independent review passed. Desktop verification remains part of C12. | +| C02 | complete — `4096055` | RED reproduced; full gate passed, 806 tests, 8 package files; independent review and 37 focused tests passed. main.js +136,579 bytes, only external runtime import obsidian; 273k-character parsing median 208ms. | +| C03 | complete — `7a03fa0` | 9 regressions RED/GREEN; full gate 815 tests, 8 package files. 400-note benchmark: load 122ms, scan 67ms, same 246 issues; C07 subsequently shared AST parsing. | +| C04 | complete — `5bd8a3b` | 7 initial failures reproduced, 13 focused cases green; full gate 828 tests, 8 package files. | +| C05 | complete — `f818f90` | Explicit-any RED failures fixed; six-case priority matrix plus incompatible baseline test passed; full gate 834 tests, package boundary unchanged. | +| C06 | complete — `bd444d9` | 23 regressions; raw destination collisions, extensionless-file cache access, and decoded-fragment fingerprint collision reproduced and fixed. Full gate 857 tests, 8 package files. Independent review approved after verifying baseline new=1/persisting=1 and unchanged identities for unaffected inputs. | +| C07 | complete — `344b36f` | 8 RED failures fixed; full gate 866 tests, 8 package files. Shared parser, CLI linked-image/orphan integration passed. 400-note benchmark: load 111ms, scan 53ms, same 246 issues. | +| C08 | complete — `5f1c7ed` | 6 RED regressions fixed; full gate 875 tests, 8 package files; all network behavior mocked. | +| C09 | complete — `5c1035` | 8 RED regressions fixed; full gate 888 tests, 8 package files. js-yaml lock remains 4.2.0; actual Node 24 CLI package-audit script passes all 7 checks, including sanitized YAML setup failure. | +| C10 | complete — `59d6d55` | 6 RED failures fixed; full gate 891 tests. Canonical active collection excludes ignoredIssues; real button wiring verifies repeat/clear behavior. Automatic notification helper unchanged. | +| C11 | complete — `c465480` | 4 real CLI→UI/Markdown RED regressions fixed; full gate 895 tests, 8 package files. Comma/space/Chinese-comma paths, clicks, metadata identity, and duplicate inbound impact directly verified. | +| C12a | complete — `11eb75c` | Actual absent-crypto subprocess RED reproduced; full gate 897 tests; actual Node 18.20.8 package-audit 7 checks pass, existing crypto retained. Independent review approved. | +| C12b | complete — `9827ff9` | Four RED cases fixed with shared first-closing-delimiter boundaries. Full gate and final coverage pass: 914 tests / 60 files. Independent review reproduced LF/BOM/CRLF and body horizontal-rule cases against the actual CLI and approved. | +| C12 | code/package validation complete; desktop cleanup blocked | Documentation updated; full lint/warning-lint/build/test/package gate passes (914 tests). Installed package passes seven scenario checks on Node 18.20.8 and Node 24.16.0. Desktop results and remaining boundaries below. | + +## 19. Additional runtime regression found during package validation + +**C12a — `fix: provide Web Crypto for Node 18 CLI scans`** + +A real Node 18.20.8 runtime, within the declared supported range, starts the command but actual scans fail with `crypto is not defined`. The baseline CLI entrypoint also lacks initialization, while shared browser-safe hashing uses `crypto.subtle` for scan profiles and duplicate checks. + +- Initialize missing global Web Crypto from `node:crypto` at the dedicated CLI entrypoint before calling `runCli`; preserve an existing implementation. +- Keep Node imports out of plugin sources and preserve shared hashing behavior. +- Add a real CLI subprocess regression with Web Crypto absent, then rerun the complete commit gate. +- Verify the packed and installed CLI on actual Node 18.20.8, without experimental flags. +- Commit this correction separately after C11 and before C12 documentation. This adds one focused implementation commit to the original twelve-step plan. + +## 20. Final acceptance evidence + +- Final code: `9827ff9`; 914 tests across 60 files, unchanged coverage thresholds. Coverage: statements 82.69%, branches 79.60%, functions 79.30%, lines 84.36%. +- Packed and locally installed package: 8 files, 164,688 bytes compressed / 784,489 bytes unpacked. `main.js` 352,137 bytes; `cli.js` 395,702 bytes. Plugin bundle external import remains only `obsidian`; Markdown/YAML parsers are bundled. +- Installed CLI: actual Node 18.20.8 and Node 24.16.0 pass encoded-space/Wiki collision, balanced parentheses, encoded-hash heading, block/same-note links, fail-on priority, YAML equivalence and sanitized setup-error checks. +- Serial 400-note benchmark: same 552 files and 246 issues as baseline; load median 117ms versus 56ms baseline, scan median 54ms versus 75ms. Parsing adds load cost; this is not a performance improvement claim. A concurrent coverage run distorted timing and was excluded; retained serial scan samples are 63/54/51ms. Shared AST parsing avoids per-link full-note reparsing; C02 large-note parser measurement was 208ms median for 273k characters / 3,000 links. + +### Desktop observations (Obsidian 1.13.7) + +| Scenario | Actual result | +|---|---| +| Upgrade snapshot | Scanner behavior changed / comparison restarted shown; no false resolved items. | +| PDF page embed, existing block and same-note heading | Isolated source references resolve; referenced PDF is not orphaned; missing heading and block have specific diagnostics. | +| Source-preserving fix | Actual confirm/apply flow changes only the real Markdown link; indented code and escaped copies remain byte-for-byte intact. Disk assertions passed. | +| New findings | Four new findings across warning/info and confirmed/candidate/unverifiable classes; CTA shows exactly four. Repeated CTA remains in New mode; Clear filters exits. Prior-filter clearing is additionally covered by persistent wiring tests. | +| Comma duplicate paths | Two intact paths shown; clicking `a,one.svg` opens that exact image. CLI-to-Markdown export paths are covered by persistent tests; desktop export command not yet manually exercised. | +| Duplicate safety | Malformed Canvas disables duplicate trash fixes. After coverage restoration, explicit keep selection is required; only disposable `b.svg` is trashed and the report shows Fixed 1. Batch-exclusion and preflight race branches remain automated-test coverage rather than manual timing tests. | +| Resolved badge | Dark and light default themes, narrow right sidebar: green outline and readable RESOLVED text; no solid empty green bar. | + +### Remaining runtime observations and acceptance boundaries + +- The immediate verification scan after a successful source edit displayed Still present 1 once; a later manual scan correctly marked it resolved. Disk content was correct. This indicates a native metadata-refresh timing boundary and is recorded for follow-up; do not claim immediate desktop verification is always synchronized. +- Native Obsidian normalizes an unresolved Markdown `.md` destination in its unresolved-link map; the original `.md` fixture was reported without an automatic fix action. An extensionless equivalent exposed the fix action and was used for source-preservation acceptance. This pre-existing conservative fallback was not expanded by this plan. +- Desktop export and manual preflight-race/batch-exclusion walkthroughs are not claimed complete; their persistent integration tests pass. No PR or release has been created in this execution. +- Test environment restoration: pending manual unlock after the host automatically locked during restoration. Original assets/preferences backup is `/private/tmp/vi-audit-ui-backup`; isolated fixture directory is `99-Vault Inspector Audit 2026-09-08`. From f15a00cf65a393596a4f976025985028b78029d9 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:58:36 +0800 Subject: [PATCH 16/16] docs: record completed test environment restoration --- docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md b/docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md index 74c8867..1dd40cf 100644 --- a/docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md +++ b/docs/superpowers/plans/2026-09-08-audit-bugfix-plan.md @@ -12,7 +12,7 @@ ## 1. 完整问题清单与提交映射 -编号沿用审查的逻辑顺序。10 类问题展开为 11 个修复提交;实际 Node 18 验收新增 C12a 兼容性修复,集成审查新增 C12b frontmatter 边界修复,最后是 C12 文档提交,共 14 个执行提交。计划文档自身使用独立的 `docs: add audit bugfix implementation plan`,不计入执行提交。 +编号沿用审查的逻辑顺序。10 类问题展开为 11 个修复提交;实际 Node 18 验收新增 C12a 兼容性修复,集成审查新增 C12b frontmatter 边界修复,最后是 C12 文档提交,共 14 个执行提交。计划文档自身使用独立的 `docs: add audit bugfix implementation plan`,不计入执行提交。锁屏解除后的环境恢复记录另用一个文档提交,总计 16 个本地提交。 | 问题 | 优先级 | 明确覆盖的根因/场景 | 提交 | |---|---|---|---| @@ -498,7 +498,7 @@ Integration follow-ups: explicitly assert cross-note block reference kinds and d | C11 | complete — `c465480` | 4 real CLI→UI/Markdown RED regressions fixed; full gate 895 tests, 8 package files. Comma/space/Chinese-comma paths, clicks, metadata identity, and duplicate inbound impact directly verified. | | C12a | complete — `11eb75c` | Actual absent-crypto subprocess RED reproduced; full gate 897 tests; actual Node 18.20.8 package-audit 7 checks pass, existing crypto retained. Independent review approved. | | C12b | complete — `9827ff9` | Four RED cases fixed with shared first-closing-delimiter boundaries. Full gate and final coverage pass: 914 tests / 60 files. Independent review reproduced LF/BOM/CRLF and body horizontal-rule cases against the actual CLI and approved. | -| C12 | code/package validation complete; desktop cleanup blocked | Documentation updated; full lint/warning-lint/build/test/package gate passes (914 tests). Installed package passes seven scenario checks on Node 18.20.8 and Node 24.16.0. Desktop results and remaining boundaries below. | +| C12 | code/package validation and desktop cleanup complete | Documentation updated; full lint/warning-lint/build/test/package gate passes (914 tests). Installed package passes seven scenario checks on Node 18.20.8 and Node 24.16.0. Desktop results and remaining boundaries below. | ## 19. Additional runtime regression found during package validation @@ -536,4 +536,4 @@ A real Node 18.20.8 runtime, within the declared supported range, starts the com - The immediate verification scan after a successful source edit displayed Still present 1 once; a later manual scan correctly marked it resolved. Disk content was correct. This indicates a native metadata-refresh timing boundary and is recorded for follow-up; do not claim immediate desktop verification is always synchronized. - Native Obsidian normalizes an unresolved Markdown `.md` destination in its unresolved-link map; the original `.md` fixture was reported without an automatic fix action. An extensionless equivalent exposed the fix action and was used for source-preservation acceptance. This pre-existing conservative fallback was not expanded by this plan. - Desktop export and manual preflight-race/batch-exclusion walkthroughs are not claimed complete; their persistent integration tests pass. No PR or release has been created in this execution. -- Test environment restoration: pending manual unlock after the host automatically locked during restoration. Original assets/preferences backup is `/private/tmp/vi-audit-ui-backup`; isolated fixture directory is `99-Vault Inspector Audit 2026-09-08`. +- Test environment restoration completed after manual unlock: default Dark theme restored, original plugin preferences and saved snapshot restored byte-for-byte, isolated fixture directory removed, and plugin re-enabled. The test vault retains the verified repair build; original assets/preferences remain backed up at `/private/tmp/vi-audit-ui-backup`. Existing test note checked for absence of accidental command text. Desktop export and manual race walkthrough boundaries above remain unchanged.