mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
fix(annotate): support @ markdown file references (#488)
OpenCode can pass annotate targets as @file references, so resolve markdown paths with an @ fallback only after the primary lookup misses. This preserves real @-prefixed filenames while making @README.md and quoted @ inputs resolve reliably.
This commit is contained in:
@@ -106,6 +106,33 @@ describe("resolveMarkdownFile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves @ filename via fallback when @ file does not exist", async () => {
|
||||
const root = createTempProject({ "README.md": "# Hello" });
|
||||
const result = resolveMarkdownFile("@README.md", root);
|
||||
expect(result).toEqual({
|
||||
kind: "found",
|
||||
path: resolve(root, "README.md"),
|
||||
});
|
||||
});
|
||||
|
||||
test("prioritizes real @ filename before fallback", async () => {
|
||||
const root = createTempProject({ "@README.md": "# At" });
|
||||
const result = resolveMarkdownFile("@README.md", root);
|
||||
expect(result).toEqual({
|
||||
kind: "found",
|
||||
path: resolve(root, "@README.md"),
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves quoted @ filename", async () => {
|
||||
const root = createTempProject({ "README.md": "# Hello" });
|
||||
const result = resolveMarkdownFile('"@README.md"', root);
|
||||
expect(result).toEqual({
|
||||
kind: "found",
|
||||
path: resolve(root, "README.md"),
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves relative paths with Windows separators", async () => {
|
||||
const root = createTempProject({ "docs/test-plan.md": "# Test plan\n" });
|
||||
const result = resolveMarkdownFile("docs\\test-plan.md", root);
|
||||
@@ -147,6 +174,19 @@ describe("resolveMarkdownFile", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("returns ambiguous in @ fallback when target exists multiple times", async () => {
|
||||
const root = createTempProject({
|
||||
"docs/plan.md": "# Plan 1",
|
||||
"api/plan.md": "# Plan 2",
|
||||
});
|
||||
const result = resolveMarkdownFile("@plan.md", root);
|
||||
expect(result.kind).toBe("ambiguous");
|
||||
if (result.kind === "ambiguous") {
|
||||
expect(result.input).toBe("@plan.md");
|
||||
expect(result.matches).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
// Ignored directories
|
||||
|
||||
test("skips node_modules", async () => {
|
||||
@@ -190,6 +230,12 @@ describe("resolveMarkdownFile", () => {
|
||||
expect(result.kind).toBe("not_found");
|
||||
});
|
||||
|
||||
test("returns not_found for @ path that cannot be resolved", async () => {
|
||||
const root = createTempProject();
|
||||
const result = resolveMarkdownFile("@nope.md", root);
|
||||
expect(result).toEqual({ kind: "not_found", input: "@nope.md" });
|
||||
});
|
||||
|
||||
test("handles deeply nested files", async () => {
|
||||
const root = createTempProject({
|
||||
"a/b/c/d/deep.md": "# Deep",
|
||||
|
||||
@@ -78,6 +78,20 @@ function isSearchableMarkdownPath(input: string): boolean {
|
||||
return MARKDOWN_PATH_REGEX.test(input.trim());
|
||||
}
|
||||
|
||||
function stripWrappingQuotes(input: string): string {
|
||||
if (input.length < 2) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const first = input[0];
|
||||
const last = input[input.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return input.slice(1, -1);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
/** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */
|
||||
function hasWindowsDriveLetter(input: string): boolean {
|
||||
return /^[a-zA-Z]:[/\\]/.test(input);
|
||||
@@ -155,12 +169,10 @@ export function isAbsoluteMarkdownPath(
|
||||
* @param input - User-provided path (absolute, relative, or bare filename)
|
||||
* @param projectRoot - Project root directory to search within
|
||||
*/
|
||||
export function resolveMarkdownFile(
|
||||
function resolveMarkdownFileCore(
|
||||
input: string,
|
||||
projectRoot: string,
|
||||
): ResolveResult {
|
||||
// Trim whitespace/CR that may leak from Windows shell pipelines
|
||||
input = input.trim();
|
||||
const normalizedInput = normalizeMarkdownPathInput(input);
|
||||
const searchInput = normalizeSeparators(normalizedInput);
|
||||
const isBareFilename = !searchInput.includes("/");
|
||||
@@ -218,6 +230,47 @@ export function resolveMarkdownFile(
|
||||
return { kind: "not_found", input };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a markdown file path within a project root.
|
||||
*
|
||||
* @param input - User-provided path (absolute, relative, or bare filename)
|
||||
* @param projectRoot - Project root directory to search within
|
||||
*/
|
||||
export function resolveMarkdownFile(
|
||||
input: string,
|
||||
projectRoot: string,
|
||||
): ResolveResult {
|
||||
const originalInput = input.trim();
|
||||
const unquotedInput = stripWrappingQuotes(originalInput);
|
||||
|
||||
const primary = resolveMarkdownFileCore(unquotedInput, projectRoot);
|
||||
if (primary.kind === "found") {
|
||||
return primary;
|
||||
}
|
||||
if (primary.kind === "ambiguous") {
|
||||
return { ...primary, input: originalInput };
|
||||
}
|
||||
|
||||
if (!unquotedInput.startsWith("@")) {
|
||||
return { kind: "not_found", input: originalInput };
|
||||
}
|
||||
|
||||
const normalizedInput = unquotedInput.replace(/^@+/, "");
|
||||
if (!normalizedInput) {
|
||||
return { kind: "not_found", input: originalInput };
|
||||
}
|
||||
|
||||
const fallback = resolveMarkdownFileCore(normalizedInput, projectRoot);
|
||||
if (fallback.kind === "found") {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback.kind === "ambiguous") {
|
||||
return { ...fallback, input: originalInput };
|
||||
}
|
||||
|
||||
return { kind: "not_found", input: originalInput };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory contains at least one markdown file.
|
||||
* Used to validate folder annotation targets.
|
||||
|
||||
Reference in New Issue
Block a user