From 55af8bfec17133d7c53d8810760d2d8a1aa29c6a Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Sun, 23 Aug 2026 11:14:37 +0530 Subject: [PATCH] Match repo file lookups case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README / LICENSE / CONTRIBUTING casing varies in the wild (readme.md, Readme.md, README.MD). Exact-match lookups scored those files as missing on case-sensitive filesystems, so Linux CI and a macOS dev box disagreed on the same commit — and the deployed numbers were the wrong ones. vercel/next.js, expressjs/express, nestjs/nest and five other tracked repos were recorded as having no README at all; next.js was under-scored by 18.5 points. Signal path lookups now go through case-folding resolvers in helpers.ts. resolveAllRelative dedupes by resolved path so a candidate list carrying two spellings of one file (Makefile / makefile) cannot count twice — on a case-insensitive filesystem that had been inflating dev_env to 1.0. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 4 +- app/methodology/page.tsx | 8 +- lib/scoring/signals/ci.ts | 38 +++++---- lib/scoring/signals/cursor-rules.ts | 21 ++--- lib/scoring/signals/deps-manifest.ts | 2 +- lib/scoring/signals/dev-env.ts | 14 ++-- lib/scoring/signals/gemini-md.ts | 25 +----- lib/scoring/signals/helpers.ts | 103 ++++++++++++++++++++++++- lib/scoring/signals/linter.ts | 9 +-- lib/scoring/signals/openhands-setup.ts | 13 ++-- lib/scoring/signals/tests.ts | 30 ++++--- lib/scoring/signals/type-config.ts | 16 ++-- tests/path-resolution.test.ts | 71 +++++++++++++++++ tests/signals/contributing.test.ts | 5 ++ tests/signals/dev-env.test.ts | 5 ++ tests/signals/license.test.ts | 8 ++ tests/signals/readme.test.ts | 8 ++ 17 files changed, 286 insertions(+), 94 deletions(-) create mode 100644 tests/path-resolution.test.ts diff --git a/AGENTS.md b/AGENTS.md index dcd6b27..381d9d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,7 @@ tests/ parse-repo-url.test.ts # GH / GL / BB parsing + edge cases scorer.test.ts # scoreRepo, topImprovements badge-adoption.test.ts # detectBadgeEmbed — README badge-embed detection + path-resolution.test.ts # firstExisting / resolveRelative / resolveAllRelative — case-insensitive lookup signals/ # one *.test.ts per signal tasks/ README.md @@ -142,6 +143,7 @@ Keep it that way when adding features. If a component needs data, fetch in the p - **Server components** unless interactivity requires client. Prefer `` + query params over client state. - **All SQL** lives in `lib/db.ts`. Don't scatter `db.prepare(...)` elsewhere. - **Signal IDs** are stable strings (`agents_md`, `tests`, etc.). Changing one = migration. +- **Repo path lookups** in `lib/scoring/signals/` go through `firstExisting` / `resolveRelative` / `resolveAllRelative` in `helpers.ts` — never a raw `existsSync(join(repo, …))`. They match case-insensitively because README / LICENSE / CONTRIBUTING casing varies in the wild (`readme.md`, `Readme.md`, `README.MD`); an exact-match lookup scores those files as missing on case-sensitive filesystems, so Linux CI and a macOS dev box disagree on the same commit. `resolveAllRelative` dedupes by resolved path — a candidate list carrying two spellings of one file must not count as two hits. - **Tailwind first**, then `@theme` tokens. Avoid inline styles; avoid custom classes unless the pattern is truly repeatable. - **No comments** explaining _what_ the code does. Only comment _why_ — the shallow-clone rationale in `lib/clients/git.ts` is the model. - **Brand on UI**: "Agent Friendly Code" (no hyphen). Repo/package slug + GitHub `User-Agent` string: `agent-friendly-code`. @@ -170,7 +172,7 @@ If either sibling isn't present locally, flag it; never silently skip the propag ## Adding a signal -1. New file at `lib/scoring/signals/.ts` implementing `Signal` (including `improveSuggestion`). +1. New file at `lib/scoring/signals/.ts` implementing `Signal` (including `improveSuggestion`). Use the `helpers.ts` resolvers for every path lookup (see Conventions). 2. Import and add to the `SIGNALS` array in `lib/scoring/signals/index.ts`. 3. Add a weight entry to **every** model in `lib/scoring/weights.ts` — missing weights default to 0, decide deliberately. 4. Re-score: `bun run seed` is idempotent. diff --git a/app/methodology/page.tsx b/app/methodology/page.tsx index 197fa3c..82664b2 100644 --- a/app/methodology/page.tsx +++ b/app/methodology/page.tsx @@ -20,7 +20,7 @@ export const metadata: Metadata = { const FAQ = [ { q: "How is the agent-friendliness score computed?", - a: "Each repository is shallow-cloned and evaluated against sixteen static signals — twelve cross-agent (AGENTS.md / CLAUDE.md, CI, tests, README, linter, type config, license, contributing guide, reproducible dev environment, pre-commit hooks, dependency manifest, codebase size) plus four agent-specific instruction files (`.cursor/rules/*.mdc`, `GEMINI.md`, `.openhands/setup.sh`, `.aider.conf.yml`). Per-model score = Σ(signal.pass × model.weight[signal]) / Σ(model.weight) × 100. Overall score = mean of per-model scores.", + a: "Each repository is shallow-cloned and evaluated against sixteen static signals — twelve cross-agent (AGENTS.md / CLAUDE.md, CI, tests, README, linter, type config, license, contributing guide, reproducible dev environment, pre-commit hooks, dependency manifest, codebase size) plus four agent-specific instruction files (`.cursor/rules/*.mdc`, `GEMINI.md`, `.openhands/setup.sh`, `.aider.conf.yml`). File names are matched case-insensitively, so `readme.md` and `README.MD` count exactly like `README.md`. Per-model score = Σ(signal.pass × model.weight[signal]) / Σ(model.weight) × 100. Overall score = mean of per-model scores.", }, { q: "Why score per model instead of giving one overall number?", @@ -133,6 +133,12 @@ improvement = closing a gap unlocks (1 - pass) × weight / Σweight × 100 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$/); + }); });