Signals ({SIGNALS.length})
+
+ File names are matched case-insensitively —{" "}
+ readme.md and{" "}
+ README.MD both count as a README.
+
+
{SIGNALS.map((s) => (
- {
- const ghWf = join(repo, ".github", "workflows");
-
- if (existsSync(ghWf) && statSync(ghWf).isDirectory()) {
- const files = readdirSync(ghWf).filter((f) => /\.ya?ml$/.test(f));
-
- if (files.length > 0) {
- return {
- pass: 1,
- id: "ci",
- label: "CI configuration",
- matchedPath: ".github/workflows",
- detail: `${files.length} GitHub Actions workflow(s)`,
- };
- }
+ const ghWf = resolveRelative(repo, ".github/workflows");
+
+ if (ghWf) {
+ const abs = join(repo, ghWf);
+
+ try {
+ if (statSync(abs).isDirectory()) {
+ const files = readdirSync(abs).filter((f) => /\.ya?ml$/i.test(f));
+
+ if (files.length > 0) {
+ return {
+ pass: 1,
+ id: "ci",
+ matchedPath: ghWf,
+ label: "CI configuration",
+ detail: `${files.length} GitHub Actions workflow(s)`,
+ };
+ }
+ }
+ } catch {}
}
const m = firstExisting(repo, OTHER_CI);
diff --git a/lib/scoring/signals/cursor-rules.ts b/lib/scoring/signals/cursor-rules.ts
index da62ada..cd66b77 100644
--- a/lib/scoring/signals/cursor-rules.ts
+++ b/lib/scoring/signals/cursor-rules.ts
@@ -1,6 +1,7 @@
-import { existsSync, readdirSync, statSync } from "node:fs";
+import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
+import { resolveRelative } from "./helpers";
import type { Signal } from "./types";
const LABEL = "Cursor rules (.cursor/rules)";
@@ -12,19 +13,21 @@ export const cursorRules: Signal = {
improveSuggestion:
"Add `.cursor/rules/*.mdc` files describing how Cursor should work in this repo (architecture, conventions, naming). The legacy `.cursorrules` file is still read but is deprecated.",
check: (repo) => {
- const dir = join(repo, ".cursor", "rules");
+ const dir = resolveRelative(repo, ".cursor/rules");
+
+ if (dir) {
+ const abs = join(repo, dir);
- if (existsSync(dir)) {
try {
- if (statSync(dir).isDirectory()) {
- const mdc = readdirSync(dir).filter((f) => f.endsWith(".mdc"));
+ if (statSync(abs).isDirectory()) {
+ const mdc = readdirSync(abs).filter((f) => f.toLowerCase().endsWith(".mdc"));
if (mdc.length > 0) {
return {
pass: 1,
label: LABEL,
id: "cursor_rules",
- matchedPath: `.cursor/rules/${mdc[0]}`,
+ matchedPath: `${dir}/${mdc[0]}`,
detail: `${mdc.length} .mdc file${mdc.length === 1 ? "" : "s"} in .cursor/rules/`,
};
}
@@ -32,13 +35,13 @@ export const cursorRules: Signal = {
} catch {}
}
- const legacy = join(repo, ".cursorrules");
- if (existsSync(legacy)) {
+ const legacy = resolveRelative(repo, ".cursorrules");
+ if (legacy) {
return {
pass: 0.5,
label: LABEL,
id: "cursor_rules",
- matchedPath: ".cursorrules",
+ matchedPath: legacy,
detail: "Legacy .cursorrules — Cursor still reads it, but `.cursor/rules/*.mdc` is preferred",
};
}
diff --git a/lib/scoring/signals/deps-manifest.ts b/lib/scoring/signals/deps-manifest.ts
index a620fe3..04686d4 100644
--- a/lib/scoring/signals/deps-manifest.ts
+++ b/lib/scoring/signals/deps-manifest.ts
@@ -36,7 +36,7 @@ const CANDIDATES = [
"vcpkg.json",
];
-const GLOB_MANIFESTS: RegExp[] = [/\.(csproj|fsproj|vbproj|sln)$/, /\.cabal$/, /\.nimble$/];
+const GLOB_MANIFESTS: RegExp[] = [/\.(csproj|fsproj|vbproj|sln)$/i, /\.cabal$/i, /\.nimble$/i];
function findGlobManifest(repo: string): string | null {
try {
diff --git a/lib/scoring/signals/dev-env.ts b/lib/scoring/signals/dev-env.ts
index b772fec..75b2c87 100644
--- a/lib/scoring/signals/dev-env.ts
+++ b/lib/scoring/signals/dev-env.ts
@@ -1,12 +1,10 @@
-import { existsSync } from "node:fs";
import { join } from "node:path";
-import { readSafe } from "./helpers";
+import { readSafe, resolveAllRelative, resolveRelative } from "./helpers";
import type { Signal } from "./types";
const ARTIFACTS = [
"Makefile",
- "makefile",
".devcontainer/devcontainer.json",
".devcontainer.json",
"flake.nix",
@@ -30,7 +28,7 @@ export const devEnv: Signal = {
description: "One-command setup the agent can run (Makefile / devcontainer / Nix / Docker).",
improveSuggestion: "Add a Makefile or devcontainer or Dockerfile so the agent can set up the project in one command.",
check: (repo) => {
- const matches = ARTIFACTS.filter((c) => existsSync(join(repo, c)));
+ const matches = resolveAllRelative(repo, ARTIFACTS);
if (matches.length >= 2) {
return {
@@ -52,16 +50,16 @@ export const devEnv: Signal = {
};
}
- const pkg = join(repo, "package.json");
- if (existsSync(pkg)) {
+ const pkg = resolveRelative(repo, "package.json");
+ if (pkg) {
try {
- const j = JSON.parse(readSafe(pkg));
+ const j = JSON.parse(readSafe(join(repo, pkg)));
if (j.scripts && Object.keys(j.scripts).length >= 3) {
return {
pass: 0.6,
id: "dev_env",
- matchedPath: "package.json",
+ matchedPath: pkg,
label: "Reproducible dev env",
detail: `package.json has ${Object.keys(j.scripts).length} scripts`,
};
diff --git a/lib/scoring/signals/gemini-md.ts b/lib/scoring/signals/gemini-md.ts
index c5994a0..0e44d89 100644
--- a/lib/scoring/signals/gemini-md.ts
+++ b/lib/scoring/signals/gemini-md.ts
@@ -1,29 +1,10 @@
-import { readdirSync } from "node:fs";
import { join } from "node:path";
-import { readSafe } from "./helpers";
+import { readSafe, resolveRelative } from "./helpers";
import type { Signal } from "./types";
const LABEL = "GEMINI.md";
-function findGeminiMd(repo: string): string | null {
- let entries: string[] = [];
-
- try {
- entries = readdirSync(repo);
- } catch {
- return null;
- }
-
- for (const e of entries) {
- if (e.toLowerCase() === "gemini.md") {
- return join(repo, e);
- }
- }
-
- return null;
-}
-
export const geminiMd: Signal = {
label: LABEL,
id: "gemini_md",
@@ -31,7 +12,7 @@ export const geminiMd: Signal = {
improveSuggestion:
"Add a GEMINI.md at the repo root covering project goals, layout, setup commands, and conventions. Aim for 800+ chars of real guidance (not boilerplate).",
check: (repo) => {
- const matched = findGeminiMd(repo);
+ const matched = resolveRelative(repo, "GEMINI.md");
if (!matched) {
return {
@@ -42,7 +23,7 @@ export const geminiMd: Signal = {
};
}
- const len = readSafe(matched).trim().length;
+ const len = readSafe(join(repo, matched)).trim().length;
if (len === 0) {
return {
pass: 0.2,
diff --git a/lib/scoring/signals/helpers.ts b/lib/scoring/signals/helpers.ts
index 79d0892..c2b117c 100644
--- a/lib/scoring/signals/helpers.ts
+++ b/lib/scoring/signals/helpers.ts
@@ -1,12 +1,107 @@
-import { existsSync, readdirSync, readFileSync, type Stats, statSync } from "node:fs";
+import { readdirSync, readFileSync, type Stats, statSync } from "node:fs";
import { join } from "node:path";
+// Path lookups are case-insensitive: README / LICENSE / CONTRIBUTING casing
+// varies in the wild (`readme.md`, `Readme.md`, `README.MD`), and exact
+// matching scores those files as missing on case-sensitive filesystems.
+type DirIndex = {
+ exact: Set;
+ lower: Map;
+};
+
+type DirCache = Map;
+
+function indexDir(dir: string, cache: DirCache): DirIndex | null {
+ const cached = cache.get(dir);
+
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ let index: DirIndex | null = null;
+
+ try {
+ const entries = readdirSync(dir);
+ const lower = new Map();
+
+ // Sorted so that when both README.md and readme.md exist, the pick is
+ // stable across runs instead of following readdir order.
+ for (const e of [...entries].sort()) {
+ const key = e.toLowerCase();
+
+ if (!lower.has(key)) {
+ lower.set(key, e);
+ }
+ }
+
+ index = { lower, exact: new Set(entries) };
+ } catch {}
+
+ cache.set(dir, index);
+ return index;
+}
+
+function resolveWith(repo: string, rel: string, cache: DirCache): string | null {
+ const parts: string[] = [];
+ let current = repo;
+
+ for (const segment of rel.split("/")) {
+ if (!segment) {
+ return null;
+ }
+
+ const index = indexDir(current, cache);
+ if (!index) {
+ return null;
+ }
+
+ // Exact spelling wins so it is never shadowed by a differently-cased sibling.
+ const actual = index.exact.has(segment) ? segment : index.lower.get(segment.toLowerCase());
+ if (!actual) {
+ return null;
+ }
+
+ parts.push(actual);
+ current = join(current, actual);
+ }
+
+ return parts.join("/");
+}
+
+// Returns the path as spelled on disk, not as spelled in the candidate list —
+// callers surface it as `matchedPath`, so it has to be the real name.
+export function resolveRelative(repo: string, rel: string): string | null {
+ return resolveWith(repo, rel, new Map());
+}
+
+// Deduped by resolved path: a candidate list carrying two spellings of one
+// file (Makefile / makefile) must not count as two hits.
+export function resolveAllRelative(repo: string, candidates: string[]): string[] {
+ const cache: DirCache = new Map();
+ const hits = new Set();
+
+ for (const c of candidates) {
+ const hit = resolveWith(repo, c, cache);
+
+ if (hit) {
+ hits.add(hit);
+ }
+ }
+
+ return [...hits];
+}
+
+// Absolute, unlike the resolve* helpers above: callers feed the result straight
+// to readSafe, and scorer.ts relativises it before it is stored or rendered.
export function firstExisting(repo: string, candidates: string[]): string | null {
+ // One directory listing serves every candidate rooted in the same place.
+ const cache: DirCache = new Map();
+
for (const c of candidates) {
- const p = join(repo, c);
+ const hit = resolveWith(repo, c, cache);
- if (existsSync(p)) {
- return p;
+ if (hit) {
+ return join(repo, hit);
}
}
diff --git a/lib/scoring/signals/linter.ts b/lib/scoring/signals/linter.ts
index a08f6c4..a2ddf93 100644
--- a/lib/scoring/signals/linter.ts
+++ b/lib/scoring/signals/linter.ts
@@ -1,7 +1,6 @@
-import { existsSync } from "node:fs";
import { join } from "node:path";
-import { firstExisting, readSafe } from "./helpers";
+import { firstExisting, readSafe, resolveRelative } from "./helpers";
import type { Signal } from "./types";
const CANDIDATES = [
@@ -74,12 +73,12 @@ export const linter: Signal = {
};
}
- const pyproject = join(repo, "pyproject.toml");
- if (existsSync(pyproject) && PYPROJECT_RE.test(readSafe(pyproject))) {
+ const pyproject = resolveRelative(repo, "pyproject.toml");
+ if (pyproject && PYPROJECT_RE.test(readSafe(join(repo, pyproject)))) {
return {
pass: 1,
id: "linter",
- matchedPath: "pyproject.toml",
+ matchedPath: pyproject,
label: "Linter / formatter config",
detail: "Configured in pyproject.toml",
};
diff --git a/lib/scoring/signals/openhands-setup.ts b/lib/scoring/signals/openhands-setup.ts
index 712c418..cb4ce6e 100644
--- a/lib/scoring/signals/openhands-setup.ts
+++ b/lib/scoring/signals/openhands-setup.ts
@@ -1,7 +1,6 @@
-import { existsSync } from "node:fs";
import { join } from "node:path";
-import { readSafe } from "./helpers";
+import { readSafe, resolveRelative } from "./helpers";
import type { Signal } from "./types";
const LABEL = ".openhands/setup.sh";
@@ -14,9 +13,9 @@ export const openhandsSetup: Signal = {
improveSuggestion:
"Add a `.openhands/setup.sh` that installs dependencies and prepares the project so OpenHands can run tests and lints out of the box.",
check: (repo) => {
- const abs = join(repo, REL);
+ const rel = resolveRelative(repo, REL);
- if (!existsSync(abs)) {
+ if (!rel) {
return {
pass: 0,
label: LABEL,
@@ -25,12 +24,12 @@ export const openhandsSetup: Signal = {
};
}
- const len = readSafe(abs).trim().length;
+ const len = readSafe(join(repo, rel)).trim().length;
if (len === 0) {
return {
pass: 0.2,
label: LABEL,
- matchedPath: abs,
+ matchedPath: rel,
id: "openhands_setup",
detail: "Empty .openhands/setup.sh",
};
@@ -39,7 +38,7 @@ export const openhandsSetup: Signal = {
return {
pass: 1,
label: LABEL,
- matchedPath: abs,
+ matchedPath: rel,
id: "openhands_setup",
detail: `Setup script present (${len} chars)`,
};
diff --git a/lib/scoring/signals/tests.ts b/lib/scoring/signals/tests.ts
index 1d2ba64..e32e2a6 100644
--- a/lib/scoring/signals/tests.ts
+++ b/lib/scoring/signals/tests.ts
@@ -1,10 +1,10 @@
-import { existsSync, statSync } from "node:fs";
+import { statSync } from "node:fs";
import { join } from "node:path";
-import { walkFind } from "./helpers";
+import { resolveRelative, walkFind } from "./helpers";
import type { Signal } from "./types";
-const DIRS = ["tests", "test", "__tests__", "spec", "specs", "Tests", "src/test"];
+const DIRS = ["tests", "test", "__tests__", "spec", "specs", "src/test"];
const FILE_RE =
/(^|\/)(.*\.test\.|.*\.spec\.|test_.*\.py$|.*_test\.go$|.*_test\.rs$|.*Test\.java$|.*Tests?\.kt$|.*_test\.exs$|.*_test\.dart$|.*Spec\.scala$|.*Test\.scala$|.*Test\.php$|.*_test\.rb$|.*_spec\.rb$|.*Tests?\.cs$)/;
@@ -16,17 +16,23 @@ export const tests: Signal = {
"Add a tests/ (or test/, __tests__/, spec/) directory with runnable tests. Document how to run them in AGENTS.md.",
check: (repo) => {
for (const d of DIRS) {
- const p = join(repo, d);
+ const rel = resolveRelative(repo, d);
- if (existsSync(p) && statSync(p).isDirectory()) {
- return {
- pass: 1,
- id: "tests",
- matchedPath: d,
- label: "Test suite",
- detail: `Found /${d}`,
- };
+ if (!rel) {
+ continue;
}
+
+ try {
+ if (statSync(join(repo, rel)).isDirectory()) {
+ return {
+ pass: 1,
+ id: "tests",
+ matchedPath: rel,
+ label: "Test suite",
+ detail: `Found /${rel}`,
+ };
+ }
+ } catch {}
}
const hits = walkFind(repo, (rel) => FILE_RE.test(rel), 3, 1);
diff --git a/lib/scoring/signals/type-config.ts b/lib/scoring/signals/type-config.ts
index 176605f..2edad88 100644
--- a/lib/scoring/signals/type-config.ts
+++ b/lib/scoring/signals/type-config.ts
@@ -1,7 +1,7 @@
-import { existsSync, readdirSync } from "node:fs";
+import { readdirSync } from "node:fs";
import { join } from "node:path";
-import { firstExisting, readSafe } from "./helpers";
+import { firstExisting, readSafe, resolveRelative } from "./helpers";
import type { Signal } from "./types";
const CANDIDATES = ["tsconfig.json", "jsconfig.json", "mypy.ini", ".mypy.ini", "pyrightconfig.json"];
@@ -22,13 +22,13 @@ const TYPED_LANG_FILES: { file: string; lang: string }[] = [
];
const GLOB_TYPED: { re: RegExp; lang: string }[] = [
- { re: /\.(csproj|fsproj|vbproj|sln)$/, lang: "C#" },
- { re: /\.cabal$/, lang: "Haskell" },
+ { re: /\.(csproj|fsproj|vbproj|sln)$/i, lang: "C#" },
+ { re: /\.cabal$/i, lang: "Haskell" },
];
function detectTypedLang(repo: string): string | null {
for (const { file, lang } of TYPED_LANG_FILES) {
- if (existsSync(join(repo, file))) {
+ if (resolveRelative(repo, file)) {
return lang;
}
}
@@ -64,13 +64,13 @@ export const typeConfig: Signal = {
};
}
- const pyproject = join(repo, "pyproject.toml");
- if (existsSync(pyproject) && PYPROJECT_RE.test(readSafe(pyproject))) {
+ const pyproject = resolveRelative(repo, "pyproject.toml");
+ if (pyproject && PYPROJECT_RE.test(readSafe(join(repo, pyproject)))) {
return {
pass: 1,
id: "type_config",
label: "Type configuration",
- matchedPath: "pyproject.toml",
+ matchedPath: pyproject,
detail: "Configured in pyproject.toml",
};
}
diff --git a/tests/path-resolution.test.ts b/tests/path-resolution.test.ts
new file mode 100644
index 0000000..4850839
--- /dev/null
+++ b/tests/path-resolution.test.ts
@@ -0,0 +1,71 @@
+import { strict as assert } from "node:assert";
+import { afterEach, describe, test } from "node:test";
+
+import { firstExisting, resolveAllRelative, resolveRelative } from "../lib/scoring/signals/helpers";
+import { makeFixture, removeFixture } from "./_helpers";
+
+describe("case-insensitive path resolution", () => {
+ let fixture = "";
+
+ afterEach(() => {
+ if (fixture) {
+ removeFixture(fixture);
+ fixture = "";
+ }
+ });
+
+ test("resolves a differently-cased file", () => {
+ fixture = makeFixture({ "Readme.md": "x" });
+ assert.equal(resolveRelative(fixture, "README.md"), "Readme.md");
+ });
+
+ // Asserting the on-disk spelling (not the candidate spelling) is what makes
+ // these fail on a case-insensitive filesystem too, where a plain existence
+ // check would happily pass.
+ test("returns the on-disk spelling, not the candidate spelling", () => {
+ fixture = makeFixture({ "license.md": "MIT" });
+ assert.equal(firstExisting(fixture, ["LICENSE", "LICENSE.md"]), `${fixture}/license.md`);
+ });
+
+ test("prefers an exact match over a differently-cased sibling", () => {
+ fixture = makeFixture({ "README.md": "exact", "readme.MD": "other" });
+ assert.equal(resolveRelative(fixture, "README.md"), "README.md");
+ });
+
+ test("resolves every segment of a nested path", () => {
+ fixture = makeFixture({ "Docs/Contributing.md": "x" });
+ assert.equal(resolveRelative(fixture, "docs/CONTRIBUTING.md"), "Docs/Contributing.md");
+ });
+
+ test("returns null when nothing matches", () => {
+ fixture = makeFixture({ "README.md": "x" });
+ assert.equal(resolveRelative(fixture, "LICENSE"), null);
+ assert.equal(firstExisting(fixture, ["LICENSE", "COPYING"]), null);
+ });
+
+ test("returns null when an intermediate segment is a file", () => {
+ fixture = makeFixture({ docs: "not a directory" });
+ assert.equal(resolveRelative(fixture, "docs/CONTRIBUTING.md"), null);
+ });
+
+ test("resolveAllRelative collapses candidate spellings of one file", () => {
+ fixture = makeFixture({ Makefile: "all:" });
+ assert.deepEqual(resolveAllRelative(fixture, ["Makefile", "makefile"]), ["Makefile"]);
+ });
+
+ test("resolveAllRelative keeps genuinely distinct hits", () => {
+ fixture = makeFixture({ Dockerfile: "FROM node:20", "compose.yaml": "services: {}" });
+ assert.equal(resolveAllRelative(fixture, ["Dockerfile", "compose.yaml"]).length, 2);
+ });
+
+ // readdir never yields "." or "..", so resolution cannot climb out of the
+ // repo — the scorer reads arbitrary cloned trees and must stay inside them.
+ test("cannot escape the repo root", () => {
+ fixture = makeFixture({ "sub/README.md": "x" });
+
+ assert.equal(resolveRelative(fixture, "../README.md"), null);
+ assert.equal(resolveRelative(fixture, "sub/../../README.md"), null);
+ assert.equal(resolveRelative(fixture, "/etc/hosts"), null);
+ assert.equal(firstExisting(fixture, ["../README.md", "./README.md"]), null);
+ });
+});
diff --git a/tests/signals/contributing.test.ts b/tests/signals/contributing.test.ts
index 9727927..994211b 100644
--- a/tests/signals/contributing.test.ts
+++ b/tests/signals/contributing.test.ts
@@ -44,4 +44,9 @@ describe("contributing signal", () => {
fixture = makeFixture({ "CONTRIBUTING.adoc": "= Contributing" });
assert.equal(contributing.check(fixture).pass, 1);
});
+
+ test("matches a lowercased contributing file", () => {
+ fixture = makeFixture({ "contributing.md": "x".repeat(400) });
+ assert.equal(contributing.check(fixture).pass, 1);
+ });
});
diff --git a/tests/signals/dev-env.test.ts b/tests/signals/dev-env.test.ts
index dc69865..037c5cd 100644
--- a/tests/signals/dev-env.test.ts
+++ b/tests/signals/dev-env.test.ts
@@ -74,4 +74,9 @@ describe("devEnv signal", () => {
fixture = makeFixture({ "compose.yaml": "services: {}" });
assert.equal(devEnv.check(fixture).pass, 0.7);
});
+
+ test("pass=0.7 for a lone Makefile — one file is never two hits", () => {
+ fixture = makeFixture({ Makefile: "test:\n\tpytest" });
+ assert.equal(devEnv.check(fixture).pass, 0.7);
+ });
});
diff --git a/tests/signals/license.test.ts b/tests/signals/license.test.ts
index 763b9b8..cb3bfef 100644
--- a/tests/signals/license.test.ts
+++ b/tests/signals/license.test.ts
@@ -28,4 +28,12 @@ describe("license signal", () => {
fixture = makeFixture({ COPYING: "GPL..." });
assert.equal(license.check(fixture).pass, 1);
});
+
+ test("matches a lowercased license file", () => {
+ fixture = makeFixture({ "license.md": "MIT" });
+ const r = license.check(fixture);
+
+ assert.equal(r.pass, 1);
+ assert.match(r.matchedPath ?? "", /\/license\.md$/);
+ });
});
diff --git a/tests/signals/readme.test.ts b/tests/signals/readme.test.ts
index cd35e01..bc382fc 100644
--- a/tests/signals/readme.test.ts
+++ b/tests/signals/readme.test.ts
@@ -41,4 +41,12 @@ describe("readme signal", () => {
fixture = makeFixture({ "README.rst": "x".repeat(400) });
assert.equal(readme.check(fixture).pass, 0.7);
});
+
+ test("matches a differently-cased README and reports its real name", () => {
+ fixture = makeFixture({ "Readme.md": "x".repeat(1500) });
+ const r = readme.check(fixture);
+
+ assert.equal(r.pass, 1);
+ assert.match(r.matchedPath ?? "", /\/Readme\.md$/);
+ });
});