Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 33 additions & 19 deletions action/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28960,6 +28960,7 @@ var DEFAULT_SCHEMA_GLOBS = [
var MAX_SCHEMA_FACTS = 3;
var MAX_INSTRUCTION_FACTS = 3;
var MAX_ENV_CHECKS = 10;
var GENERATED_GLOBS = ["**/*.lock.yml"];
var ENV_PATTERNS = [
/\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)/,
/\bprocess\.env\[["']([A-Za-z_][A-Za-z0-9_]*)["']\]/,
Expand Down Expand Up @@ -29054,15 +29055,15 @@ var detectContracts = async (ctx) => {
if (deletedDecls.length > 0) {
const dependents = ctx.graph?.dependentsOf(basePath2).length ?? 0;
if (dependents > 0) {
for (const d of deletedDecls) {
facts.push({
id: "contract.api",
weight: "strong",
file: basePath2,
line: d.line,
text: `Removes or changes exported ${code(d.name)} (${code(locate(basePath2, d.line))}), imported by ${plural2(dependents, "file")}.`
});
}
const shown = deletedDecls.slice(0, 3).map((d) => code(d.name));
const names = shown.join(", ") + (deletedDecls.length > 3 ? ", \u2026" : "");
facts.push({
id: "contract.api",
weight: "strong",
file: basePath2,
line: deletedDecls[0].line,
text: `Removes or changes ${plural2(deletedDecls.length, "exported declaration")} in ${code(basePath2)} (${names}), imported by ${plural2(dependents, "file")}.`
});
}
} else if (addedDecls.length > 0 && f.status !== "added") {
const dependents = ctx.graph?.dependentsOf(basePath2).length ?? 0;
Expand Down Expand Up @@ -29094,7 +29095,7 @@ var detectContracts = async (ctx) => {
const seen = /* @__PURE__ */ new Set();
for (const f of files) {
if (f.binary) continue;
if (isTestPath(ctx.config, f.path) || languageFamily(f.path) === "docs") continue;
if (isTestPath(ctx.config, f.path) || languageFamily(f.path) === "docs" || matchAny(GENERATED_GLOBS, f.path)) continue;
for (const h of f.hunks) {
for (const { kind, line: line2, text } of changedLines(h)) {
if (kind !== "+") continue;
Expand All @@ -29107,9 +29108,22 @@ var detectContracts = async (ctx) => {
}
}
const toCheck = envCandidates.slice(0, MAX_ENV_CHECKS);
const newByFile = /* @__PURE__ */ new Map();
for (const c of toCheck) {
if (await existsAtBase(ctx, c.name)) continue;
facts.push({ id: "contract.env", weight: "strong", file: c.file, line: c.line, text: `Reads a new environment variable ${code(c.name)} (${code(locate(c.file, c.line))}).` });
newByFile.set(c.file, [...newByFile.get(c.file) ?? [], { name: c.name, line: c.line }]);
}
for (const [file2, vars] of newByFile) {
const first = vars[0];
const shown = vars.slice(0, 3).map((v) => code(v.name));
const names = shown.join(", ") + (vars.length > 3 ? ", \u2026" : "");
facts.push({
id: "contract.env",
weight: "strong",
file: file2,
line: first.line,
text: `Reads ${plural2(vars.length, "new environment variable")} (${names}) (${code(locate(file2, first.line))}).`
});
}
const skipped = envCandidates.length - toCheck.length;
if (skipped > 0) notes.push(`contract.env: ${plural2(skipped, "more new environment variable")} not checked`);
Expand Down Expand Up @@ -29155,6 +29169,10 @@ function isHigherConfidence(a, b) {
function excluded(ctx, path) {
return matchAny(ctx.config.paths.ignore, path) || languageFamily(path) === "docs";
}
var CI_WORKFLOW_GLOBS = ["**/.github/workflows/**"];
function isNoisyChangedSide(ctx, path) {
return matchAny(CI_WORKFLOW_GLOBS, path) || isManifestPath(path) || matchAny(ctx.config.paths.tests, path);
}
var detectCoupling = async (ctx) => {
const cfg = ctx.config.captain.coupling;
if (!cfg.enabled) return { facts: [], notes: [] };
Expand All @@ -29174,12 +29192,9 @@ var detectCoupling = async (ctx) => {
if (await ctx.readHead(pair.partner) === void 0) continue;
const firing = ctx.diff.files.find((f) => f.path === pair.file);
if (firing?.status === "deleted") continue;
let weight = "strong";
if (isNoisyChangedSide(ctx, pair.file)) continue;
let suffix = "";
if (matchAny(ctx.config.paths.tests, pair.file) && !matchAny(ctx.config.paths.tests, pair.partner)) {
weight = "info";
suffix = " Downgraded: a changed test without its source partner is a test-only change.";
} else if (firing && firing.hunks.length > 0 && firing.hunks.every((h) => [...changedLines(h)].some((l) => l.kind === "-"))) {
if (firing && firing.hunks.length > 0 && firing.hunks.every((h) => [...changedLines(h)].some((l) => l.kind === "-"))) {
const identifiers = /* @__PURE__ */ new Set();
for (const h of firing.hunks) {
for (const l of changedLines(h)) {
Expand All @@ -29190,13 +29205,12 @@ var detectCoupling = async (ctx) => {
const partnerText = await ctx.readBase(pair.partner) ?? "";
const survives = [...identifiers].some((id) => partnerText.includes(id));
if (!survives) {
weight = "info";
suffix = ` Downgraded: the lines changed in ${code(pair.file)} mention nothing ${code(pair.partner)} contains.`;
suffix = ` The lines changed in ${code(pair.file)} mention nothing ${code(pair.partner)} contains.`;
}
}
facts.push({
id: "coupling.missing",
weight,
weight: "info",
file: pair.file,
text: `${code(pair.partner)} changed with ${code(pair.file)} in ${pair.support} of ${pair.total} commits (last ${plural2(days, "day")}); this PR changes only ${code(pair.file)}.${suffix}`
});
Expand Down
51 changes: 38 additions & 13 deletions docs/captain.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,13 @@ version bump on an existing name is not a fact:

### `contract.api` — a changed public contract

A deleted line that reads as a public declaration, in a file that other files depend on:
A deleted line that reads as a public declaration, in a file that other files depend on. One fact
per file, with a count — a mass refactor that deletes hundreds of declarations in one file is one
fact, not hundreds, the way `change.safety_removed` already counts (`docs/experiments.md`,
"contract.api": one PR alone fired 2,628 facts under the old per-declaration behaviour):

> Removes or changes exported `parseToken` (`src/auth/token.ts:42`), imported by 6 files.
> Removes or changes 3 exported declarations in `src/auth/token.ts` (`parseToken`, `verify`,
> `refresh`), imported by 6 files.

### `contract.schema` — a schema or contract file changed

Expand All @@ -108,9 +112,11 @@ OpenAPI/Swagger specs, and anything added under `captain.contracts.paths`):
### `contract.env` — a new environment variable

An added line that reads an environment variable whose name does not appear anywhere in the tree
at the PR's base:
at the PR's base. One fact per file, with a count, and never on a generated file (`*.lock.yml`
and the like): a generated workflow lock file that introduces ten variables at once is one fact,
not ten (`docs/experiments.md`, "contract.env": 10 of 14 run-1 fires were one generated file):

> Reads a new environment variable `JWT_SECRET` (`src/auth/token.ts:12`).
> Reads 2 new environment variables (`JWT_SECRET`, `JWT_ISSUER`) (`src/auth/token.ts:12`).

### `contract.instructions` — a changed agent-instruction file

Expand Down Expand Up @@ -146,14 +152,30 @@ one of them:
> `session.ts` changed with `auth.ts` in 14 of 17 commits (last 365 days); this PR changes only
> `auth.ts`.

Three deterministic noise classes, found by reading the field test's 18 fires (spec §3.8), are
filtered or downgraded in the detector itself rather than left for a reviewer to dismiss: a
**retired file** (the firing file is deleted in this PR) never fires at all, since a file going
away has no partner left to update; a **partner lacking the subject** (every hunk of the firing
file removes lines, and no identifier from those removed lines occurs in the partner's base text)
downgrades to `info`; and a **test fired on its source** (the firing file matches `paths.tests`,
its partner does not) also downgrades to `info`, since a test changing alone is a test-only change,
not a forgotten co-change.
**Demoted 2026-10-23** (`docs/experiments.md`, "coupling.missing"): the outcome-labels study
measured this fact against real review comments and later fixes on 1,010 merged PRs and found 0 of
45 fires drew a reviewer's attention and the fix rate on the file was no higher than the PR's other
files (0.92×) — every fire was on a CI workflow, a manifest or a test, the same noise the
hand-graded field test found at 18-of-18 (spec §3.8) two orders of magnitude earlier. Two changes
follow from that result:

- The fact is now always `info`, never `strong`: it can no longer by itself raise a PR to
"careful review" or "standard review" (§2).
- It never fires at all when the file this PR actually changed (as opposed to its missing
partner) is a **CI workflow** (`.github/workflows/**`), a **manifest** (`package.json`,
`Cargo.toml`, `go.mod` and the other kinds `contract.dependency` parses), or a **test**
(`paths.tests`) — narrower than the field test's original three classes, which only downgraded
a changed-test-without-its-source case and left CI workflows and manifests unaddressed. A
**retired file** (the firing file is deleted in this PR) still never fires at all, since a file
going away has no partner left to update. When neither exclusion applies but every hunk of the
firing file removes lines and no identifier from those lines occurs in the partner's base text,
the text notes it (`mention nothing `<partner>` contains`) without changing the weight, since
every fire is `info` regardless now.

It still appears in the full AI-facing brief (`captain.facts` lists every fact, §5) and still
counts toward the PR comment's five slots when room is left after `strong` facts, but it can no
longer take a slot away from them the way a `strong` fact could, and it never pushes the priority
past what the other facts and the tier already say.

### `drift.scope` — what the PR grew into after opendrs first scored it

Expand Down Expand Up @@ -220,7 +242,10 @@ own captain brief off. See the [configuration reference](configuration.html) for
45.5 % of them were bug-inducing against a 23.6 % base rate: a lift of 1.9×. Adding it to the
score did not raise AUC (0.823 with and without), so it stays a captain fact and is not a
score signal. The defaults (`minSupport: 8`, `minConfidence: 0.6`) were tuned on the training
split only. Details: [Experiments](experiments.html).
split only. That ApacheJIT lift measures whether the file *was buggy*, not whether a reviewer
would have cared; measured against real review comments and fixes instead (the outcome-labels
study, §4 above), it did not clear its own bar and was demoted to `info`-only with a narrower
trigger, as recorded there. Details: [Experiments](experiments.html).
- **Contracts and change risk** (`contract.dependency`, `contract.api`, `contract.schema`,
`change.concurrency`, `change.safety_removed`) cleared the same pre-registered bug-lift bar
(≥ 1.2×, ≥ 50 firing test commits) on ApacheJIT's test split (24,690 commits, pooled):
Expand Down
Loading
Loading