diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8379caed6..75cdbd8c7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `omp ttsr test ` silently evaluating a supplied source file against the text (prose) context when its extension was absent from the hardcoded `SOURCE_FILE_EXT` allowlist, producing a false negative indistinguishable from a non-matching regex. It now emits an explanatory note (in text and `--json` output) when a resolvable file path infers `text`, pointing at `--source tool --tool edit`, and the allowlist gained the .NET family and other common source languages (`cs`, `razor`, `cshtml`, `fs`, `fsx`, `vb`, `sh`, `bash`, `sql`, `zig`, `dart`, `scala`, `ex`, `exs`, `proto`, `tf`) ([#6887](https://github.com/can1357/oh-my-pi/issues/6887)). + ## [17.1.7] - 2026-07-27 ### Fixed diff --git a/packages/coding-agent/src/cli/ttsr-cli.ts b/packages/coding-agent/src/cli/ttsr-cli.ts index c20629bf7..2f5f1176f 100644 --- a/packages/coding-agent/src/cli/ttsr-cli.ts +++ b/packages/coding-agent/src/cli/ttsr-cli.ts @@ -94,12 +94,18 @@ interface TestReport { evaluated: number; triggered: RuleMatchDetail[]; notTriggered: RuleMatchDetail[]; + /** + * Set when a file path was supplied but the match source was inferred as + * `text` (extension absent from {@link SOURCE_FILE_EXT}), so callers can + * surface why a source file was evaluated against a prose context. + */ + inferenceNote?: string; } const STDIN_MARKER = "-"; /** Extensions treated as source files for default tool-context inference. */ const SOURCE_FILE_EXT = - /^\.(ts|tsx|js|jsx|mjs|cjs|rs|py|go|java|kt|swift|c|cc|cpp|h|hpp|rb|php|lua|css|scss|html|json|ya?ml|toml|md|mdc)$/i; + /^\.(ts|tsx|js|jsx|mjs|cjs|rs|py|go|java|kt|swift|c|cc|cpp|h|hpp|rb|php|lua|css|scss|html|json|ya?ml|toml|md|mdc|cs|razor|cshtml|fs|fsx|vb|sh|bash|sql|zig|dart|scala|ex|exs|proto|tf)$/i; const BINARY_PROBE_BYTES = 8192; const DEFAULT_MAX_SCAN_BYTES = 5 * 1024 * 1024; @@ -342,6 +348,14 @@ async function runTest(args: TtsrTestArgs, json: boolean, cwd: string): Promise< args.source ?? (filePath && SOURCE_FILE_EXT.test(path.extname(filePath)) ? "tool" : "text"); const tool = args.tool ?? (source === "tool" ? "edit" : undefined); + // A supplied source file whose extension is unknown falls through to the + // text (prose) context, where tool-scoped rules can never match. Surface + // that so a false negative reads as a context mismatch, not a bad regex. + const inferenceNote = + !args.source && filePath && source === "text" + ? `inferred --source text from '${path.extname(filePath) || filePath}' (not in the source-file extension set); pass --source tool --tool edit to evaluate tool-scoped rules` + : undefined; + const context: TtsrMatchContext = { source, toolName: tool, @@ -373,6 +387,7 @@ async function runTest(args: TtsrTestArgs, json: boolean, cwd: string): Promise< evaluated: rules.length, triggered, notTriggered, + inferenceNote, }; if (json) { @@ -390,6 +405,9 @@ function renderTestReport(report: TestReport, verbose: boolean, isolated: boolea `${chalk.bold("TTSR test")} — source=${chalk.cyan(ctxLabel)}${pathLabel} snippet=${chalk.dim(`${report.snippetBytes}b`)}\n`, ); process.stdout.write(`${chalk.dim(` "${report.snippetPreview}"`)}\n\n`); + if (report.inferenceNote) { + process.stdout.write(`${chalk.yellow(`note: ${report.inferenceNote}`)}\n\n`); + } if (report.triggered.length === 0) { process.stdout.write(`${chalk.red("No rules triggered.")} (evaluated ${report.evaluated})\n`); diff --git a/packages/coding-agent/test/cli/ttsr-cli.test.ts b/packages/coding-agent/test/cli/ttsr-cli.test.ts index 680ab8d72..0d34452fd 100644 --- a/packages/coding-agent/test/cli/ttsr-cli.test.ts +++ b/packages/coding-agent/test/cli/ttsr-cli.test.ts @@ -183,6 +183,41 @@ describe("omp ttsr", () => { expect(stdout).toContain("Triggered"); expect(stdout).toContain("astCondition"); }); + + it("infers tool/edit context for a newly-allowlisted .cs file", async () => { + captureStreams(); + const rulePath = await writeTempRule("class", ["tool:edit(*.cs)"]); + const snippetPath = await writeTempSnippet("class A {}", "cs"); + await run({ action: "test", test: { rule: rulePath, file: snippetPath, source: undefined }, json: true }); + const report = JSON.parse(stdout); + expect(report.source).toBe("tool"); + expect(report.tool).toBe("edit"); + expect(report.triggered).toHaveLength(1); + // A recognized source file needs no context-mismatch note. + expect(report.inferenceNote).toBeUndefined(); + }); + + it("emits an inference note when a supplied file path falls through to text source", async () => { + captureStreams(); + const rulePath = await writeTempRule("class", ["tool:edit(*.cs)"]); + const snippetPath = await writeTempSnippet("class A {}", "unknownext"); + await run({ action: "test", test: { rule: rulePath, file: snippetPath, source: undefined }, json: true }); + const report = JSON.parse(stdout); + expect(report.source).toBe("text"); + expect(report.inferenceNote).toContain(".unknownext"); + expect(report.inferenceNote).toContain("--source tool --tool edit"); + // The tool-scoped rule is a false negative here — the note explains why. + expect(report.triggered).toHaveLength(0); + }); + + it("omits the inference note when --source is explicit", async () => { + captureStreams(); + const rulePath = await writeTempRule("class", ["tool:edit(*.cs)"]); + const snippetPath = await writeTempSnippet("class A {}", "unknownext"); + await run({ action: "test", test: { rule: rulePath, file: snippetPath, source: "text" }, json: true }); + const report = JSON.parse(stdout); + expect(report.inferenceNote).toBeUndefined(); + }); }); describe("list", () => {