@pmelab/gtd 14.0.1 → 15.0.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/README.md +2 -2
- package/dist/gtd.bundle.mjs +482 -92
- 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/src/workflows/text.ts +7 -5
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: () => [],
|
package/src/workflows/text.ts
CHANGED
|
@@ -621,17 +621,19 @@ review the changes:
|
|
|
621
621
|
- At least one \`## <Chunk Title>\` heading grouping hunks
|
|
622
622
|
semantically (same feature/refactor/fix, across files), each
|
|
623
623
|
with a short explanation of what changed and why, then one
|
|
624
|
-
pointer per hunk (\`./\`-relative path,
|
|
625
|
-
checkboxes are for the human, not you).
|
|
626
|
-
|
|
624
|
+
pointer per hunk (\`./\`-relative path, mandatory \`#start-end\`;
|
|
625
|
+
checkboxes are for the human, not you). A pointer into a NEW file
|
|
626
|
+
gets a range covering only the region the note is about — never
|
|
627
|
+
the whole file. Put the note's opening line right on the
|
|
628
|
+
pointer's line:
|
|
627
629
|
|
|
628
|
-
- [ ] ./path/to/file.ts#42 — what this hunk does
|
|
630
|
+
- [ ] ./path/to/file.ts#42-70 — what this hunk does
|
|
629
631
|
|
|
630
632
|
Continue a longer note below the pointer, indented exactly two spaces
|
|
631
633
|
— never four or more, which reads as a code block and never
|
|
632
634
|
reflows:
|
|
633
635
|
|
|
634
|
-
- [ ] ./path/to/file.ts#42 — what this hunk does
|
|
636
|
+
- [ ] ./path/to/file.ts#42-70 — what this hunk does
|
|
635
637
|
and here is more detail, continued below it
|
|
636
638
|
|
|
637
639
|
A note sitting entirely on the line(s) beneath the pointer is
|