From 063f6d3d1f2f5e9b3d1376f0aa9d38481549fe2c Mon Sep 17 00:00:00 2001 From: chumanic Date: Wed, 11 Nov 2026 22:26:59 +0800 Subject: [PATCH] Use original document positions in CLI findings and diffs --- browser/main.ts | 6 ++---- src/cli/commands/diff.ts | 4 ++-- src/domain/frontmatter.ts | 23 ++++++++++++++++++----- src/scanners/structure.ts | 4 ++-- tests/domain/frontmatter.test.ts | 6 +++++- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/browser/main.ts b/browser/main.ts index fe23001..a12286d 100644 --- a/browser/main.ts +++ b/browser/main.ts @@ -3,7 +3,7 @@ const input = document.querySelector("#source")!; const status = document.querySelector("#status")!; const message = document.querySelector("#message")!; const locate = document.querySelector("#locate")!; -let errorLocation: {start: number; end: number} | undefined; +let errorLocation: {start: number; end: number; line: number; column: number} | undefined; function resetLocation() { errorLocation = undefined; locate.hidden = true; @@ -66,9 +66,7 @@ function check() { status.textContent = "无法解析文档"; errorLocation = parsed.location; if (errorLocation) { - const lines = input.value.slice(0, errorLocation.start).split("\n"); - const column = Array.from(lines[lines.length-1] ?? "").length + 1; - message.textContent = "第 " + lines.length + " 行,第 " + column + " 列。" + parsed.error; + message.textContent = "第 " + errorLocation.line + " 行,第 " + errorLocation.column + " 列。" + parsed.error; locate.hidden = false; } else { message.textContent = parsed.error; diff --git a/src/cli/commands/diff.ts b/src/cli/commands/diff.ts index b0e262c..e74c3fd 100644 --- a/src/cli/commands/diff.ts +++ b/src/cli/commands/diff.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; -import { parseSkillDocument } from "../../domain/frontmatter.js"; +import { describeDocumentError, parseSkillDocument } from "../../domain/frontmatter.js"; import { normalizeRelativePath } from "../../domain/digest.js"; import { scanInventory, type ScanTarget } from "../../domain/inventory.js"; import { @@ -60,7 +60,7 @@ function hydrateFrontmatter(skill: Skill, label: "before" | "after"): Skill { const parsed = parseSkillDocument(skillMd.content.toString("utf8")); if (!parsed.ok) { - throw new Error(`Cannot diff ${label} Skill ${skill.name}: ${parsed.error}`); + throw new Error(`Cannot diff ${label} Skill ${skill.name}: ${describeDocumentError(parsed)}`); } return { ...skill, frontmatter: parsed.value.frontmatter }; diff --git a/src/domain/frontmatter.ts b/src/domain/frontmatter.ts index ded11f3..6621ce9 100644 --- a/src/domain/frontmatter.ts +++ b/src/domain/frontmatter.ts @@ -6,9 +6,22 @@ export type ParsedSkillDocument = { sourceRange: { start: number; end: number }; }; +type DocumentLocation = { start: number; end: number; line: number; column: number }; +type DocumentFailure = { ok: false; error: string; location?: DocumentLocation }; + +function documentLocation(text: string, start: number, end = start): DocumentLocation { + const lines = text.slice(0, start).split("\n"); + return {start, end, line: lines.length, column: Array.from(lines[lines.length - 1] ?? "").length + 1}; +} + +export function describeDocumentError(failure: DocumentFailure): string { + const at = failure.location; + return at ? failure.error + " (line " + at.line + ", column " + at.column + ")" : failure.error; +} + export type ParseSkillDocumentResult = | { ok: true; value: ParsedSkillDocument } - | { ok: false; error: string; location?: { start: number; end: number } }; + | DocumentFailure; function parserError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); @@ -20,7 +33,7 @@ export function parseSkillDocument(content: string): ParseSkillDocumentResult { if (!opening) { return { ok: false, - location: { start: 0, end: 0 }, + location: documentLocation(content, 0), error: "Skill document must start with a YAML frontmatter delimiter.", }; } @@ -30,7 +43,7 @@ export function parseSkillDocument(content: string): ParseSkillDocumentResult { if (!closing) { return { ok: false, - location: { start: content.length, end: content.length }, + location: documentLocation(content, content.length), error: "Skill frontmatter closing delimiter is missing.", }; } @@ -44,7 +57,7 @@ export function parseSkillDocument(content: string): ParseSkillDocumentResult { parsed = parse(frontmatterText, { uniqueKeys: true, prettyErrors: false }); } catch (error: unknown) { const location = error instanceof YAMLParseError - ? { start: Math.min(yamlEnd, openingEnd + error.pos[0]), end: Math.min(yamlEnd, openingEnd + error.pos[1]) } + ? documentLocation(content, Math.min(yamlEnd, openingEnd + error.pos[0]), Math.min(yamlEnd, openingEnd + error.pos[1])) : undefined; return { ...(location && { location }), @@ -61,7 +74,7 @@ export function parseSkillDocument(content: string): ParseSkillDocumentResult { return { ok: false, error: "Skill frontmatter must be a YAML mapping.", - location: { start: openingEnd, end: yamlEnd }, + location: documentLocation(content, openingEnd, yamlEnd), }; } diff --git a/src/scanners/structure.ts b/src/scanners/structure.ts index f7a825d..1190c58 100644 --- a/src/scanners/structure.ts +++ b/src/scanners/structure.ts @@ -1,6 +1,6 @@ import { basename } from "node:path"; -import { parseSkillDocument } from "../domain/frontmatter.js"; +import { describeDocumentError, parseSkillDocument } from "../domain/frontmatter.js"; import { normalizeRelativePath } from "../domain/digest.js"; import type { Finding } from "../domain/result.js"; import type { Skill, SkillFile } from "../domain/skill.js"; @@ -160,7 +160,7 @@ export function inspectStructure(skill: Skill): Finding[] { severity: "error", status: "fail", code: "structure.invalid-frontmatter", - message: parsed.error, + message: describeDocumentError(parsed), evidence: [{ path: "SKILL.md" }], remediation: "Add valid YAML frontmatter enclosed by --- delimiters.", }), diff --git a/tests/domain/frontmatter.test.ts b/tests/domain/frontmatter.test.ts index 7fdfac8..2139f60 100644 --- a/tests/domain/frontmatter.test.ts +++ b/tests/domain/frontmatter.test.ts @@ -27,7 +27,11 @@ describe("parseSkillDocument", () => { const text = "---\r\nname: 鱼🐟\r\nname: b\r\n---\r\nbody"; const result = parseSkillDocument(text); expect(result.ok).toBe(false); - if (!result.ok) expect(result.location?.start).toBe(text.lastIndexOf("name:")); + if (!result.ok) { + expect(result.location?.start).toBe(text.lastIndexOf("name:")); + expect(result.location?.line).toBe(3); + expect(result.location?.column).toBe(1); + } }); it("rejects a document without an opening or closing delimiter", () => {