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
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,19 @@
"url": "https://github.com/Tyler-R-Kendrick/ts-autocode/issues"
},
"scripts": {
"build": "npm run build:grounding && npm run build:harness && npm run build:rewrite && npm run build:training && npm run build:core",
"build": "npm run build:packages && npm run build:core",
"build:grounding": "node -e \"require('node:fs').rmSync('packages/grounding/dist', { recursive: true, force: true })\" && tsc -p packages/grounding/tsconfig.json",
"build:core": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
"build:harness": "node -e \"require('node:fs').rmSync('packages/harness/dist', { recursive: true, force: true })\" && tsc -p packages/harness/tsconfig.json",
"typecheck": "npm run build:grounding && npm run build:harness && npm run build:rewrite && npm run build:training && tsc --noEmit -p tsconfig.test.json && tsc --noEmit -p packages/grounding/tsconfig.test.json && tsc --noEmit -p packages/harness/tsconfig.test.json && tsc --noEmit -p packages/rewrite/tsconfig.test.json && tsc --noEmit -p packages/training/tsconfig.test.json",
"typecheck": "npm run build:packages && tsc --noEmit -p tsconfig.test.json && tsc --noEmit -p packages/grounding/tsconfig.test.json && tsc --noEmit -p packages/harness/tsconfig.test.json && tsc --noEmit -p packages/rewrite/tsconfig.test.json && tsc --noEmit -p packages/training/tsconfig.test.json",
"test": "node test/run.mjs",
"test:coverage": "node test/run.mjs --coverage",
"test:mutation": "stryker run",
"test:mutation": "npm run build:packages && stryker run",
"check": "npm run typecheck && npm run test:coverage && npm run build:core",
"prepublishOnly": "npm run check",
"build:training": "node -e \"require('node:fs').rmSync('packages/training/dist', { recursive: true, force: true })\" && tsc -p packages/training/tsconfig.json",
"build:rewrite": "node -e \"require('node:fs').rmSync('packages/rewrite/dist', { recursive: true, force: true })\" && tsc -p packages/rewrite/tsconfig.json"
"build:rewrite": "node -e \"require('node:fs').rmSync('packages/rewrite/dist', { recursive: true, force: true })\" && tsc -p packages/rewrite/tsconfig.json",
"build:packages": "npm run build:grounding && npm run build:harness && npm run build:rewrite && npm run build:training"
},
"keywords": [
"agentv",
Expand Down
23 changes: 16 additions & 7 deletions packages/rewrite/src/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,23 @@ export function revertRewrite(source: string, snapshot: RewriteSnapshot): string
}

function formatImplementation(implementation: string, methodIndent: string, source: string): string {
// Match the method's own indentation style rather than the whole file's,
// so a tab-indented method in a mostly-spaces file still gets tabs.
const indentUnit = methodIndent.includes("\t") ? "\t" : source.includes("\t") ? "\t" : " ";
// The method's own indentation style wins, so a tab-indented method in a
// mostly-spaces file still gets tabs; the file is the fallback for a method
// whose own indent says nothing, as a top-level function's does not.
const indentUnit = methodIndent.includes("\t") || source.includes("\t") ? "\t" : " ";
const bodyIndent = `${methodIndent}${indentUnit}`;
const lines = implementation.split("\n");
const minimumIndent = Math.min(
...lines.filter((line) => line.trim()).map((line) => /^\s*/.exec(line)?.[0].length ?? 0),
);
const normalized = lines.map((line) => `${bodyIndent}${line.slice(Number.isFinite(minimumIndent) ? minimumIndent : 0)}`);
// Blank lines carry no indentation to measure, and a blank line counted as
// zero would cancel the de-dent for every other line. `Math.min()` of
// nothing is Infinity, which is the all-blank body: de-dent by nothing.
const minimumIndent = Math.min(...lines.filter((line) => line.trim()).map(leadingWhitespace));
// A blank line is emitted empty rather than indented: trailing whitespace in
// a file this library wrote is a lint failure in the user's own repository.
const normalized = lines.map((line) =>
line.trim() ? `${bodyIndent}${line.slice(Number.isFinite(minimumIndent) ? minimumIndent : 0)}` : "");
return `\n${normalized.join("\n")}\n${methodIndent}`;
}

function leadingWhitespace(line: string): number {
return line.length - line.trimStart().length;
}
49 changes: 46 additions & 3 deletions packages/rewrite/test/apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,12 @@ describe("body reindentation", () => {
expect(result).not.toContain(' return "b";');
});

it("preserves blank lines inside the body", () => {
it("preserves blank lines inside the body, and leaves them empty", () => {
// Indenting a blank line would write trailing whitespace into the user's
// source file, which their own lint step then rejects.
const result = rewriteWith(spaceSource, 'const a = 1;\n\nreturn "b";');
expect(result).toContain('\n const a = 1;\n');
expect(result).toContain('\n return "b";\n }');
expect(result).toContain('\n const a = 1;\n\n return "b";\n }');
expect(result).not.toMatch(/[ \t]+\n/);
});

it("handles a body that is only whitespace without collapsing the method", () => {
Expand All @@ -127,6 +129,47 @@ describe("body reindentation", () => {
expect(result.endsWith("}\n")).toBe(true);
});

// The mutants that survived the previous round all lived in the de-dent:
// every case above uses either a single-line body or a multi-line one with
// no leading indent, so `Math.min` returned 0 whatever the filter did. A
// model returns indented, blank-line-separated code, which is exactly the
// shape that tells the decisions apart.
it("measures the de-dent from the code lines, ignoring the blank ones between them", () => {
// Every code line is indented four; dropping the blank-line filter would
// measure zero and leave the whole body double-indented.
const result = rewriteWith(spaceSource, ' const a = 1;\n\n return "b";');
expect(result).toContain('\n const a = 1;\n\n return "b";\n }');
});

it("treats a whitespace-only line as blank, not as the shallowest indent", () => {
// The separator here is two spaces rather than empty. Filtering on the
// raw line instead of its trimmed form would measure it as an indent of
// two and de-dent every other line by two instead of four.
const result = rewriteWith(spaceSource, ' const a = 1;\n \n return "b";');
expect(result).toContain('\n const a = 1;\n\n return "b";\n }');
expect(result).not.toContain('\n const a = 1;');
});

it("uses tabs when the file is tab-indented even where the method's own indent is not", () => {
// A method at column zero -- a top-level function, or the first method of
// a class written flush left -- has no indent of its own to copy, so the
// file decides. Nothing exercised this arm before: every tab fixture also
// had a tab-indented method.
const mixed = 'class T {\n\tother(): void {}\n}\nfunction m(): string {\n "use audit";\n return "a";\n}\n';
const bodyStart = mixed.indexOf('"use audit";') + '"use audit";'.length;
const closing = "\n}";
const target: RewriteTarget = {
id: "m",
artifactRef: "memory://mixed.ts",
bodyStart,
bodyEnd: mixed.lastIndexOf(closing) + closing.length - 1,
bodyDigest: digest(mixed.slice(bodyStart, mixed.lastIndexOf(closing) + closing.length - 1)),
indentation: "",
};
const result = applyCandidate(mixed, { id: "mixed", target, implementation: 'return "b";' });
expect(result).toContain('\n\treturn "b";\n}');
});

it("always opens with a newline and closes at the method's indent", () => {
const result = rewriteWith(spaceSource, 'return "b";');
const bodyStart = result.indexOf('"use audit";') + '"use audit";'.length;
Expand Down
10 changes: 10 additions & 0 deletions packages/rewrite/test/canonical.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ describe("digest", () => {
expect(digest(bare)).toBe(digest({ a: 2, b: 1 }));
});

it("hashes undefined rather than throwing on it", () => {
// `isRecord`'s `typeof` guard is what keeps `undefined` away from
// `Object.getPrototypeOf`, which throws on it. Optional fields reach the
// digest undefined -- candidate `metadata` is one -- so this is the
// ordinary case, not a hostile input.
expect(digest(undefined)).toMatch(/^sha256:[0-9a-f]{64}$/);
expect(digest({ metadata: undefined })).toBe(digest({}));
expect(digest([undefined])).toBe(digest([null]));
});

it("does not canonicalize class instances into empty objects", () => {
// A Date serializes through JSON.stringify; if isRecord wrongly accepted
// it, every Date would hash identically.
Expand Down
16 changes: 12 additions & 4 deletions stryker.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
"packages/rewrite/src/canonical.ts"
],
"thresholds": {
"high": 98,
"low": 95,
"break": 95
"high": 100,
"low": 100,
"break": 100
},
"_comment": [
"Scoped to the modules where a surviving mutant is alarming rather than merely untidy:",
Expand All @@ -39,7 +39,15 @@
"the decision. Mutating everything would take hours and mostly re-measure line coverage,",
"which vitest already enforces.",
"The break threshold is a ratchet, like the coverage thresholds: raise it as suites",
"improve, never lower it to get a build green."
"improve, never lower it to get a build green. It reached 100 by killing the last",
"survivors in the rewrite formatter; a genuinely equivalent mutant -- one no test",
"could distinguish -- is excluded at the line with a `// Stryker disable next-line",
"<mutator>: <reason>` comment, which is reviewable, rather than by lowering this.",
"Holding it at 100 is also the only thing that notices a test file dropping out of",
"the run. `npm run test:mutation` builds the sibling packages first because several",
"of these tests import them by name, through their dist/; without that build the",
"files fail to collect, and Stryker scores their mutants as survivors rather than",
"as an error. At a threshold of 95 that was invisible, and had been for a while."
],
"ignorePatterns": [
"test/output",
Expand Down
21 changes: 17 additions & 4 deletions test/fuzz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,27 +206,40 @@ describe("the fuzz corpus itself", () => {
// A corpus that never reaches the code under test makes every property
// above vacuously true. This asserts the corpus does its job, so the suite
// cannot quietly decay into theatre.
//
// Unlike the properties above, these three measure the *generator* rather
// than the code, so they draw from a fixed seed. Unseeded, each run drew a
// different corpus: `markedModule` can emit a class of only unmarked
// methods and no marked free function, so the hit rate is a binomial around
// 90 in 100 -- measured across seeds it ranges 83 to 95 -- and a threshold
// of 80 sits about three standard deviations out. CI duly drew 79 one run
// and failed on a corpus that was doing its job. A seeded draw measures the
// same thing every time and on every machine, and still fails loudly if the
// generators change such that the corpus stops reaching real code, which is
// the whole point of these three.
const corpusSeed = 1;

it("produces modules that discovery actually finds targets in", () => {
let withTargets = 0;
const samples = fc.sample(markedModule, 100);
for (const source of samples) {
for (const source of fc.sample(markedModule, { numRuns: 100, seed: corpusSeed })) {
if (discoverInSource(source, "fuzz.ts").length > 0) withTargets += 1;
}
expect(withTargets).toBeGreaterThan(80);
});

it("produces damaged modules that still often parse", () => {
let withTargets = 0;
for (const source of fc.sample(damagedModule, 200)) {
for (const source of fc.sample(damagedModule, { numRuns: 200, seed: corpusSeed })) {
if (safeDiscover(source).length > 0) withTargets += 1;
}
// Damaged input should be a genuine mix, not all-or-nothing.
expect(withTargets).toBeGreaterThan(10);
expect(withTargets).toBeLessThan(200);
});

it("produces modules the load hook actually rewrites", () => {
let rewritten = 0;
for (const source of fc.sample(markedModule, 100)) {
for (const source of fc.sample(markedModule, { numRuns: 100, seed: corpusSeed })) {
if (augmentSource(source, "fuzz.ts") !== source) rewritten += 1;
}
expect(rewritten).toBeGreaterThan(80);
Expand Down
Loading