@pmelab/gtd 18.1.0 → 19.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.
@@ -1,306 +0,0 @@
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
- }