@pmelab/gtd 14.0.1 → 14.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gtd.bundle.mjs +290 -15
- package/package.json +1 -1
- package/src/flows/runtime.ts +25 -6
- package/src/workflows/diff.test.ts +115 -0
- package/src/workflows/diff.ts +306 -0
- package/src/workflows/packages.ts +24 -8
- package/src/workflows/text.fixture.ts +1 -0
package/dist/gtd.bundle.mjs
CHANGED
|
@@ -30948,15 +30948,25 @@ const scope = async (scope, fn) => {
|
|
|
30948
30948
|
const read = (path) => ctx().read(path);
|
|
30949
30949
|
/** Every path in the tree matching `pattern` (`*` stays within a segment, `**` crosses them). */
|
|
30950
30950
|
const glob = (pattern) => ctx().glob(pattern);
|
|
30951
|
+
const toChanges = (list) => Object.freeze(Object.assign([...list], {
|
|
30952
|
+
paths: list.map((c) => c.path),
|
|
30953
|
+
get: (path) => list.find((c) => c.path === path)
|
|
30954
|
+
}));
|
|
30951
30955
|
/** What the last step changed, optionally only the paths matching a glob. */
|
|
30952
30956
|
const changes = (pattern) => {
|
|
30953
30957
|
const context = ctx();
|
|
30954
30958
|
const all = context.changes();
|
|
30955
|
-
|
|
30956
|
-
|
|
30957
|
-
|
|
30958
|
-
|
|
30959
|
-
|
|
30959
|
+
return toChanges(pattern === void 0 ? all : all.filter((c) => context.matches(c.path, pattern)));
|
|
30960
|
+
};
|
|
30961
|
+
/**
|
|
30962
|
+
* Every change from the tree at `hash` to the tree replay stands on — this
|
|
30963
|
+
* package's own range, when `hash` is captured at its first step. `hash` must
|
|
30964
|
+
* be the episode's base or one of its commits; anything else fails the step.
|
|
30965
|
+
*/
|
|
30966
|
+
const changesSince = (hash, pattern) => {
|
|
30967
|
+
const context = ctx();
|
|
30968
|
+
const all = context.changesSince(hash);
|
|
30969
|
+
return toChanges(pattern === void 0 ? all : all.filter((c) => context.matches(c.path, pattern)));
|
|
30960
30970
|
};
|
|
30961
30971
|
/** The commit the process stands on at this point of the flow. */
|
|
30962
30972
|
const head = () => ctx().head();
|
|
@@ -31128,6 +31138,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
|
|
|
31128
31138
|
agent: () => agent,
|
|
31129
31139
|
answered: () => answered,
|
|
31130
31140
|
changes: () => changes,
|
|
31141
|
+
changesSince: () => changesSince,
|
|
31131
31142
|
check: () => check,
|
|
31132
31143
|
checkScript: () => checkScript,
|
|
31133
31144
|
codeChanges: () => codeChanges,
|
|
@@ -45412,6 +45423,7 @@ const replay = async (input) => {
|
|
|
45412
45423
|
...c,
|
|
45413
45424
|
parsed: parseCommitMessage(c.message)
|
|
45414
45425
|
}));
|
|
45426
|
+
const treeAt = (hash) => hash === input.episode.base.hash ? input.episode.base.tree : commits.find((c) => c.hash === hash)?.tree;
|
|
45415
45427
|
let cursor = 0;
|
|
45416
45428
|
let pendingUsed = false;
|
|
45417
45429
|
let position = input.episode.base;
|
|
@@ -45616,6 +45628,11 @@ const replay = async (input) => {
|
|
|
45616
45628
|
read: (path) => position.tree.read(path),
|
|
45617
45629
|
glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
|
|
45618
45630
|
changes: () => changesBetween(previousPosition.tree, position.tree),
|
|
45631
|
+
changesSince: (hash) => {
|
|
45632
|
+
const tree = treeAt(hash);
|
|
45633
|
+
if (tree === void 0) throw new Error(`gtd: changesSince(${hash}): ${hash} is not the episode base or one of its commits — pass a hash this run read from head() or start(), not one captured earlier, read from state, or from another branch`);
|
|
45634
|
+
return changesBetween(tree, position.tree);
|
|
45635
|
+
},
|
|
45619
45636
|
matches: globMatches,
|
|
45620
45637
|
sections: (text) => headingSections(text),
|
|
45621
45638
|
sectionBodies: (text) => headingSectionBodies(text),
|
|
@@ -46638,29 +46655,286 @@ const gate = (name, message) => scope(name, async () => {
|
|
|
46638
46655
|
});
|
|
46639
46656
|
});
|
|
46640
46657
|
//#endregion
|
|
46658
|
+
//#region src/workflows/diff.ts
|
|
46659
|
+
const CONTEXT = 3;
|
|
46660
|
+
const TOO_LARGE_LINES = 1500;
|
|
46661
|
+
const LOCKFILES = /* @__PURE__ */ new Set([
|
|
46662
|
+
"package-lock.json",
|
|
46663
|
+
"npm-shrinkwrap.json",
|
|
46664
|
+
"yarn.lock",
|
|
46665
|
+
"pnpm-lock.yaml",
|
|
46666
|
+
"bun.lock",
|
|
46667
|
+
"bun.lockb",
|
|
46668
|
+
"Cargo.lock",
|
|
46669
|
+
"composer.lock",
|
|
46670
|
+
"Gemfile.lock",
|
|
46671
|
+
"poetry.lock",
|
|
46672
|
+
"uv.lock",
|
|
46673
|
+
"Pipfile.lock",
|
|
46674
|
+
"go.sum",
|
|
46675
|
+
"flake.lock",
|
|
46676
|
+
"mix.lock",
|
|
46677
|
+
"pubspec.lock",
|
|
46678
|
+
"Podfile.lock",
|
|
46679
|
+
"gradle.lockfile"
|
|
46680
|
+
]);
|
|
46681
|
+
const GENERATED_GLOBS = [
|
|
46682
|
+
"dist/**",
|
|
46683
|
+
"build/**",
|
|
46684
|
+
"out/**",
|
|
46685
|
+
"coverage/**",
|
|
46686
|
+
"node_modules/**",
|
|
46687
|
+
"vendor/**",
|
|
46688
|
+
".turbo/**",
|
|
46689
|
+
"**/__snapshots__/**",
|
|
46690
|
+
"**/*.snap",
|
|
46691
|
+
"**/*.min.js",
|
|
46692
|
+
"**/*.min.css",
|
|
46693
|
+
"**/*.map"
|
|
46694
|
+
];
|
|
46695
|
+
const BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
46696
|
+
"png",
|
|
46697
|
+
"jpg",
|
|
46698
|
+
"jpeg",
|
|
46699
|
+
"gif",
|
|
46700
|
+
"webp",
|
|
46701
|
+
"ico",
|
|
46702
|
+
"pdf",
|
|
46703
|
+
"zip",
|
|
46704
|
+
"gz",
|
|
46705
|
+
"tar",
|
|
46706
|
+
"woff",
|
|
46707
|
+
"woff2",
|
|
46708
|
+
"ttf",
|
|
46709
|
+
"otf",
|
|
46710
|
+
"mp4",
|
|
46711
|
+
"wasm",
|
|
46712
|
+
"bin",
|
|
46713
|
+
"exe",
|
|
46714
|
+
"so",
|
|
46715
|
+
"dylib"
|
|
46716
|
+
]);
|
|
46717
|
+
const globCache = /* @__PURE__ */ new Map();
|
|
46718
|
+
const compileGlob = (glob) => {
|
|
46719
|
+
let pattern = "^";
|
|
46720
|
+
let i = 0;
|
|
46721
|
+
while (i < glob.length) {
|
|
46722
|
+
const char = glob[i];
|
|
46723
|
+
if (char !== "*") {
|
|
46724
|
+
pattern += char.replace(/[.+^${}()|[\]\\?]/g, "\\$&");
|
|
46725
|
+
i += 1;
|
|
46726
|
+
} else if (glob[i + 1] !== "*") {
|
|
46727
|
+
pattern += "[^/]*";
|
|
46728
|
+
i += 1;
|
|
46729
|
+
} else if (glob[i + 2] === "/") {
|
|
46730
|
+
pattern += "(?:.*/)?";
|
|
46731
|
+
i += 3;
|
|
46732
|
+
} else {
|
|
46733
|
+
pattern += ".*";
|
|
46734
|
+
i += 2;
|
|
46735
|
+
}
|
|
46736
|
+
}
|
|
46737
|
+
return new RegExp(`${pattern}$`);
|
|
46738
|
+
};
|
|
46739
|
+
const matchesGlob = (path, glob) => {
|
|
46740
|
+
let regex = globCache.get(glob);
|
|
46741
|
+
if (regex === void 0) {
|
|
46742
|
+
regex = compileGlob(glob);
|
|
46743
|
+
globCache.set(glob, regex);
|
|
46744
|
+
}
|
|
46745
|
+
return regex.test(path);
|
|
46746
|
+
};
|
|
46747
|
+
const extensionOf = (path) => {
|
|
46748
|
+
const base = path.split("/").pop() ?? path;
|
|
46749
|
+
const dot = base.lastIndexOf(".");
|
|
46750
|
+
return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
|
|
46751
|
+
};
|
|
46752
|
+
const isBinary = (change) => BINARY_EXTENSIONS.has(extensionOf(change.path)) || (change.before?.includes("\0") ?? false) || (change.after?.includes("\0") ?? false);
|
|
46753
|
+
const isExcluded = (change) => change.path === ".gtd" || change.path.startsWith(".gtd/") || LOCKFILES.has(change.path.split("/").pop() ?? change.path) || GENERATED_GLOBS.some((glob) => matchesGlob(change.path, glob)) || isBinary(change);
|
|
46754
|
+
/** `changes` with lockfiles, generated trees and binaries dropped. */
|
|
46755
|
+
const filterChanges = (changes) => changes.filter((change) => !isExcluded(change));
|
|
46756
|
+
const linesOf = (content) => {
|
|
46757
|
+
if (content === void 0 || content === "") return [];
|
|
46758
|
+
const lines = content.split("\n");
|
|
46759
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
46760
|
+
return lines;
|
|
46761
|
+
};
|
|
46762
|
+
/** `dp[i][j]` = the LCS length of `a[i:]` and `b[j:]`. */
|
|
46763
|
+
const lcsTable = (a, b) => {
|
|
46764
|
+
const n = a.length;
|
|
46765
|
+
const m = b.length;
|
|
46766
|
+
const dp = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }).fill(0));
|
|
46767
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
46768
|
+
return dp;
|
|
46769
|
+
};
|
|
46770
|
+
/** Walk an LCS table into an edit script, favoring a deletion on a tie. */
|
|
46771
|
+
const backtrack = (a, b, dp) => {
|
|
46772
|
+
const ops = [];
|
|
46773
|
+
let i = 0;
|
|
46774
|
+
let j = 0;
|
|
46775
|
+
while (i < a.length && j < b.length) if (a[i] === b[j]) {
|
|
46776
|
+
ops.push({
|
|
46777
|
+
type: "eq",
|
|
46778
|
+
line: a[i]
|
|
46779
|
+
});
|
|
46780
|
+
i++;
|
|
46781
|
+
j++;
|
|
46782
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
46783
|
+
ops.push({
|
|
46784
|
+
type: "del",
|
|
46785
|
+
line: a[i]
|
|
46786
|
+
});
|
|
46787
|
+
i++;
|
|
46788
|
+
} else {
|
|
46789
|
+
ops.push({
|
|
46790
|
+
type: "add",
|
|
46791
|
+
line: b[j]
|
|
46792
|
+
});
|
|
46793
|
+
j++;
|
|
46794
|
+
}
|
|
46795
|
+
while (i < a.length) ops.push({
|
|
46796
|
+
type: "del",
|
|
46797
|
+
line: a[i++]
|
|
46798
|
+
});
|
|
46799
|
+
while (j < b.length) ops.push({
|
|
46800
|
+
type: "add",
|
|
46801
|
+
line: b[j++]
|
|
46802
|
+
});
|
|
46803
|
+
return ops;
|
|
46804
|
+
};
|
|
46805
|
+
/** Longest-common-subsequence edit script between two (already trimmed) line arrays. */
|
|
46806
|
+
const lcsOps = (a, b) => backtrack(a, b, lcsTable(a, b));
|
|
46807
|
+
const commonAffixes = (before, after) => {
|
|
46808
|
+
const maxPrefix = Math.min(before.length, after.length);
|
|
46809
|
+
let prefix = 0;
|
|
46810
|
+
while (prefix < maxPrefix && before[prefix] === after[prefix]) prefix++;
|
|
46811
|
+
const maxSuffix = maxPrefix - prefix;
|
|
46812
|
+
let suffix = 0;
|
|
46813
|
+
while (suffix < maxSuffix && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) suffix++;
|
|
46814
|
+
return {
|
|
46815
|
+
prefix,
|
|
46816
|
+
suffix
|
|
46817
|
+
};
|
|
46818
|
+
};
|
|
46819
|
+
const buildHunks = (ops) => {
|
|
46820
|
+
const beforeLineAt = [];
|
|
46821
|
+
const afterLineAt = [];
|
|
46822
|
+
let bl = 1;
|
|
46823
|
+
let al = 1;
|
|
46824
|
+
for (const op of ops) {
|
|
46825
|
+
beforeLineAt.push(bl);
|
|
46826
|
+
afterLineAt.push(al);
|
|
46827
|
+
if (op.type !== "add") bl++;
|
|
46828
|
+
if (op.type !== "del") al++;
|
|
46829
|
+
}
|
|
46830
|
+
const ranges = [];
|
|
46831
|
+
ops.forEach((op, idx) => {
|
|
46832
|
+
if (op.type === "eq") return;
|
|
46833
|
+
const start = Math.max(0, idx - CONTEXT);
|
|
46834
|
+
const end = Math.min(ops.length - 1, idx + CONTEXT);
|
|
46835
|
+
const last = ranges[ranges.length - 1];
|
|
46836
|
+
if (last !== void 0 && start <= last[1] + 1) last[1] = Math.max(last[1], end);
|
|
46837
|
+
else ranges.push([start, end]);
|
|
46838
|
+
});
|
|
46839
|
+
return ranges.map(([start, end]) => {
|
|
46840
|
+
const slice = ops.slice(start, end + 1);
|
|
46841
|
+
return {
|
|
46842
|
+
beforeStart: beforeLineAt[start],
|
|
46843
|
+
beforeCount: slice.filter((op) => op.type !== "add").length,
|
|
46844
|
+
afterStart: afterLineAt[start],
|
|
46845
|
+
afterCount: slice.filter((op) => op.type !== "del").length,
|
|
46846
|
+
lines: slice.map((op) => `${op.type === "add" ? "+" : op.type === "del" ? "-" : " "}${op.line}`)
|
|
46847
|
+
};
|
|
46848
|
+
});
|
|
46849
|
+
};
|
|
46850
|
+
const renderHunk = (hunk) => [`@@ -${hunk.beforeStart},${hunk.beforeCount} +${hunk.afterStart},${hunk.afterCount} @@`, ...hunk.lines];
|
|
46851
|
+
/** One file's unified diff, or a summary line when its changed region is too large to inline. */
|
|
46852
|
+
const renderFileDiff = (change) => {
|
|
46853
|
+
const before = linesOf(change.before);
|
|
46854
|
+
const after = linesOf(change.after);
|
|
46855
|
+
const { prefix, suffix } = commonAffixes(before, after);
|
|
46856
|
+
const beforeMiddle = before.slice(prefix, before.length - suffix);
|
|
46857
|
+
const afterMiddle = after.slice(prefix, after.length - suffix);
|
|
46858
|
+
if (beforeMiddle.length > TOO_LARGE_LINES || afterMiddle.length > TOO_LARGE_LINES) return `${change.path}: +${afterMiddle.length}/-${beforeMiddle.length} lines, too large to inline`;
|
|
46859
|
+
const prefixOps = before.slice(0, prefix).map((line) => ({
|
|
46860
|
+
type: "eq",
|
|
46861
|
+
line
|
|
46862
|
+
}));
|
|
46863
|
+
const suffixOps = before.slice(before.length - suffix).map((line) => ({
|
|
46864
|
+
type: "eq",
|
|
46865
|
+
line
|
|
46866
|
+
}));
|
|
46867
|
+
const ops = [
|
|
46868
|
+
...prefixOps,
|
|
46869
|
+
...lcsOps(beforeMiddle, afterMiddle),
|
|
46870
|
+
...suffixOps
|
|
46871
|
+
];
|
|
46872
|
+
const beforePath = change.status === "added" ? "/dev/null" : `a/${change.path}`;
|
|
46873
|
+
const afterPath = change.status === "deleted" ? "/dev/null" : `b/${change.path}`;
|
|
46874
|
+
return [
|
|
46875
|
+
`--- ${beforePath}`,
|
|
46876
|
+
`+++ ${afterPath}`,
|
|
46877
|
+
...buildHunks(ops).flatMap(renderHunk)
|
|
46878
|
+
].join("\n");
|
|
46879
|
+
};
|
|
46880
|
+
const omissionTrailer = (paths) => `${paths.length} file(s) omitted for the judge's byte budget: ${paths.join(", ")}`;
|
|
46881
|
+
/** The byte size of keeping `files`' first `keepCount` entries plus the trailer the rest would need — the trailer counts against the cap too, not just the kept text. */
|
|
46882
|
+
const sizeWithTrailer = (files, keepCount) => {
|
|
46883
|
+
let used = 0;
|
|
46884
|
+
for (let i = 0; i < keepCount; i++) used += Buffer.byteLength(files[i].text, "utf8") + (i > 0 ? 1 : 0);
|
|
46885
|
+
if (files.length - keepCount === 0) return used;
|
|
46886
|
+
const trailer = omissionTrailer(files.slice(keepCount).map((f) => f.path));
|
|
46887
|
+
return used + (keepCount > 0 ? 1 : 0) + Buffer.byteLength(trailer, "utf8");
|
|
46888
|
+
};
|
|
46889
|
+
/**
|
|
46890
|
+
* The filtered `changes`, rendered as one unified diff (files in path order)
|
|
46891
|
+
* and bounded to `capBytes`: over the cap, whole files are dropped from the
|
|
46892
|
+
* end — never bytes off a hunk — and named in a trailer line, so the
|
|
46893
|
+
* judge is told what it did not see rather than handed a silently short diff.
|
|
46894
|
+
* The trailer itself is sized against the cap along with the files it keeps,
|
|
46895
|
+
* so a package with many touched files can never push the total past it.
|
|
46896
|
+
*/
|
|
46897
|
+
const packageDiff = (changes, capBytes) => {
|
|
46898
|
+
const files = filterChanges(changes).slice().sort((a, b) => a.path.localeCompare(b.path)).map((change) => ({
|
|
46899
|
+
path: change.path,
|
|
46900
|
+
text: renderFileDiff(change)
|
|
46901
|
+
}));
|
|
46902
|
+
let keepCount = files.length;
|
|
46903
|
+
while (keepCount > 0 && sizeWithTrailer(files, keepCount) > capBytes) keepCount--;
|
|
46904
|
+
const dropped = files.slice(keepCount).map((f) => f.path);
|
|
46905
|
+
return [...files.slice(0, keepCount).map((f) => f.text), ...dropped.length > 0 ? [omissionTrailer(dropped)] : []].join("\n");
|
|
46906
|
+
};
|
|
46907
|
+
//#endregion
|
|
46641
46908
|
//#region src/workflows/packages.ts
|
|
46642
46909
|
const MAX_SECTIONS = 8;
|
|
46910
|
+
const DIFF_KEY = "diff";
|
|
46643
46911
|
const sectionQuestion = (id, title) => ({
|
|
46644
46912
|
id,
|
|
46645
46913
|
primitive: "noul",
|
|
46646
|
-
instructions: `Is the requirement "${title}" already fully satisfied by the code
|
|
46647
|
-
criteria: "Judge from the
|
|
46914
|
+
instructions: `Is the requirement "${title}" — the "${id}" evidence — already fully satisfied by the code in the "${DIFF_KEY}" evidence?`,
|
|
46915
|
+
criteria: "Judge from the requirement text and the diff evidence alone. Only answer yes at a probability clearing the threshold below if genuinely confident nothing in this section is missing."
|
|
46648
46916
|
});
|
|
46649
46917
|
/**
|
|
46650
46918
|
* Review one freshly built package against its spec: a pre-judge per
|
|
46651
46919
|
* section, then an agent review of the sections it did not confidently
|
|
46652
|
-
* clear.
|
|
46920
|
+
* clear. `since` is the commit the package's own build started from — the
|
|
46921
|
+
* pre-judge's evidence is a diff over exactly that range, never an earlier
|
|
46922
|
+
* package's commits. Resolves `true` when approved.
|
|
46653
46923
|
*/
|
|
46654
|
-
const specReview = async (pkg) => {
|
|
46924
|
+
const specReview = async (pkg, since) => {
|
|
46655
46925
|
const found = sectionBodies(read(pkg) ?? "");
|
|
46656
46926
|
const titles = found.map((section) => section.title);
|
|
46657
46927
|
const judged = titles.length > 0 && titles.length <= MAX_SECTIONS;
|
|
46658
46928
|
const evidence = {};
|
|
46659
46929
|
const questions = [];
|
|
46660
|
-
if (judged)
|
|
46661
|
-
|
|
46662
|
-
|
|
46663
|
-
|
|
46930
|
+
if (judged) {
|
|
46931
|
+
found.forEach(({ title, body }, i) => {
|
|
46932
|
+
evidence[`section-${i + 1}`] = body;
|
|
46933
|
+
questions.push(sectionQuestion(`section-${i + 1}`, title));
|
|
46934
|
+
});
|
|
46935
|
+
const capBytes = Math.floor(numeric(vars.judgeBudgetBytes, 32768) / (found.length + 1));
|
|
46936
|
+
evidence[DIFF_KEY] = packageDiff(changesSince(since), capBytes);
|
|
46937
|
+
}
|
|
46664
46938
|
const { answers, truncated } = await judge("spec.pre", {
|
|
46665
46939
|
questions,
|
|
46666
46940
|
evidence,
|
|
@@ -46670,7 +46944,7 @@ const specReview = async (pkg) => {
|
|
|
46670
46944
|
const clearMinP = numeric(vars.specPreJudge, Infinity);
|
|
46671
46945
|
const failing = titles.filter((_, i) => {
|
|
46672
46946
|
const id = `section-${i + 1}`;
|
|
46673
|
-
return !judged || truncated.includes(id) || !answered(answers[id], "yes", clearMinP);
|
|
46947
|
+
return !judged || truncated.includes(id) || truncated.includes(DIFF_KEY) || !answered(answers[id], "yes", clearMinP);
|
|
46674
46948
|
});
|
|
46675
46949
|
if (titles.length > 0 && failing.length === 0) return true;
|
|
46676
46950
|
await reviewPackage(pkg, failing);
|
|
@@ -46680,10 +46954,11 @@ const specReview = async (pkg) => {
|
|
|
46680
46954
|
};
|
|
46681
46955
|
/** Build `pkg`, keep the suite green, review it against its spec, and close it out, which removes it. */
|
|
46682
46956
|
const packageItem = async (pkg) => {
|
|
46957
|
+
const since = head();
|
|
46683
46958
|
await build(pkg);
|
|
46684
46959
|
for (;;) {
|
|
46685
46960
|
await healthy(fixSuite);
|
|
46686
|
-
if (await specReview(pkg)) break;
|
|
46961
|
+
if (await specReview(pkg, since)) break;
|
|
46687
46962
|
await fixSpec(pkg);
|
|
46688
46963
|
}
|
|
46689
46964
|
await run$2("closing", removeScript([
|
package/package.json
CHANGED
package/src/flows/runtime.ts
CHANGED
|
@@ -125,6 +125,8 @@ export interface FlowContext {
|
|
|
125
125
|
readonly glob: (pattern: string) => readonly string[]
|
|
126
126
|
/** The last step's changes, content read on demand. */
|
|
127
127
|
readonly changes: () => readonly Change[]
|
|
128
|
+
/** Every change from the tree at `hash` to the tree replay stands on. Throws when `hash` resolves to neither. */
|
|
129
|
+
readonly changesSince: (hash: string) => readonly Change[]
|
|
128
130
|
readonly matches: (path: string, pattern: string) => boolean
|
|
129
131
|
readonly sections: (text: string) => readonly string[]
|
|
130
132
|
readonly sectionBodies: (text: string) => readonly Section[]
|
|
@@ -201,17 +203,34 @@ export const read = (path: string): string | undefined => ctx().read(path)
|
|
|
201
203
|
/** Every path in the tree matching `pattern` (`*` stays within a segment, `**` crosses them). */
|
|
202
204
|
export const glob = (pattern: string): readonly string[] => ctx().glob(pattern)
|
|
203
205
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const context = ctx()
|
|
207
|
-
const all = context.changes()
|
|
208
|
-
const list = pattern === undefined ? all : all.filter((c) => context.matches(c.path, pattern))
|
|
209
|
-
return Object.freeze(
|
|
206
|
+
const toChanges = (list: readonly Change[]): Changes =>
|
|
207
|
+
Object.freeze(
|
|
210
208
|
Object.assign([...list], {
|
|
211
209
|
paths: list.map((c) => c.path),
|
|
212
210
|
get: (path: string) => list.find((c) => c.path === path),
|
|
213
211
|
}),
|
|
214
212
|
)
|
|
213
|
+
|
|
214
|
+
/** What the last step changed, optionally only the paths matching a glob. */
|
|
215
|
+
export const changes = (pattern?: string): Changes => {
|
|
216
|
+
const context = ctx()
|
|
217
|
+
const all = context.changes()
|
|
218
|
+
return toChanges(
|
|
219
|
+
pattern === undefined ? all : all.filter((c) => context.matches(c.path, pattern)),
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Every change from the tree at `hash` to the tree replay stands on — this
|
|
225
|
+
* package's own range, when `hash` is captured at its first step. `hash` must
|
|
226
|
+
* be the episode's base or one of its commits; anything else fails the step.
|
|
227
|
+
*/
|
|
228
|
+
export const changesSince = (hash: string, pattern?: string): Changes => {
|
|
229
|
+
const context = ctx()
|
|
230
|
+
const all = context.changesSince(hash)
|
|
231
|
+
return toChanges(
|
|
232
|
+
pattern === undefined ? all : all.filter((c) => context.matches(c.path, pattern)),
|
|
233
|
+
)
|
|
215
234
|
}
|
|
216
235
|
|
|
217
236
|
/** The commit the process stands on at this point of the flow. */
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import type { Change } from "../flows/index.js"
|
|
3
|
+
import { filterChanges, packageDiff } from "./diff.js"
|
|
4
|
+
|
|
5
|
+
const change = (over: Partial<Change> & Pick<Change, "path" | "status">): Change => ({
|
|
6
|
+
before: undefined,
|
|
7
|
+
after: undefined,
|
|
8
|
+
...over,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
// A cap high enough that nothing is ever dropped, for cases exercising the
|
|
12
|
+
// rendering pipeline rather than the byte-budget behavior itself.
|
|
13
|
+
const NO_CAP = 10_000
|
|
14
|
+
|
|
15
|
+
describe("packageDiff / rendering", () => {
|
|
16
|
+
it("renders a normal edit as hunks with three lines of context", () => {
|
|
17
|
+
const before = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join("\n")
|
|
18
|
+
const after = before.replace("line 5", "line five")
|
|
19
|
+
const text = packageDiff([change({ path: "a.ts", status: "modified", before, after })], NO_CAP)
|
|
20
|
+
expect(text).toContain("--- a/a.ts")
|
|
21
|
+
expect(text).toContain("+++ b/a.ts")
|
|
22
|
+
expect(text).toContain("-line 5")
|
|
23
|
+
expect(text).toContain("+line five")
|
|
24
|
+
// three lines of context on either side of the one changed line
|
|
25
|
+
expect(text).toContain(" line 2")
|
|
26
|
+
expect(text).toContain(" line 8")
|
|
27
|
+
expect(text).not.toContain(" line 1\n")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("degrades a 1500+ line change to its summary line", () => {
|
|
31
|
+
const before = Array.from({ length: 2000 }, (_, i) => `line ${i}`).join("\n")
|
|
32
|
+
const after = Array.from({ length: 2000 }, (_, i) => `changed ${i}`).join("\n")
|
|
33
|
+
const text = packageDiff(
|
|
34
|
+
[change({ path: "big.ts", status: "modified", before, after })],
|
|
35
|
+
NO_CAP,
|
|
36
|
+
)
|
|
37
|
+
expect(text).toBe("big.ts: +2000/-2000 lines, too large to inline")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("renders files in path order for byte-identical output across replays", () => {
|
|
41
|
+
const a = change({ path: "z.ts", status: "added", after: "z" })
|
|
42
|
+
const b = change({ path: "a.ts", status: "added", after: "a" })
|
|
43
|
+
expect(packageDiff([a, b], NO_CAP)).toBe(packageDiff([b, a], NO_CAP))
|
|
44
|
+
expect(packageDiff([a, b], NO_CAP).indexOf("a.ts")).toBeLessThan(
|
|
45
|
+
packageDiff([a, b], NO_CAP).indexOf("z.ts"),
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe("filterChanges / exclusion list", () => {
|
|
51
|
+
it("excludes a lockfile change entirely", () => {
|
|
52
|
+
const c = change({ path: "package-lock.json", status: "modified", before: "a", after: "b" })
|
|
53
|
+
expect(filterChanges([c])).toEqual([])
|
|
54
|
+
expect(packageDiff([c], NO_CAP)).toBe("")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("excludes .gtd/** paths", () => {
|
|
58
|
+
const c = change({ path: ".gtd/PLAN.md", status: "added", after: "x" })
|
|
59
|
+
expect(filterChanges([c])).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("excludes a binary file by extension", () => {
|
|
63
|
+
const c = change({ path: "logo.png", status: "added", after: "binary-ish" })
|
|
64
|
+
expect(filterChanges([c])).toEqual([])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("excludes a binary file by content, separately from extension", () => {
|
|
68
|
+
const c = change({ path: "weird.txt", status: "added", after: "abc\0def" })
|
|
69
|
+
expect(filterChanges([c])).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("keeps an ordinary source file", () => {
|
|
73
|
+
const c = change({ path: "src/a.ts", status: "added", after: "export const a = 1\n" })
|
|
74
|
+
expect(filterChanges([c])).toEqual([c])
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe("packageDiff", () => {
|
|
79
|
+
it("drops whole files from the end and names them in a trailer", () => {
|
|
80
|
+
const a = change({ path: "a.ts", status: "added", after: "export const a = 1\n" })
|
|
81
|
+
const b = change({
|
|
82
|
+
path: "b.ts",
|
|
83
|
+
status: "added",
|
|
84
|
+
after: Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n"),
|
|
85
|
+
})
|
|
86
|
+
const text = packageDiff([a, b], 120)
|
|
87
|
+
expect(text).toContain("--- /dev/null")
|
|
88
|
+
expect(text).toContain("+++ b/a.ts")
|
|
89
|
+
expect(text).not.toContain("b.ts\n")
|
|
90
|
+
expect(text).toContain("1 file(s) omitted for the judge's byte budget: b.ts")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("counts the omission trailer itself against the cap, so many dropped paths never push the total past it", () => {
|
|
94
|
+
const kept = change({ path: "a.ts", status: "added", after: "export const a = 1\n" })
|
|
95
|
+
const rest = Array.from({ length: 6 }, (_, i) =>
|
|
96
|
+
change({ path: `f${i}.ts`, status: "added", after: "y" }),
|
|
97
|
+
)
|
|
98
|
+
// A naive cap check (size the kept text alone, append the trailer
|
|
99
|
+
// afterwards) would keep a.ts plus two of the small files here — 150
|
|
100
|
+
// bytes of text — then tack on an unbudgeted trailer for the other four,
|
|
101
|
+
// landing well past capBytes. Budgeting the trailer itself must instead
|
|
102
|
+
// drop enough files that the total, trailer included, stays at or under it.
|
|
103
|
+
const capBytes = 150
|
|
104
|
+
const text = packageDiff([kept, ...rest], capBytes)
|
|
105
|
+
expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(capBytes)
|
|
106
|
+
expect(text).toContain("omitted")
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("keeps every file when the cap is not exceeded", () => {
|
|
110
|
+
const a = change({ path: "a.ts", status: "added", after: "x" })
|
|
111
|
+
const text = packageDiff([a], 10_000)
|
|
112
|
+
expect(text).not.toContain("omitted")
|
|
113
|
+
expect(text).toContain("a.ts")
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import type { Change } from "../flows/index.js"
|
|
2
|
+
|
|
3
|
+
// Pure evidence rendering for `packages.item.spec.pre`: no step, no tree
|
|
4
|
+
// access. Everything here works from the `Change` list a flow already holds.
|
|
5
|
+
|
|
6
|
+
const CONTEXT = 3
|
|
7
|
+
const TOO_LARGE_LINES = 1500
|
|
8
|
+
|
|
9
|
+
// ── The fixed exclusion list — no configuration key ─────────────────────────
|
|
10
|
+
|
|
11
|
+
const LOCKFILES = new Set([
|
|
12
|
+
"package-lock.json",
|
|
13
|
+
"npm-shrinkwrap.json",
|
|
14
|
+
"yarn.lock",
|
|
15
|
+
"pnpm-lock.yaml",
|
|
16
|
+
"bun.lock",
|
|
17
|
+
"bun.lockb",
|
|
18
|
+
"Cargo.lock",
|
|
19
|
+
"composer.lock",
|
|
20
|
+
"Gemfile.lock",
|
|
21
|
+
"poetry.lock",
|
|
22
|
+
"uv.lock",
|
|
23
|
+
"Pipfile.lock",
|
|
24
|
+
"go.sum",
|
|
25
|
+
"flake.lock",
|
|
26
|
+
"mix.lock",
|
|
27
|
+
"pubspec.lock",
|
|
28
|
+
"Podfile.lock",
|
|
29
|
+
"gradle.lockfile",
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
const GENERATED_GLOBS = [
|
|
33
|
+
"dist/**",
|
|
34
|
+
"build/**",
|
|
35
|
+
"out/**",
|
|
36
|
+
"coverage/**",
|
|
37
|
+
"node_modules/**",
|
|
38
|
+
"vendor/**",
|
|
39
|
+
".turbo/**",
|
|
40
|
+
"**/__snapshots__/**",
|
|
41
|
+
"**/*.snap",
|
|
42
|
+
"**/*.min.js",
|
|
43
|
+
"**/*.min.css",
|
|
44
|
+
"**/*.map",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
const BINARY_EXTENSIONS = new Set([
|
|
48
|
+
"png",
|
|
49
|
+
"jpg",
|
|
50
|
+
"jpeg",
|
|
51
|
+
"gif",
|
|
52
|
+
"webp",
|
|
53
|
+
"ico",
|
|
54
|
+
"pdf",
|
|
55
|
+
"zip",
|
|
56
|
+
"gz",
|
|
57
|
+
"tar",
|
|
58
|
+
"woff",
|
|
59
|
+
"woff2",
|
|
60
|
+
"ttf",
|
|
61
|
+
"otf",
|
|
62
|
+
"mp4",
|
|
63
|
+
"wasm",
|
|
64
|
+
"bin",
|
|
65
|
+
"exe",
|
|
66
|
+
"so",
|
|
67
|
+
"dylib",
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
// A small, self-contained glob match — `*` stays within a segment, `**`
|
|
71
|
+
// crosses them — kept local rather than reached through another boundary for
|
|
72
|
+
// eighteen fixed patterns.
|
|
73
|
+
const globCache = new Map<string, RegExp>()
|
|
74
|
+
const compileGlob = (glob: string): RegExp => {
|
|
75
|
+
let pattern = "^"
|
|
76
|
+
let i = 0
|
|
77
|
+
while (i < glob.length) {
|
|
78
|
+
const char = glob[i]!
|
|
79
|
+
if (char !== "*") {
|
|
80
|
+
pattern += char.replace(/[.+^${}()|[\]\\?]/g, "\\$&")
|
|
81
|
+
i += 1
|
|
82
|
+
} else if (glob[i + 1] !== "*") {
|
|
83
|
+
pattern += "[^/]*"
|
|
84
|
+
i += 1
|
|
85
|
+
} else if (glob[i + 2] === "/") {
|
|
86
|
+
pattern += "(?:.*/)?"
|
|
87
|
+
i += 3
|
|
88
|
+
} else {
|
|
89
|
+
pattern += ".*"
|
|
90
|
+
i += 2
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return new RegExp(`${pattern}$`)
|
|
94
|
+
}
|
|
95
|
+
const matchesGlob = (path: string, glob: string): boolean => {
|
|
96
|
+
let regex = globCache.get(glob)
|
|
97
|
+
if (regex === undefined) {
|
|
98
|
+
regex = compileGlob(glob)
|
|
99
|
+
globCache.set(glob, regex)
|
|
100
|
+
}
|
|
101
|
+
return regex.test(path)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const extensionOf = (path: string): string => {
|
|
105
|
+
const base = path.split("/").pop() ?? path
|
|
106
|
+
const dot = base.lastIndexOf(".")
|
|
107
|
+
return dot === -1 ? "" : base.slice(dot + 1).toLowerCase()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const isBinary = (change: Change): boolean =>
|
|
111
|
+
BINARY_EXTENSIONS.has(extensionOf(change.path)) ||
|
|
112
|
+
(change.before?.includes("\0") ?? false) ||
|
|
113
|
+
(change.after?.includes("\0") ?? false)
|
|
114
|
+
|
|
115
|
+
const isExcluded = (change: Change): boolean =>
|
|
116
|
+
change.path === ".gtd" ||
|
|
117
|
+
change.path.startsWith(".gtd/") ||
|
|
118
|
+
LOCKFILES.has(change.path.split("/").pop() ?? change.path) ||
|
|
119
|
+
GENERATED_GLOBS.some((glob) => matchesGlob(change.path, glob)) ||
|
|
120
|
+
isBinary(change)
|
|
121
|
+
|
|
122
|
+
/** `changes` with lockfiles, generated trees and binaries dropped. */
|
|
123
|
+
export const filterChanges = (changes: readonly Change[]): readonly Change[] =>
|
|
124
|
+
changes.filter((change) => !isExcluded(change))
|
|
125
|
+
|
|
126
|
+
// ── Line diff ────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
interface Op {
|
|
129
|
+
readonly type: "eq" | "add" | "del"
|
|
130
|
+
readonly line: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const linesOf = (content: string | undefined): readonly string[] => {
|
|
134
|
+
if (content === undefined || content === "") return []
|
|
135
|
+
const lines = content.split("\n")
|
|
136
|
+
if (lines[lines.length - 1] === "") lines.pop()
|
|
137
|
+
return lines
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `dp[i][j]` = the LCS length of `a[i:]` and `b[j:]`. */
|
|
141
|
+
const lcsTable = (a: readonly string[], b: readonly string[]): number[][] => {
|
|
142
|
+
const n = a.length
|
|
143
|
+
const m = b.length
|
|
144
|
+
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
|
145
|
+
Array.from<number>({ length: m + 1 }).fill(0),
|
|
146
|
+
)
|
|
147
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
148
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
149
|
+
dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return dp
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Walk an LCS table into an edit script, favoring a deletion on a tie. */
|
|
156
|
+
const backtrack = (a: readonly string[], b: readonly string[], dp: number[][]): Op[] => {
|
|
157
|
+
const ops: Op[] = []
|
|
158
|
+
let i = 0
|
|
159
|
+
let j = 0
|
|
160
|
+
while (i < a.length && j < b.length) {
|
|
161
|
+
if (a[i] === b[j]) {
|
|
162
|
+
ops.push({ type: "eq", line: a[i]! })
|
|
163
|
+
i++
|
|
164
|
+
j++
|
|
165
|
+
} else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
|
|
166
|
+
ops.push({ type: "del", line: a[i]! })
|
|
167
|
+
i++
|
|
168
|
+
} else {
|
|
169
|
+
ops.push({ type: "add", line: b[j]! })
|
|
170
|
+
j++
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
while (i < a.length) ops.push({ type: "del", line: a[i++]! })
|
|
174
|
+
while (j < b.length) ops.push({ type: "add", line: b[j++]! })
|
|
175
|
+
return ops
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Longest-common-subsequence edit script between two (already trimmed) line arrays. */
|
|
179
|
+
const lcsOps = (a: readonly string[], b: readonly string[]): Op[] => backtrack(a, b, lcsTable(a, b))
|
|
180
|
+
|
|
181
|
+
const commonAffixes = (
|
|
182
|
+
before: readonly string[],
|
|
183
|
+
after: readonly string[],
|
|
184
|
+
): { readonly prefix: number; readonly suffix: number } => {
|
|
185
|
+
const maxPrefix = Math.min(before.length, after.length)
|
|
186
|
+
let prefix = 0
|
|
187
|
+
while (prefix < maxPrefix && before[prefix] === after[prefix]) prefix++
|
|
188
|
+
const maxSuffix = maxPrefix - prefix
|
|
189
|
+
let suffix = 0
|
|
190
|
+
while (
|
|
191
|
+
suffix < maxSuffix &&
|
|
192
|
+
before[before.length - 1 - suffix] === after[after.length - 1 - suffix]
|
|
193
|
+
)
|
|
194
|
+
suffix++
|
|
195
|
+
return { prefix, suffix }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface Hunk {
|
|
199
|
+
readonly beforeStart: number
|
|
200
|
+
readonly beforeCount: number
|
|
201
|
+
readonly afterStart: number
|
|
202
|
+
readonly afterCount: number
|
|
203
|
+
readonly lines: readonly string[]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const buildHunks = (ops: readonly Op[]): readonly Hunk[] => {
|
|
207
|
+
const beforeLineAt: number[] = []
|
|
208
|
+
const afterLineAt: number[] = []
|
|
209
|
+
let bl = 1
|
|
210
|
+
let al = 1
|
|
211
|
+
for (const op of ops) {
|
|
212
|
+
beforeLineAt.push(bl)
|
|
213
|
+
afterLineAt.push(al)
|
|
214
|
+
if (op.type !== "add") bl++
|
|
215
|
+
if (op.type !== "del") al++
|
|
216
|
+
}
|
|
217
|
+
const ranges: [number, number][] = []
|
|
218
|
+
ops.forEach((op, idx) => {
|
|
219
|
+
if (op.type === "eq") return
|
|
220
|
+
const start = Math.max(0, idx - CONTEXT)
|
|
221
|
+
const end = Math.min(ops.length - 1, idx + CONTEXT)
|
|
222
|
+
const last = ranges[ranges.length - 1]
|
|
223
|
+
if (last !== undefined && start <= last[1] + 1) {
|
|
224
|
+
last[1] = Math.max(last[1], end)
|
|
225
|
+
} else {
|
|
226
|
+
ranges.push([start, end])
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
return ranges.map(([start, end]) => {
|
|
230
|
+
const slice = ops.slice(start, end + 1)
|
|
231
|
+
return {
|
|
232
|
+
beforeStart: beforeLineAt[start]!,
|
|
233
|
+
beforeCount: slice.filter((op) => op.type !== "add").length,
|
|
234
|
+
afterStart: afterLineAt[start]!,
|
|
235
|
+
afterCount: slice.filter((op) => op.type !== "del").length,
|
|
236
|
+
lines: slice.map(
|
|
237
|
+
(op) => `${op.type === "add" ? "+" : op.type === "del" ? "-" : " "}${op.line}`,
|
|
238
|
+
),
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const renderHunk = (hunk: Hunk): readonly string[] => [
|
|
244
|
+
`@@ -${hunk.beforeStart},${hunk.beforeCount} +${hunk.afterStart},${hunk.afterCount} @@`,
|
|
245
|
+
...hunk.lines,
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
/** One file's unified diff, or a summary line when its changed region is too large to inline. */
|
|
249
|
+
const renderFileDiff = (change: Change): string => {
|
|
250
|
+
const before = linesOf(change.before)
|
|
251
|
+
const after = linesOf(change.after)
|
|
252
|
+
const { prefix, suffix } = commonAffixes(before, after)
|
|
253
|
+
const beforeMiddle = before.slice(prefix, before.length - suffix)
|
|
254
|
+
const afterMiddle = after.slice(prefix, after.length - suffix)
|
|
255
|
+
if (beforeMiddle.length > TOO_LARGE_LINES || afterMiddle.length > TOO_LARGE_LINES) {
|
|
256
|
+
return `${change.path}: +${afterMiddle.length}/-${beforeMiddle.length} lines, too large to inline`
|
|
257
|
+
}
|
|
258
|
+
const prefixOps: Op[] = before.slice(0, prefix).map((line) => ({ type: "eq", line }))
|
|
259
|
+
const suffixOps: Op[] = before.slice(before.length - suffix).map((line) => ({ type: "eq", line }))
|
|
260
|
+
const ops = [...prefixOps, ...lcsOps(beforeMiddle, afterMiddle), ...suffixOps]
|
|
261
|
+
const beforePath = change.status === "added" ? "/dev/null" : `a/${change.path}`
|
|
262
|
+
const afterPath = change.status === "deleted" ? "/dev/null" : `b/${change.path}`
|
|
263
|
+
return [`--- ${beforePath}`, `+++ ${afterPath}`, ...buildHunks(ops).flatMap(renderHunk)].join(
|
|
264
|
+
"\n",
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const omissionTrailer = (paths: readonly string[]): string =>
|
|
269
|
+
`${paths.length} file(s) omitted for the judge's byte budget: ${paths.join(", ")}`
|
|
270
|
+
|
|
271
|
+
interface RenderedFile {
|
|
272
|
+
readonly path: string
|
|
273
|
+
readonly text: string
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The byte size of keeping `files`' first `keepCount` entries plus the trailer the rest would need — the trailer counts against the cap too, not just the kept text. */
|
|
277
|
+
const sizeWithTrailer = (files: readonly RenderedFile[], keepCount: number): number => {
|
|
278
|
+
let used = 0
|
|
279
|
+
for (let i = 0; i < keepCount; i++) {
|
|
280
|
+
used += Buffer.byteLength(files[i]!.text, "utf8") + (i > 0 ? 1 : 0)
|
|
281
|
+
}
|
|
282
|
+
const droppedCount = files.length - keepCount
|
|
283
|
+
if (droppedCount === 0) return used
|
|
284
|
+
const trailer = omissionTrailer(files.slice(keepCount).map((f) => f.path))
|
|
285
|
+
return used + (keepCount > 0 ? 1 : 0) + Buffer.byteLength(trailer, "utf8")
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The filtered `changes`, rendered as one unified diff (files in path order)
|
|
290
|
+
* and bounded to `capBytes`: over the cap, whole files are dropped from the
|
|
291
|
+
* end — never bytes off a hunk — and named in a trailer line, so the
|
|
292
|
+
* judge is told what it did not see rather than handed a silently short diff.
|
|
293
|
+
* The trailer itself is sized against the cap along with the files it keeps,
|
|
294
|
+
* so a package with many touched files can never push the total past it.
|
|
295
|
+
*/
|
|
296
|
+
export const packageDiff = (changes: readonly Change[], capBytes: number): string => {
|
|
297
|
+
const files = filterChanges(changes)
|
|
298
|
+
.slice()
|
|
299
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
300
|
+
.map((change) => ({ path: change.path, text: renderFileDiff(change) }))
|
|
301
|
+
let keepCount = files.length
|
|
302
|
+
while (keepCount > 0 && sizeWithTrailer(files, keepCount) > capBytes) keepCount--
|
|
303
|
+
const dropped = files.slice(keepCount).map((f) => f.path)
|
|
304
|
+
const kept = files.slice(0, keepCount).map((f) => f.text)
|
|
305
|
+
return [...kept, ...(dropped.length > 0 ? [omissionTrailer(dropped)] : [])].join("\n")
|
|
306
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
answered,
|
|
3
3
|
changes,
|
|
4
|
+
changesSince,
|
|
4
5
|
glob,
|
|
6
|
+
head,
|
|
5
7
|
judge,
|
|
6
8
|
numeric,
|
|
7
9
|
read,
|
|
@@ -10,31 +12,34 @@ import {
|
|
|
10
12
|
run,
|
|
11
13
|
scope,
|
|
12
14
|
sectionBodies,
|
|
13
|
-
start,
|
|
14
15
|
vars,
|
|
15
16
|
wrote,
|
|
16
17
|
type JudgeQuestion,
|
|
17
18
|
} from "../flows/index.js"
|
|
19
|
+
import { packageDiff } from "./diff.js"
|
|
18
20
|
import { healthy } from "./health.js"
|
|
19
21
|
import { ARCHITECTURE, build, fixSpec, fixSuite, reviewPackage, SPEC_FEEDBACK } from "./steps.js"
|
|
20
22
|
import * as t from "./text.js"
|
|
21
23
|
|
|
22
24
|
const MAX_SECTIONS = 8
|
|
25
|
+
const DIFF_KEY = "diff"
|
|
23
26
|
|
|
24
27
|
const sectionQuestion = (id: string, title: string): JudgeQuestion => ({
|
|
25
28
|
id,
|
|
26
29
|
primitive: "noul",
|
|
27
|
-
instructions: `Is the requirement "${title}" already fully satisfied by the code
|
|
30
|
+
instructions: `Is the requirement "${title}" — the "${id}" evidence — already fully satisfied by the code in the "${DIFF_KEY}" evidence?`,
|
|
28
31
|
criteria:
|
|
29
|
-
"Judge from the
|
|
32
|
+
"Judge from the requirement text and the diff evidence alone. Only answer yes at a probability clearing the threshold below if genuinely confident nothing in this section is missing.",
|
|
30
33
|
})
|
|
31
34
|
|
|
32
35
|
/**
|
|
33
36
|
* Review one freshly built package against its spec: a pre-judge per
|
|
34
37
|
* section, then an agent review of the sections it did not confidently
|
|
35
|
-
* clear.
|
|
38
|
+
* clear. `since` is the commit the package's own build started from — the
|
|
39
|
+
* pre-judge's evidence is a diff over exactly that range, never an earlier
|
|
40
|
+
* package's commits. Resolves `true` when approved.
|
|
36
41
|
*/
|
|
37
|
-
export const specReview = async (pkg: string): Promise<boolean> => {
|
|
42
|
+
export const specReview = async (pkg: string, since: string): Promise<boolean> => {
|
|
38
43
|
const found = sectionBodies(read(pkg) ?? "")
|
|
39
44
|
const titles = found.map((section) => section.title)
|
|
40
45
|
const judged = titles.length > 0 && titles.length <= MAX_SECTIONS
|
|
@@ -45,6 +50,10 @@ export const specReview = async (pkg: string): Promise<boolean> => {
|
|
|
45
50
|
evidence[`section-${i + 1}`] = body
|
|
46
51
|
questions.push(sectionQuestion(`section-${i + 1}`, title))
|
|
47
52
|
})
|
|
53
|
+
// The diff's own even share of the budget: one key per section plus
|
|
54
|
+
// `diff` itself, so this cap equals what `budgeted` gives it below.
|
|
55
|
+
const capBytes = Math.floor(numeric(vars.judgeBudgetBytes, 32768) / (found.length + 1))
|
|
56
|
+
evidence[DIFF_KEY] = packageDiff(changesSince(since), capBytes)
|
|
48
57
|
}
|
|
49
58
|
const { answers, truncated } = await judge("spec.pre", {
|
|
50
59
|
questions,
|
|
@@ -52,11 +61,17 @@ export const specReview = async (pkg: string): Promise<boolean> => {
|
|
|
52
61
|
message: t.packagesItemSpecPreMessage(),
|
|
53
62
|
label: "Judging spec coverage",
|
|
54
63
|
})
|
|
55
|
-
// A section is cleared only by a confident yes on evidence that was not
|
|
64
|
+
// A section is cleared only by a confident yes on evidence that was not
|
|
65
|
+
// cut — its own body, or the diff every question is judged against.
|
|
56
66
|
const clearMinP = numeric(vars.specPreJudge, Infinity)
|
|
57
67
|
const failing = titles.filter((_, i) => {
|
|
58
68
|
const id = `section-${i + 1}`
|
|
59
|
-
return
|
|
69
|
+
return (
|
|
70
|
+
!judged ||
|
|
71
|
+
truncated.includes(id) ||
|
|
72
|
+
truncated.includes(DIFF_KEY) ||
|
|
73
|
+
!answered(answers[id], "yes", clearMinP)
|
|
74
|
+
)
|
|
60
75
|
})
|
|
61
76
|
if (titles.length > 0 && failing.length === 0) return true
|
|
62
77
|
await reviewPackage(pkg, failing)
|
|
@@ -69,10 +84,11 @@ export const specReview = async (pkg: string): Promise<boolean> => {
|
|
|
69
84
|
|
|
70
85
|
/** Build `pkg`, keep the suite green, review it against its spec, and close it out, which removes it. */
|
|
71
86
|
export const packageItem = async (pkg: string): Promise<void> => {
|
|
87
|
+
const since = head()
|
|
72
88
|
await build(pkg)
|
|
73
89
|
for (;;) {
|
|
74
90
|
await healthy(fixSuite)
|
|
75
|
-
if (await specReview(pkg)) break
|
|
91
|
+
if (await specReview(pkg, since)) break
|
|
76
92
|
await fixSpec(pkg)
|
|
77
93
|
}
|
|
78
94
|
// The spent technical plan goes with the first package built from it.
|
|
@@ -22,6 +22,7 @@ export const renderText = <T>(text: () => T, context: TextContext = {}): T => {
|
|
|
22
22
|
read: context.read ?? (() => undefined),
|
|
23
23
|
glob: () => [],
|
|
24
24
|
changes: () => [],
|
|
25
|
+
changesSince: unavailable,
|
|
25
26
|
matches: () => false,
|
|
26
27
|
sections: () => [],
|
|
27
28
|
sectionBodies: () => [],
|