@smeltjs/core 0.5.0 → 0.6.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.
Files changed (97) hide show
  1. package/dist/apply.d.ts.map +1 -1
  2. package/dist/apply.js +8 -2
  3. package/dist/apply.js.map +1 -1
  4. package/dist/cli/init.d.ts.map +1 -1
  5. package/dist/cli/init.js +4 -2
  6. package/dist/cli/init.js.map +1 -1
  7. package/dist/cli/report.d.ts +10 -1
  8. package/dist/cli/report.d.ts.map +1 -1
  9. package/dist/cli/report.js +16 -1
  10. package/dist/cli/report.js.map +1 -1
  11. package/dist/cli/subcommands/flags.d.ts +3 -0
  12. package/dist/cli/subcommands/flags.d.ts.map +1 -1
  13. package/dist/cli/subcommands/flags.js +16 -3
  14. package/dist/cli/subcommands/flags.js.map +1 -1
  15. package/dist/cli/subcommands/smelt.d.ts +3 -0
  16. package/dist/cli/subcommands/smelt.d.ts.map +1 -1
  17. package/dist/cli/subcommands/smelt.js +5 -1
  18. package/dist/cli/subcommands/smelt.js.map +1 -1
  19. package/dist/cli/subcommands/stats.d.ts +8 -3
  20. package/dist/cli/subcommands/stats.d.ts.map +1 -1
  21. package/dist/cli/subcommands/stats.js +15 -5
  22. package/dist/cli/subcommands/stats.js.map +1 -1
  23. package/dist/errors.d.ts +10 -0
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +10 -0
  26. package/dist/errors.js.map +1 -1
  27. package/dist/hooks/focus-terms.d.ts +57 -0
  28. package/dist/hooks/focus-terms.d.ts.map +1 -0
  29. package/dist/hooks/focus-terms.js +230 -0
  30. package/dist/hooks/focus-terms.js.map +1 -0
  31. package/dist/hooks/guard-core.d.ts +8 -16
  32. package/dist/hooks/guard-core.d.ts.map +1 -1
  33. package/dist/hooks/guard-core.js +22 -120
  34. package/dist/hooks/guard-core.js.map +1 -1
  35. package/dist/index.d.ts +4 -4
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/ops/index.d.ts +3 -3
  40. package/dist/ops/index.d.ts.map +1 -1
  41. package/dist/ops/index.js +2 -2
  42. package/dist/ops/index.js.map +1 -1
  43. package/dist/ops/verbs.d.ts +57 -1
  44. package/dist/ops/verbs.d.ts.map +1 -1
  45. package/dist/ops/verbs.js +49 -1
  46. package/dist/ops/verbs.js.map +1 -1
  47. package/dist/plan/auto.d.ts +9 -2
  48. package/dist/plan/auto.d.ts.map +1 -1
  49. package/dist/plan/auto.js +14 -2
  50. package/dist/plan/auto.js.map +1 -1
  51. package/dist/plan/diff.d.ts +45 -0
  52. package/dist/plan/diff.d.ts.map +1 -0
  53. package/dist/plan/diff.js +284 -0
  54. package/dist/plan/diff.js.map +1 -0
  55. package/dist/plan/json.d.ts +37 -0
  56. package/dist/plan/json.d.ts.map +1 -0
  57. package/dist/plan/json.js +181 -0
  58. package/dist/plan/json.js.map +1 -0
  59. package/dist/plan/kind.d.ts +28 -0
  60. package/dist/plan/kind.d.ts.map +1 -0
  61. package/dist/plan/kind.js +57 -0
  62. package/dist/plan/kind.js.map +1 -0
  63. package/dist/plan/offsets.d.ts +8 -0
  64. package/dist/plan/offsets.d.ts.map +1 -0
  65. package/dist/plan/offsets.js +19 -0
  66. package/dist/plan/offsets.js.map +1 -0
  67. package/dist/plan/planners.d.ts +15 -5
  68. package/dist/plan/planners.d.ts.map +1 -1
  69. package/dist/plan/planners.js +13 -5
  70. package/dist/plan/planners.js.map +1 -1
  71. package/dist/plan/structural.d.ts.map +1 -1
  72. package/dist/plan/structural.js +106 -14
  73. package/dist/plan/structural.js.map +1 -1
  74. package/dist/retrieve.d.ts +22 -1
  75. package/dist/retrieve.d.ts.map +1 -1
  76. package/dist/retrieve.js +59 -0
  77. package/dist/retrieve.js.map +1 -1
  78. package/dist/smelter.d.ts +4 -0
  79. package/dist/smelter.d.ts.map +1 -1
  80. package/dist/smelter.js +4 -0
  81. package/dist/smelter.js.map +1 -1
  82. package/dist/stats.d.ts +16 -1
  83. package/dist/stats.d.ts.map +1 -1
  84. package/dist/stats.js +29 -0
  85. package/dist/stats.js.map +1 -1
  86. package/dist/store-dir.d.ts +9 -2
  87. package/dist/store-dir.d.ts.map +1 -1
  88. package/dist/store-dir.js +48 -7
  89. package/dist/store-dir.js.map +1 -1
  90. package/dist/store.d.ts +4 -2
  91. package/dist/store.d.ts.map +1 -1
  92. package/dist/store.js +12 -2
  93. package/dist/store.js.map +1 -1
  94. package/dist/types.d.ts +116 -2
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/types.js.map +1 -1
  97. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/plan/diff.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAiB,SAAS,EAAkB,OAAO,EAAE,MAAM,aAAa,CAAC;AAKlG,eAAO,MAAM,eAAe,YAAY,CAAC;AAEzC;;;GAGG;AACH,eAAO,MAAM,kBAAkB,kBAAkB,CAAC;AAClD,eAAO,MAAM,kBAAkB,kBAAkB,CAAC;AAClD,eAAO,MAAM,gBAAgB,gBAAgB,CAAC;AAQ9C,MAAM,WAAW,kBAAkB;IACjC,gEAAgE;IAChE,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;CAClC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,WAAY,YAAW,OAAO;;IACzC,QAAQ,CAAC,EAAE,aAAmB;IAG9B,YAAY,OAAO,GAAE,kBAAuB,EAE3C;IAED,IAAI,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,CAAC,CAE3C;CACF;AA2BD;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,GAAE,kBAAuB,GAAG,WAAW,CA8FxF","sourcesContent":["import { ContentKindError, MissingMarkerPricingError } from '../errors.ts';\nimport type { ElisionPlan, MarkerPricing, PlanInput, PlannedElision, Planner } from '../types.ts';\n\nimport { predictOutputBytes, savingBytes } from './budget.ts';\nimport { probeKind } from './kind.ts';\n\nexport const DIFF_PLANNER_ID = 'diff/v1';\n\n/**\n * The three rules, coarsest first: whole files the focus never touches; hunks inside a\n * file it does; and, inside a hunk it does, the line runs no match sits near.\n */\nexport const FILE_COLLAPSE_RULE = 'file-collapse';\nexport const HUNK_COLLAPSE_RULE = 'hunk-collapse';\nexport const HUNK_WINDOW_RULE = 'hunk-window';\n\n/** Context lines kept either side of a match inside a matched hunk, tried in order. */\nconst WINDOW_LADDER: readonly number[] = [4, 3, 2, 1, 0];\n\n/** Never collapse a run inside a hunk shorter than this. */\nconst MIN_RUN_LINES = 3;\n\nexport interface DiffPlannerOptions {\n /** Focus matching is substring, case-insensitive by default. */\n readonly caseSensitive?: boolean;\n}\n\n/**\n * The diff planner: **files and hunks are the units, not lines.**\n *\n * A unified diff has a structure a line planner cannot see: files, each with a header\n * and hunks. Under a focus, a file none of whose hunks carry a term collapses whole —\n * one marker per run of such files, the outline naming their paths — and inside a file\n * that does match, the hunks that do not collapse as a run while the header survives\n * verbatim — and inside a hunk that does match, the line runs no match sits near\n * collapse as a window (the lexical planner's move, confined to the hunk, with the same\n * context ladder under budget pressure), so the planner never keeps more of a hunk than\n * a line planner would. With no focus every file header is kept and each file's hunks\n * collapse to one marker, so the survivor is the diff's table of contents. Measured on\n * the bench's real diff (`git-diff`): every hunk mentioned the focus term, and without\n * the window rule this planner cut nothing where lexical cut to 1516 B.\n *\n * Refuses text without a unified-diff header shape ({@link ContentKindError}).\n */\nexport class DiffPlanner implements Planner {\n readonly id = DIFF_PLANNER_ID;\n readonly #options: DiffPlannerOptions;\n\n constructor(options: DiffPlannerOptions = {}) {\n this.#options = options;\n }\n\n plan(input: PlanInput): Promise<ElisionPlan> {\n return Promise.resolve(planDiff(input, this.#options));\n }\n}\n\ninterface Line {\n readonly start: number;\n /** One past the last byte, excluding the newline. */\n readonly end: number;\n readonly text: string;\n}\n\ninterface Hunk {\n readonly start: number;\n readonly end: number;\n /** The `@@ … @@` header, without any trailing function context. */\n readonly header: string;\n readonly text: string;\n /** The body lines after the header, for the window rule. */\n readonly lines: readonly Line[];\n}\n\ninterface FileDiff {\n readonly start: number;\n readonly end: number;\n readonly path: string;\n readonly headerText: string;\n readonly hunks: readonly Hunk[];\n}\n\n/**\n * The synchronous core. Deterministic; every candidate priced through the input's\n * {@link MarkerPricing}. Byte offsets come straight off the UTF-8 line split, so no\n * conversion is needed.\n *\n * @throws {ContentKindError} when the text carries no unified-diff header.\n */\nexport function planDiff(input: PlanInput, options: DiffPlannerOptions = {}): ElisionPlan {\n const pricing = requirePricing(input);\n if (probeKind(input.text) !== 'diff') {\n throw new ContentKindError(\n `smelt: the diff planner was asked to plan text with no unified-diff header ` +\n `(a \"diff --git\" line, or \"--- \"/\"+++ \" lines followed by a hunk). It refuses ` +\n `rather than approximating — output labelled ${DIFF_PLANNER_ID} that was ` +\n `really line windows would be undetectable from outside. Use \"lexical\", or ` +\n `\"auto\" to pick by content.`,\n );\n }\n const files = parseFiles(splitLines(input.text));\n const caseSensitive = options.caseSensitive ?? false;\n const focus = (input.focus ?? []).filter((term) => term.length > 0);\n const needles = caseSensitive ? focus : focus.map((term) => term.toLowerCase());\n const matches = (text: string): boolean => {\n const haystack = caseSensitive ? text : text.toLowerCase();\n return needles.some((needle) => haystack.includes(needle));\n };\n\n /** The file and hunk collapses — the same at every rung of the window ladder. */\n const fixed: PlannedElision[] = [];\n /** The hunks the focus reached, where the window rule runs. */\n const matched: { readonly file: FileDiff; readonly hunk: Hunk }[] = [];\n const push = (candidate: PlannedElision): void => {\n if (savingBytes(candidate, pricing) > 0) fixed.push(candidate);\n };\n\n const collapseFiles = (run: readonly FileDiff[]): void => {\n if (run.length === 0) return;\n const hunks = run.reduce((n, file) => n + file.hunks.length, 0);\n push({\n range: { start: run[0]!.start, end: run[run.length - 1]!.end },\n reason: {\n rule: FILE_COLLAPSE_RULE,\n explanation:\n `collapsed ${String(run.length)} ${run.length === 1 ? 'file' : 'files'} ` +\n `(${String(hunks)} ${hunks === 1 ? 'hunk' : 'hunks'})`,\n },\n names: run.map((file) => file.path),\n });\n };\n\n const collapseHunks = (file: FileDiff, run: readonly Hunk[]): void => {\n if (run.length === 0) return;\n push({\n range: { start: run[0]!.start, end: run[run.length - 1]!.end },\n reason: {\n rule: HUNK_COLLAPSE_RULE,\n explanation: `collapsed ${String(run.length)} ${run.length === 1 ? 'hunk' : 'hunks'} of ${file.path}`,\n },\n names: run.map((hunk) => hunk.header),\n });\n };\n\n let fileRun: FileDiff[] = [];\n for (const file of files) {\n const fileKept =\n needles.length === 0 ||\n matches(file.headerText) ||\n file.hunks.some((hunk) => matches(hunk.text));\n if (!fileKept) {\n fileRun.push(file);\n continue;\n }\n collapseFiles(fileRun);\n fileRun = [];\n let hunkRun: Hunk[] = [];\n for (const hunk of file.hunks) {\n if (needles.length > 0 && matches(hunk.text)) {\n collapseHunks(file, hunkRun);\n hunkRun = [];\n matched.push({ file, hunk });\n } else {\n hunkRun.push(hunk);\n }\n }\n collapseHunks(file, hunkRun);\n }\n collapseFiles(fileRun);\n\n // The window rule inside every matched hunk, tried with less context at each rung\n // until the plan fits — the lexical ladder, confined to hunks. The first rung that\n // fits wins; if none does, the tightest is returned over budget, as it came back.\n const inputBytes = Buffer.byteLength(input.text, 'utf8');\n const attempts = WINDOW_LADDER.map((context) => [\n ...fixed,\n ...matched.flatMap(({ file, hunk }) => windowsIn(file, hunk, context, matches, pricing)),\n ]);\n const elisions =\n attempts.find((plan) => predictOutputBytes(inputBytes, plan, pricing) <= input.budgetBytes) ??\n attempts[attempts.length - 1]!;\n\n return { planner: DIFF_PLANNER_ID, language: input.language, elisions };\n}\n\n/**\n * Inside one matched hunk: keep every line within `context` of a matching line, and\n * collapse each run of the rest that is at least {@link MIN_RUN_LINES} long and pays\n * for its marker. The header line is never part of a run — it is what makes the\n * survivor still read as a hunk.\n */\nfunction windowsIn(\n file: FileDiff,\n hunk: Hunk,\n context: number,\n matches: (text: string) => boolean,\n pricing: MarkerPricing,\n): readonly PlannedElision[] {\n const lines = hunk.lines;\n const keep: boolean[] = lines.map(() => false);\n for (let i = 0; i < lines.length; i += 1) {\n if (!matches(lines[i]!.text)) continue;\n for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j += 1) {\n keep[j] = true;\n }\n }\n const out: PlannedElision[] = [];\n let runStart = -1;\n const flush = (endExclusive: number): void => {\n if (runStart < 0) return;\n const count = endExclusive - runStart;\n const range = { start: lines[runStart]!.start, end: lines[endExclusive - 1]!.end };\n runStart = -1;\n if (count < MIN_RUN_LINES) return;\n const candidate: PlannedElision = {\n range,\n reason: {\n rule: HUNK_WINDOW_RULE,\n explanation: `collapsed ${String(count)} lines of a hunk of ${file.path}`,\n },\n };\n if (savingBytes(candidate, pricing) > 0) out.push(candidate);\n };\n for (let i = 0; i < lines.length; i += 1) {\n if (keep[i] === true) flush(i);\n else if (runStart < 0) runStart = i;\n }\n flush(lines.length);\n return out;\n}\n\nfunction requirePricing(input: PlanInput): MarkerPricing {\n const pricing: MarkerPricing | undefined = input.pricing;\n if (pricing === undefined) throw new MissingMarkerPricingError(DIFF_PLANNER_ID);\n return pricing;\n}\n\nfunction splitLines(text: string): readonly Line[] {\n const lines: Line[] = [];\n let start = 0;\n for (const content of text.split('\\n')) {\n const end = start + Buffer.byteLength(content, 'utf8');\n lines.push({ start, end, text: content });\n start = end + 1;\n }\n return lines;\n}\n\n/** True where a file starts: a `diff --git` line, or a `---` line followed by `+++`. */\nfunction startsFile(lines: readonly Line[], i: number): boolean {\n const line = lines[i]!.text;\n if (line.startsWith('diff --git ')) return true;\n return (\n line.startsWith('--- ') &&\n lines[i + 1]?.text.startsWith('+++ ') === true &&\n !(i > 0 && lines[i - 1]!.text.startsWith('diff --git '))\n );\n}\n\nfunction parseFiles(lines: readonly Line[]): readonly FileDiff[] {\n const starts: number[] = [];\n for (let i = 0; i < lines.length; i += 1) {\n if (startsFile(lines, i)) {\n // A `---` line directly under a `diff --git` line belongs to that file's header.\n if (lines[i]!.text.startsWith('--- ') && starts.at(-1) !== undefined) {\n const previous = starts.at(-1)!;\n const between = lines.slice(previous, i).map((line) => line.text);\n if (\n between[0]?.startsWith('diff --git ') === true &&\n !between.some((l) => l.startsWith('@@ '))\n )\n continue;\n }\n starts.push(i);\n }\n }\n return starts.map((from, index) => {\n const to = index + 1 < starts.length ? starts[index + 1]! : lastContentLine(lines) + 1;\n const fileLines = lines.slice(from, to);\n const firstHunk = fileLines.findIndex((line) => line.text.startsWith('@@ '));\n const headerLines = firstHunk === -1 ? fileLines : fileLines.slice(0, firstHunk);\n const hunks: Hunk[] = [];\n if (firstHunk !== -1) {\n let hunkStart = firstHunk;\n for (let i = firstHunk + 1; i <= fileLines.length; i += 1) {\n if (i === fileLines.length || fileLines[i]!.text.startsWith('@@ ')) {\n const body = fileLines.slice(hunkStart, i);\n hunks.push({\n start: body[0]!.start,\n end: body[body.length - 1]!.end,\n header: hunkHeader(body[0]!.text),\n text: body.map((line) => line.text).join('\\n'),\n lines: body.slice(1),\n });\n hunkStart = i;\n }\n }\n }\n return {\n start: fileLines[0]!.start,\n end: fileLines[fileLines.length - 1]!.end,\n path: pathOf(headerLines.map((line) => line.text)),\n headerText: headerLines.map((line) => line.text).join('\\n'),\n hunks,\n };\n });\n}\n\n/** The index of the last line with content, so a trailing newline is never claimed. */\nfunction lastContentLine(lines: readonly Line[]): number {\n let i = lines.length - 1;\n while (i > 0 && lines[i]!.text === '') i -= 1;\n return i;\n}\n\nfunction hunkHeader(line: string): string {\n const close = line.indexOf('@@', 2);\n return close === -1 ? line : line.slice(0, close + 2);\n}\n\n/** The path a file header names: the `+++` side first, else the `diff --git` b-side. */\nfunction pathOf(header: readonly string[]): string {\n const plus = header.find((line) => line.startsWith('+++ '));\n if (plus !== undefined && plus !== '+++ /dev/null') return stripPrefix(plus.slice(4));\n const minus = header.find((line) => line.startsWith('--- '));\n if (minus !== undefined && minus !== '--- /dev/null') return stripPrefix(minus.slice(4));\n const git = header.find((line) => line.startsWith('diff --git '));\n if (git !== undefined) {\n const parts = git.slice('diff --git '.length).split(' b/');\n return parts[parts.length - 1] ?? git;\n }\n return '<unknown>';\n}\n\nfunction stripPrefix(path: string): string {\n const trimmed = path.split('\\t')[0]!;\n return trimmed.startsWith('a/') || trimmed.startsWith('b/') ? trimmed.slice(2) : trimmed;\n}\n"]}
@@ -0,0 +1,284 @@
1
+ import { ContentKindError, MissingMarkerPricingError } from '../errors.js';
2
+ import { predictOutputBytes, savingBytes } from './budget.js';
3
+ import { probeKind } from './kind.js';
4
+ export const DIFF_PLANNER_ID = 'diff/v1';
5
+ /**
6
+ * The three rules, coarsest first: whole files the focus never touches; hunks inside a
7
+ * file it does; and, inside a hunk it does, the line runs no match sits near.
8
+ */
9
+ export const FILE_COLLAPSE_RULE = 'file-collapse';
10
+ export const HUNK_COLLAPSE_RULE = 'hunk-collapse';
11
+ export const HUNK_WINDOW_RULE = 'hunk-window';
12
+ /** Context lines kept either side of a match inside a matched hunk, tried in order. */
13
+ const WINDOW_LADDER = [4, 3, 2, 1, 0];
14
+ /** Never collapse a run inside a hunk shorter than this. */
15
+ const MIN_RUN_LINES = 3;
16
+ /**
17
+ * The diff planner: **files and hunks are the units, not lines.**
18
+ *
19
+ * A unified diff has a structure a line planner cannot see: files, each with a header
20
+ * and hunks. Under a focus, a file none of whose hunks carry a term collapses whole —
21
+ * one marker per run of such files, the outline naming their paths — and inside a file
22
+ * that does match, the hunks that do not collapse as a run while the header survives
23
+ * verbatim — and inside a hunk that does match, the line runs no match sits near
24
+ * collapse as a window (the lexical planner's move, confined to the hunk, with the same
25
+ * context ladder under budget pressure), so the planner never keeps more of a hunk than
26
+ * a line planner would. With no focus every file header is kept and each file's hunks
27
+ * collapse to one marker, so the survivor is the diff's table of contents. Measured on
28
+ * the bench's real diff (`git-diff`): every hunk mentioned the focus term, and without
29
+ * the window rule this planner cut nothing where lexical cut to 1516 B.
30
+ *
31
+ * Refuses text without a unified-diff header shape ({@link ContentKindError}).
32
+ */
33
+ export class DiffPlanner {
34
+ id = DIFF_PLANNER_ID;
35
+ #options;
36
+ constructor(options = {}) {
37
+ this.#options = options;
38
+ }
39
+ plan(input) {
40
+ return Promise.resolve(planDiff(input, this.#options));
41
+ }
42
+ }
43
+ /**
44
+ * The synchronous core. Deterministic; every candidate priced through the input's
45
+ * {@link MarkerPricing}. Byte offsets come straight off the UTF-8 line split, so no
46
+ * conversion is needed.
47
+ *
48
+ * @throws {ContentKindError} when the text carries no unified-diff header.
49
+ */
50
+ export function planDiff(input, options = {}) {
51
+ const pricing = requirePricing(input);
52
+ if (probeKind(input.text) !== 'diff') {
53
+ throw new ContentKindError(`smelt: the diff planner was asked to plan text with no unified-diff header ` +
54
+ `(a "diff --git" line, or "--- "/"+++ " lines followed by a hunk). It refuses ` +
55
+ `rather than approximating — output labelled ${DIFF_PLANNER_ID} that was ` +
56
+ `really line windows would be undetectable from outside. Use "lexical", or ` +
57
+ `"auto" to pick by content.`);
58
+ }
59
+ const files = parseFiles(splitLines(input.text));
60
+ const caseSensitive = options.caseSensitive ?? false;
61
+ const focus = (input.focus ?? []).filter((term) => term.length > 0);
62
+ const needles = caseSensitive ? focus : focus.map((term) => term.toLowerCase());
63
+ const matches = (text) => {
64
+ const haystack = caseSensitive ? text : text.toLowerCase();
65
+ return needles.some((needle) => haystack.includes(needle));
66
+ };
67
+ /** The file and hunk collapses — the same at every rung of the window ladder. */
68
+ const fixed = [];
69
+ /** The hunks the focus reached, where the window rule runs. */
70
+ const matched = [];
71
+ const push = (candidate) => {
72
+ if (savingBytes(candidate, pricing) > 0)
73
+ fixed.push(candidate);
74
+ };
75
+ const collapseFiles = (run) => {
76
+ if (run.length === 0)
77
+ return;
78
+ const hunks = run.reduce((n, file) => n + file.hunks.length, 0);
79
+ push({
80
+ range: { start: run[0].start, end: run[run.length - 1].end },
81
+ reason: {
82
+ rule: FILE_COLLAPSE_RULE,
83
+ explanation: `collapsed ${String(run.length)} ${run.length === 1 ? 'file' : 'files'} ` +
84
+ `(${String(hunks)} ${hunks === 1 ? 'hunk' : 'hunks'})`,
85
+ },
86
+ names: run.map((file) => file.path),
87
+ });
88
+ };
89
+ const collapseHunks = (file, run) => {
90
+ if (run.length === 0)
91
+ return;
92
+ push({
93
+ range: { start: run[0].start, end: run[run.length - 1].end },
94
+ reason: {
95
+ rule: HUNK_COLLAPSE_RULE,
96
+ explanation: `collapsed ${String(run.length)} ${run.length === 1 ? 'hunk' : 'hunks'} of ${file.path}`,
97
+ },
98
+ names: run.map((hunk) => hunk.header),
99
+ });
100
+ };
101
+ let fileRun = [];
102
+ for (const file of files) {
103
+ const fileKept = needles.length === 0 ||
104
+ matches(file.headerText) ||
105
+ file.hunks.some((hunk) => matches(hunk.text));
106
+ if (!fileKept) {
107
+ fileRun.push(file);
108
+ continue;
109
+ }
110
+ collapseFiles(fileRun);
111
+ fileRun = [];
112
+ let hunkRun = [];
113
+ for (const hunk of file.hunks) {
114
+ if (needles.length > 0 && matches(hunk.text)) {
115
+ collapseHunks(file, hunkRun);
116
+ hunkRun = [];
117
+ matched.push({ file, hunk });
118
+ }
119
+ else {
120
+ hunkRun.push(hunk);
121
+ }
122
+ }
123
+ collapseHunks(file, hunkRun);
124
+ }
125
+ collapseFiles(fileRun);
126
+ // The window rule inside every matched hunk, tried with less context at each rung
127
+ // until the plan fits — the lexical ladder, confined to hunks. The first rung that
128
+ // fits wins; if none does, the tightest is returned over budget, as it came back.
129
+ const inputBytes = Buffer.byteLength(input.text, 'utf8');
130
+ const attempts = WINDOW_LADDER.map((context) => [
131
+ ...fixed,
132
+ ...matched.flatMap(({ file, hunk }) => windowsIn(file, hunk, context, matches, pricing)),
133
+ ]);
134
+ const elisions = attempts.find((plan) => predictOutputBytes(inputBytes, plan, pricing) <= input.budgetBytes) ??
135
+ attempts[attempts.length - 1];
136
+ return { planner: DIFF_PLANNER_ID, language: input.language, elisions };
137
+ }
138
+ /**
139
+ * Inside one matched hunk: keep every line within `context` of a matching line, and
140
+ * collapse each run of the rest that is at least {@link MIN_RUN_LINES} long and pays
141
+ * for its marker. The header line is never part of a run — it is what makes the
142
+ * survivor still read as a hunk.
143
+ */
144
+ function windowsIn(file, hunk, context, matches, pricing) {
145
+ const lines = hunk.lines;
146
+ const keep = lines.map(() => false);
147
+ for (let i = 0; i < lines.length; i += 1) {
148
+ if (!matches(lines[i].text))
149
+ continue;
150
+ for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j += 1) {
151
+ keep[j] = true;
152
+ }
153
+ }
154
+ const out = [];
155
+ let runStart = -1;
156
+ const flush = (endExclusive) => {
157
+ if (runStart < 0)
158
+ return;
159
+ const count = endExclusive - runStart;
160
+ const range = { start: lines[runStart].start, end: lines[endExclusive - 1].end };
161
+ runStart = -1;
162
+ if (count < MIN_RUN_LINES)
163
+ return;
164
+ const candidate = {
165
+ range,
166
+ reason: {
167
+ rule: HUNK_WINDOW_RULE,
168
+ explanation: `collapsed ${String(count)} lines of a hunk of ${file.path}`,
169
+ },
170
+ };
171
+ if (savingBytes(candidate, pricing) > 0)
172
+ out.push(candidate);
173
+ };
174
+ for (let i = 0; i < lines.length; i += 1) {
175
+ if (keep[i] === true)
176
+ flush(i);
177
+ else if (runStart < 0)
178
+ runStart = i;
179
+ }
180
+ flush(lines.length);
181
+ return out;
182
+ }
183
+ function requirePricing(input) {
184
+ const pricing = input.pricing;
185
+ if (pricing === undefined)
186
+ throw new MissingMarkerPricingError(DIFF_PLANNER_ID);
187
+ return pricing;
188
+ }
189
+ function splitLines(text) {
190
+ const lines = [];
191
+ let start = 0;
192
+ for (const content of text.split('\n')) {
193
+ const end = start + Buffer.byteLength(content, 'utf8');
194
+ lines.push({ start, end, text: content });
195
+ start = end + 1;
196
+ }
197
+ return lines;
198
+ }
199
+ /** True where a file starts: a `diff --git` line, or a `---` line followed by `+++`. */
200
+ function startsFile(lines, i) {
201
+ const line = lines[i].text;
202
+ if (line.startsWith('diff --git '))
203
+ return true;
204
+ return (line.startsWith('--- ') &&
205
+ lines[i + 1]?.text.startsWith('+++ ') === true &&
206
+ !(i > 0 && lines[i - 1].text.startsWith('diff --git ')));
207
+ }
208
+ function parseFiles(lines) {
209
+ const starts = [];
210
+ for (let i = 0; i < lines.length; i += 1) {
211
+ if (startsFile(lines, i)) {
212
+ // A `---` line directly under a `diff --git` line belongs to that file's header.
213
+ if (lines[i].text.startsWith('--- ') && starts.at(-1) !== undefined) {
214
+ const previous = starts.at(-1);
215
+ const between = lines.slice(previous, i).map((line) => line.text);
216
+ if (between[0]?.startsWith('diff --git ') === true &&
217
+ !between.some((l) => l.startsWith('@@ ')))
218
+ continue;
219
+ }
220
+ starts.push(i);
221
+ }
222
+ }
223
+ return starts.map((from, index) => {
224
+ const to = index + 1 < starts.length ? starts[index + 1] : lastContentLine(lines) + 1;
225
+ const fileLines = lines.slice(from, to);
226
+ const firstHunk = fileLines.findIndex((line) => line.text.startsWith('@@ '));
227
+ const headerLines = firstHunk === -1 ? fileLines : fileLines.slice(0, firstHunk);
228
+ const hunks = [];
229
+ if (firstHunk !== -1) {
230
+ let hunkStart = firstHunk;
231
+ for (let i = firstHunk + 1; i <= fileLines.length; i += 1) {
232
+ if (i === fileLines.length || fileLines[i].text.startsWith('@@ ')) {
233
+ const body = fileLines.slice(hunkStart, i);
234
+ hunks.push({
235
+ start: body[0].start,
236
+ end: body[body.length - 1].end,
237
+ header: hunkHeader(body[0].text),
238
+ text: body.map((line) => line.text).join('\n'),
239
+ lines: body.slice(1),
240
+ });
241
+ hunkStart = i;
242
+ }
243
+ }
244
+ }
245
+ return {
246
+ start: fileLines[0].start,
247
+ end: fileLines[fileLines.length - 1].end,
248
+ path: pathOf(headerLines.map((line) => line.text)),
249
+ headerText: headerLines.map((line) => line.text).join('\n'),
250
+ hunks,
251
+ };
252
+ });
253
+ }
254
+ /** The index of the last line with content, so a trailing newline is never claimed. */
255
+ function lastContentLine(lines) {
256
+ let i = lines.length - 1;
257
+ while (i > 0 && lines[i].text === '')
258
+ i -= 1;
259
+ return i;
260
+ }
261
+ function hunkHeader(line) {
262
+ const close = line.indexOf('@@', 2);
263
+ return close === -1 ? line : line.slice(0, close + 2);
264
+ }
265
+ /** The path a file header names: the `+++` side first, else the `diff --git` b-side. */
266
+ function pathOf(header) {
267
+ const plus = header.find((line) => line.startsWith('+++ '));
268
+ if (plus !== undefined && plus !== '+++ /dev/null')
269
+ return stripPrefix(plus.slice(4));
270
+ const minus = header.find((line) => line.startsWith('--- '));
271
+ if (minus !== undefined && minus !== '--- /dev/null')
272
+ return stripPrefix(minus.slice(4));
273
+ const git = header.find((line) => line.startsWith('diff --git '));
274
+ if (git !== undefined) {
275
+ const parts = git.slice('diff --git '.length).split(' b/');
276
+ return parts[parts.length - 1] ?? git;
277
+ }
278
+ return '<unknown>';
279
+ }
280
+ function stripPrefix(path) {
281
+ const trimmed = path.split('\t')[0];
282
+ return trimmed.startsWith('a/') || trimmed.startsWith('b/') ? trimmed.slice(2) : trimmed;
283
+ }
284
+ //# sourceMappingURL=diff.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff.js","sourceRoot":"","sources":["../../src/plan/diff.ts"],"names":[],"mappings":"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","sourcesContent":["import { ContentKindError, MissingMarkerPricingError } from '../errors.ts';\nimport type { ElisionPlan, MarkerPricing, PlanInput, PlannedElision, Planner } from '../types.ts';\n\nimport { predictOutputBytes, savingBytes } from './budget.ts';\nimport { probeKind } from './kind.ts';\n\nexport const DIFF_PLANNER_ID = 'diff/v1';\n\n/**\n * The three rules, coarsest first: whole files the focus never touches; hunks inside a\n * file it does; and, inside a hunk it does, the line runs no match sits near.\n */\nexport const FILE_COLLAPSE_RULE = 'file-collapse';\nexport const HUNK_COLLAPSE_RULE = 'hunk-collapse';\nexport const HUNK_WINDOW_RULE = 'hunk-window';\n\n/** Context lines kept either side of a match inside a matched hunk, tried in order. */\nconst WINDOW_LADDER: readonly number[] = [4, 3, 2, 1, 0];\n\n/** Never collapse a run inside a hunk shorter than this. */\nconst MIN_RUN_LINES = 3;\n\nexport interface DiffPlannerOptions {\n /** Focus matching is substring, case-insensitive by default. */\n readonly caseSensitive?: boolean;\n}\n\n/**\n * The diff planner: **files and hunks are the units, not lines.**\n *\n * A unified diff has a structure a line planner cannot see: files, each with a header\n * and hunks. Under a focus, a file none of whose hunks carry a term collapses whole —\n * one marker per run of such files, the outline naming their paths — and inside a file\n * that does match, the hunks that do not collapse as a run while the header survives\n * verbatim — and inside a hunk that does match, the line runs no match sits near\n * collapse as a window (the lexical planner's move, confined to the hunk, with the same\n * context ladder under budget pressure), so the planner never keeps more of a hunk than\n * a line planner would. With no focus every file header is kept and each file's hunks\n * collapse to one marker, so the survivor is the diff's table of contents. Measured on\n * the bench's real diff (`git-diff`): every hunk mentioned the focus term, and without\n * the window rule this planner cut nothing where lexical cut to 1516 B.\n *\n * Refuses text without a unified-diff header shape ({@link ContentKindError}).\n */\nexport class DiffPlanner implements Planner {\n readonly id = DIFF_PLANNER_ID;\n readonly #options: DiffPlannerOptions;\n\n constructor(options: DiffPlannerOptions = {}) {\n this.#options = options;\n }\n\n plan(input: PlanInput): Promise<ElisionPlan> {\n return Promise.resolve(planDiff(input, this.#options));\n }\n}\n\ninterface Line {\n readonly start: number;\n /** One past the last byte, excluding the newline. */\n readonly end: number;\n readonly text: string;\n}\n\ninterface Hunk {\n readonly start: number;\n readonly end: number;\n /** The `@@ … @@` header, without any trailing function context. */\n readonly header: string;\n readonly text: string;\n /** The body lines after the header, for the window rule. */\n readonly lines: readonly Line[];\n}\n\ninterface FileDiff {\n readonly start: number;\n readonly end: number;\n readonly path: string;\n readonly headerText: string;\n readonly hunks: readonly Hunk[];\n}\n\n/**\n * The synchronous core. Deterministic; every candidate priced through the input's\n * {@link MarkerPricing}. Byte offsets come straight off the UTF-8 line split, so no\n * conversion is needed.\n *\n * @throws {ContentKindError} when the text carries no unified-diff header.\n */\nexport function planDiff(input: PlanInput, options: DiffPlannerOptions = {}): ElisionPlan {\n const pricing = requirePricing(input);\n if (probeKind(input.text) !== 'diff') {\n throw new ContentKindError(\n `smelt: the diff planner was asked to plan text with no unified-diff header ` +\n `(a \"diff --git\" line, or \"--- \"/\"+++ \" lines followed by a hunk). It refuses ` +\n `rather than approximating — output labelled ${DIFF_PLANNER_ID} that was ` +\n `really line windows would be undetectable from outside. Use \"lexical\", or ` +\n `\"auto\" to pick by content.`,\n );\n }\n const files = parseFiles(splitLines(input.text));\n const caseSensitive = options.caseSensitive ?? false;\n const focus = (input.focus ?? []).filter((term) => term.length > 0);\n const needles = caseSensitive ? focus : focus.map((term) => term.toLowerCase());\n const matches = (text: string): boolean => {\n const haystack = caseSensitive ? text : text.toLowerCase();\n return needles.some((needle) => haystack.includes(needle));\n };\n\n /** The file and hunk collapses — the same at every rung of the window ladder. */\n const fixed: PlannedElision[] = [];\n /** The hunks the focus reached, where the window rule runs. */\n const matched: { readonly file: FileDiff; readonly hunk: Hunk }[] = [];\n const push = (candidate: PlannedElision): void => {\n if (savingBytes(candidate, pricing) > 0) fixed.push(candidate);\n };\n\n const collapseFiles = (run: readonly FileDiff[]): void => {\n if (run.length === 0) return;\n const hunks = run.reduce((n, file) => n + file.hunks.length, 0);\n push({\n range: { start: run[0]!.start, end: run[run.length - 1]!.end },\n reason: {\n rule: FILE_COLLAPSE_RULE,\n explanation:\n `collapsed ${String(run.length)} ${run.length === 1 ? 'file' : 'files'} ` +\n `(${String(hunks)} ${hunks === 1 ? 'hunk' : 'hunks'})`,\n },\n names: run.map((file) => file.path),\n });\n };\n\n const collapseHunks = (file: FileDiff, run: readonly Hunk[]): void => {\n if (run.length === 0) return;\n push({\n range: { start: run[0]!.start, end: run[run.length - 1]!.end },\n reason: {\n rule: HUNK_COLLAPSE_RULE,\n explanation: `collapsed ${String(run.length)} ${run.length === 1 ? 'hunk' : 'hunks'} of ${file.path}`,\n },\n names: run.map((hunk) => hunk.header),\n });\n };\n\n let fileRun: FileDiff[] = [];\n for (const file of files) {\n const fileKept =\n needles.length === 0 ||\n matches(file.headerText) ||\n file.hunks.some((hunk) => matches(hunk.text));\n if (!fileKept) {\n fileRun.push(file);\n continue;\n }\n collapseFiles(fileRun);\n fileRun = [];\n let hunkRun: Hunk[] = [];\n for (const hunk of file.hunks) {\n if (needles.length > 0 && matches(hunk.text)) {\n collapseHunks(file, hunkRun);\n hunkRun = [];\n matched.push({ file, hunk });\n } else {\n hunkRun.push(hunk);\n }\n }\n collapseHunks(file, hunkRun);\n }\n collapseFiles(fileRun);\n\n // The window rule inside every matched hunk, tried with less context at each rung\n // until the plan fits — the lexical ladder, confined to hunks. The first rung that\n // fits wins; if none does, the tightest is returned over budget, as it came back.\n const inputBytes = Buffer.byteLength(input.text, 'utf8');\n const attempts = WINDOW_LADDER.map((context) => [\n ...fixed,\n ...matched.flatMap(({ file, hunk }) => windowsIn(file, hunk, context, matches, pricing)),\n ]);\n const elisions =\n attempts.find((plan) => predictOutputBytes(inputBytes, plan, pricing) <= input.budgetBytes) ??\n attempts[attempts.length - 1]!;\n\n return { planner: DIFF_PLANNER_ID, language: input.language, elisions };\n}\n\n/**\n * Inside one matched hunk: keep every line within `context` of a matching line, and\n * collapse each run of the rest that is at least {@link MIN_RUN_LINES} long and pays\n * for its marker. The header line is never part of a run — it is what makes the\n * survivor still read as a hunk.\n */\nfunction windowsIn(\n file: FileDiff,\n hunk: Hunk,\n context: number,\n matches: (text: string) => boolean,\n pricing: MarkerPricing,\n): readonly PlannedElision[] {\n const lines = hunk.lines;\n const keep: boolean[] = lines.map(() => false);\n for (let i = 0; i < lines.length; i += 1) {\n if (!matches(lines[i]!.text)) continue;\n for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j += 1) {\n keep[j] = true;\n }\n }\n const out: PlannedElision[] = [];\n let runStart = -1;\n const flush = (endExclusive: number): void => {\n if (runStart < 0) return;\n const count = endExclusive - runStart;\n const range = { start: lines[runStart]!.start, end: lines[endExclusive - 1]!.end };\n runStart = -1;\n if (count < MIN_RUN_LINES) return;\n const candidate: PlannedElision = {\n range,\n reason: {\n rule: HUNK_WINDOW_RULE,\n explanation: `collapsed ${String(count)} lines of a hunk of ${file.path}`,\n },\n };\n if (savingBytes(candidate, pricing) > 0) out.push(candidate);\n };\n for (let i = 0; i < lines.length; i += 1) {\n if (keep[i] === true) flush(i);\n else if (runStart < 0) runStart = i;\n }\n flush(lines.length);\n return out;\n}\n\nfunction requirePricing(input: PlanInput): MarkerPricing {\n const pricing: MarkerPricing | undefined = input.pricing;\n if (pricing === undefined) throw new MissingMarkerPricingError(DIFF_PLANNER_ID);\n return pricing;\n}\n\nfunction splitLines(text: string): readonly Line[] {\n const lines: Line[] = [];\n let start = 0;\n for (const content of text.split('\\n')) {\n const end = start + Buffer.byteLength(content, 'utf8');\n lines.push({ start, end, text: content });\n start = end + 1;\n }\n return lines;\n}\n\n/** True where a file starts: a `diff --git` line, or a `---` line followed by `+++`. */\nfunction startsFile(lines: readonly Line[], i: number): boolean {\n const line = lines[i]!.text;\n if (line.startsWith('diff --git ')) return true;\n return (\n line.startsWith('--- ') &&\n lines[i + 1]?.text.startsWith('+++ ') === true &&\n !(i > 0 && lines[i - 1]!.text.startsWith('diff --git '))\n );\n}\n\nfunction parseFiles(lines: readonly Line[]): readonly FileDiff[] {\n const starts: number[] = [];\n for (let i = 0; i < lines.length; i += 1) {\n if (startsFile(lines, i)) {\n // A `---` line directly under a `diff --git` line belongs to that file's header.\n if (lines[i]!.text.startsWith('--- ') && starts.at(-1) !== undefined) {\n const previous = starts.at(-1)!;\n const between = lines.slice(previous, i).map((line) => line.text);\n if (\n between[0]?.startsWith('diff --git ') === true &&\n !between.some((l) => l.startsWith('@@ '))\n )\n continue;\n }\n starts.push(i);\n }\n }\n return starts.map((from, index) => {\n const to = index + 1 < starts.length ? starts[index + 1]! : lastContentLine(lines) + 1;\n const fileLines = lines.slice(from, to);\n const firstHunk = fileLines.findIndex((line) => line.text.startsWith('@@ '));\n const headerLines = firstHunk === -1 ? fileLines : fileLines.slice(0, firstHunk);\n const hunks: Hunk[] = [];\n if (firstHunk !== -1) {\n let hunkStart = firstHunk;\n for (let i = firstHunk + 1; i <= fileLines.length; i += 1) {\n if (i === fileLines.length || fileLines[i]!.text.startsWith('@@ ')) {\n const body = fileLines.slice(hunkStart, i);\n hunks.push({\n start: body[0]!.start,\n end: body[body.length - 1]!.end,\n header: hunkHeader(body[0]!.text),\n text: body.map((line) => line.text).join('\\n'),\n lines: body.slice(1),\n });\n hunkStart = i;\n }\n }\n }\n return {\n start: fileLines[0]!.start,\n end: fileLines[fileLines.length - 1]!.end,\n path: pathOf(headerLines.map((line) => line.text)),\n headerText: headerLines.map((line) => line.text).join('\\n'),\n hunks,\n };\n });\n}\n\n/** The index of the last line with content, so a trailing newline is never claimed. */\nfunction lastContentLine(lines: readonly Line[]): number {\n let i = lines.length - 1;\n while (i > 0 && lines[i]!.text === '') i -= 1;\n return i;\n}\n\nfunction hunkHeader(line: string): string {\n const close = line.indexOf('@@', 2);\n return close === -1 ? line : line.slice(0, close + 2);\n}\n\n/** The path a file header names: the `+++` side first, else the `diff --git` b-side. */\nfunction pathOf(header: readonly string[]): string {\n const plus = header.find((line) => line.startsWith('+++ '));\n if (plus !== undefined && plus !== '+++ /dev/null') return stripPrefix(plus.slice(4));\n const minus = header.find((line) => line.startsWith('--- '));\n if (minus !== undefined && minus !== '--- /dev/null') return stripPrefix(minus.slice(4));\n const git = header.find((line) => line.startsWith('diff --git '));\n if (git !== undefined) {\n const parts = git.slice('diff --git '.length).split(' b/');\n return parts[parts.length - 1] ?? git;\n }\n return '<unknown>';\n}\n\nfunction stripPrefix(path: string): string {\n const trimmed = path.split('\\t')[0]!;\n return trimmed.startsWith('a/') || trimmed.startsWith('b/') ? trimmed.slice(2) : trimmed;\n}\n"]}
@@ -0,0 +1,37 @@
1
+ import type { ElisionPlan, PlanInput, Planner } from '../types.ts';
2
+ export declare const JSON_PLANNER_ID = "json/v1";
3
+ /** The one rule this planner has. */
4
+ export declare const MEMBER_COLLAPSE_RULE = "member-collapse";
5
+ export interface JsonPlannerOptions {
6
+ /** Focus matching is substring, case-insensitive by default. */
7
+ readonly caseSensitive?: boolean;
8
+ }
9
+ /**
10
+ * The JSON planner: **members are the units, not lines.**
11
+ *
12
+ * A pretty-printed tool result — a test report, an API response, a bench log — puts
13
+ * its biggest values on single lines, and a line planner cannot cut a line. This one
14
+ * scans the document once with positions, so each object member (`"key": value`) and
15
+ * array element is a unit with a byte range, and collapses runs of sibling units the
16
+ * focus does not touch — exactly the structural planner's move, with members for
17
+ * declarations. A matched container is descended into, so the cut lands as deep as
18
+ * the focus reaches; with no focus the root's members are kept as a skeleton and each
19
+ * container beneath collapses to one marker whose outline names its keys.
20
+ *
21
+ * It refuses text that is not JSON ({@link ContentKindError}) rather than
22
+ * approximating — the structural planner's rule, for the same reason.
23
+ */
24
+ export declare class JsonPlanner implements Planner {
25
+ #private;
26
+ readonly id = "json/v1";
27
+ constructor(options?: JsonPlannerOptions);
28
+ plan(input: PlanInput): Promise<ElisionPlan>;
29
+ }
30
+ /**
31
+ * The synchronous core, exported like {@link planLexical}. Deterministic; every
32
+ * candidate is priced through the input's {@link MarkerPricing}, never estimated.
33
+ *
34
+ * @throws {ContentKindError} when the text does not parse as a JSON object or array.
35
+ */
36
+ export declare function planJson(input: PlanInput, options?: JsonPlannerOptions): ElisionPlan;
37
+ //# sourceMappingURL=json.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json.d.ts","sourceRoot":"","sources":["../../src/plan/json.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAiB,SAAS,EAAkB,OAAO,EAAE,MAAM,aAAa,CAAC;AAMlG,eAAO,MAAM,eAAe,YAAY,CAAC;AAEzC,qCAAqC;AACrC,eAAO,MAAM,oBAAoB,oBAAoB,CAAC;AAEtD,MAAM,WAAW,kBAAkB;IACjC,gEAAgE;IAChE,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;CAClC;AAED;;;;;;;;;;;;;;GAcG;AACH,qBAAa,WAAY,YAAW,OAAO;;IACzC,QAAQ,CAAC,EAAE,aAAmB;IAG9B,YAAY,OAAO,GAAE,kBAAuB,EAE3C;IAED,IAAI,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,CAAC,CAE3C;CACF;AAYD;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,GAAE,kBAAuB,GAAG,WAAW,CAyDxF","sourcesContent":["import { ContentKindError, MissingMarkerPricingError } from '../errors.ts';\nimport type { ElisionPlan, MarkerPricing, PlanInput, PlannedElision, Planner } from '../types.ts';\n\nimport { savingBytes } from './budget.ts';\nimport { probeKind } from './kind.ts';\nimport { utf8OffsetIndex } from './offsets.ts';\n\nexport const JSON_PLANNER_ID = 'json/v1';\n\n/** The one rule this planner has. */\nexport const MEMBER_COLLAPSE_RULE = 'member-collapse';\n\nexport interface JsonPlannerOptions {\n /** Focus matching is substring, case-insensitive by default. */\n readonly caseSensitive?: boolean;\n}\n\n/**\n * The JSON planner: **members are the units, not lines.**\n *\n * A pretty-printed tool result — a test report, an API response, a bench log — puts\n * its biggest values on single lines, and a line planner cannot cut a line. This one\n * scans the document once with positions, so each object member (`\"key\": value`) and\n * array element is a unit with a byte range, and collapses runs of sibling units the\n * focus does not touch — exactly the structural planner's move, with members for\n * declarations. A matched container is descended into, so the cut lands as deep as\n * the focus reaches; with no focus the root's members are kept as a skeleton and each\n * container beneath collapses to one marker whose outline names its keys.\n *\n * It refuses text that is not JSON ({@link ContentKindError}) rather than\n * approximating — the structural planner's rule, for the same reason.\n */\nexport class JsonPlanner implements Planner {\n readonly id = JSON_PLANNER_ID;\n readonly #options: JsonPlannerOptions;\n\n constructor(options: JsonPlannerOptions = {}) {\n this.#options = options;\n }\n\n plan(input: PlanInput): Promise<ElisionPlan> {\n return Promise.resolve(planJson(input, this.#options));\n }\n}\n\n/** One member or element: where it sits, what to call it, and what it contains. */\ninterface Unit {\n readonly start: number;\n readonly end: number;\n /** The object key, or `[index]` for an array element. */\n readonly label: string;\n /** The members inside, when the value is an object or array. */\n readonly children?: readonly Unit[];\n}\n\n/**\n * The synchronous core, exported like {@link planLexical}. Deterministic; every\n * candidate is priced through the input's {@link MarkerPricing}, never estimated.\n *\n * @throws {ContentKindError} when the text does not parse as a JSON object or array.\n */\nexport function planJson(input: PlanInput, options: JsonPlannerOptions = {}): ElisionPlan {\n const pricing = requirePricing(input);\n if (probeKind(input.text) !== 'json') {\n throw new ContentKindError(\n `smelt: the json planner was asked to plan text that does not parse as a JSON ` +\n `object or array. It refuses rather than approximating — output labelled ` +\n `${JSON_PLANNER_ID} that was really line windows would be undetectable from ` +\n `outside. Use \"lexical\", or \"auto\" to pick by content.`,\n );\n }\n const root = scanValue(input.text, skipWs(input.text, 0));\n const units = root.children ?? [];\n const boundaries = [...flatten(units)].flatMap((unit) => [unit.start, unit.end]);\n const toByte = utf8OffsetIndex(input.text, boundaries);\n const caseSensitive = options.caseSensitive ?? false;\n const focus = (input.focus ?? []).filter((term) => term.length > 0);\n const needles = caseSensitive ? focus : focus.map((term) => term.toLowerCase());\n\n const matches = (unit: Unit): boolean => {\n const raw = input.text.slice(unit.start, unit.end);\n const haystack = caseSensitive ? raw : raw.toLowerCase();\n return needles.some((needle) => haystack.includes(needle));\n };\n\n const elisions: PlannedElision[] = [];\n const collapse = (run: readonly Unit[]): void => {\n if (run.length === 0) return;\n const candidate: PlannedElision = {\n range: { start: toByte.get(run[0]!.start)!, end: toByte.get(run[run.length - 1]!.end)! },\n reason: {\n rule: MEMBER_COLLAPSE_RULE,\n explanation: `collapsed ${String(run.length)} sibling ${run.length === 1 ? 'member' : 'members'}`,\n },\n names: run.map((unit) => unit.label),\n };\n if (savingBytes(candidate, pricing) > 0) elisions.push(candidate);\n };\n\n const walk = (siblings: readonly Unit[], depth: number): void => {\n let run: Unit[] = [];\n for (const unit of siblings) {\n // With a focus, a unit survives when its bytes carry a term; with none, the root\n // is kept as a skeleton and everything beneath it is fair game.\n const kept = needles.length > 0 ? matches(unit) : depth === 0;\n if (!kept) {\n run.push(unit);\n continue;\n }\n collapse(run);\n run = [];\n if (unit.children !== undefined) walk(unit.children, depth + 1);\n }\n collapse(run);\n };\n walk(units, 0);\n\n return { planner: JSON_PLANNER_ID, language: input.language, elisions };\n}\n\nfunction* flatten(units: readonly Unit[]): Generator<Unit> {\n for (const unit of units) {\n yield unit;\n if (unit.children !== undefined) yield* flatten(unit.children);\n }\n}\n\nfunction requirePricing(input: PlanInput): MarkerPricing {\n const pricing: MarkerPricing | undefined = input.pricing;\n if (pricing === undefined) throw new MissingMarkerPricingError(JSON_PLANNER_ID);\n return pricing;\n}\n\n/* ------------------------------------------------------------------------------------\n * A positional scan of already-validated JSON: `probeKind` proved `JSON.parse` accepts\n * the text, so this only has to find where each value starts and ends.\n * ---------------------------------------------------------------------------------- */\n\ninterface Scanned {\n readonly end: number;\n readonly children?: readonly Unit[];\n}\n\nfunction skipWs(text: string, i: number): number {\n let j = i;\n while (j < text.length && ' \\t\\n\\r'.includes(text[j]!)) j += 1;\n return j;\n}\n\nfunction scanValue(text: string, i: number): Scanned {\n const ch = text[i];\n if (ch === '{') return scanObject(text, i);\n if (ch === '[') return scanArray(text, i);\n if (ch === '\"') return { end: scanString(text, i) };\n let j = i;\n while (j < text.length && !',]} \\t\\n\\r'.includes(text[j]!)) j += 1;\n return { end: j };\n}\n\nfunction scanString(text: string, i: number): number {\n let j = i + 1;\n while (j < text.length) {\n const ch = text[j]!;\n if (ch === '\\\\') {\n j += 2;\n continue;\n }\n if (ch === '\"') return j + 1;\n j += 1;\n }\n return j;\n}\n\nfunction scanObject(text: string, open: number): Scanned {\n const children: Unit[] = [];\n let i = skipWs(text, open + 1);\n while (i < text.length && text[i] !== '}') {\n const keyStart = i;\n const keyEnd = scanString(text, i);\n const label = JSON.parse(text.slice(keyStart, keyEnd)) as string;\n i = skipWs(text, keyEnd);\n i = skipWs(text, i + 1); // ':'\n const value = scanValue(text, i);\n children.push({\n start: keyStart,\n end: value.end,\n label,\n ...(value.children === undefined ? {} : { children: value.children }),\n });\n i = skipWs(text, value.end);\n if (text[i] === ',') i = skipWs(text, i + 1);\n }\n return { end: i + 1, children };\n}\n\nfunction scanArray(text: string, open: number): Scanned {\n const children: Unit[] = [];\n let i = skipWs(text, open + 1);\n let index = 0;\n while (i < text.length && text[i] !== ']') {\n const value = scanValue(text, i);\n children.push({\n start: i,\n end: value.end,\n label: `[${String(index)}]`,\n ...(value.children === undefined ? {} : { children: value.children }),\n });\n index += 1;\n i = skipWs(text, value.end);\n if (text[i] === ',') i = skipWs(text, i + 1);\n }\n return { end: i + 1, children };\n}\n"]}
@@ -0,0 +1,181 @@
1
+ import { ContentKindError, MissingMarkerPricingError } from '../errors.js';
2
+ import { savingBytes } from './budget.js';
3
+ import { probeKind } from './kind.js';
4
+ import { utf8OffsetIndex } from './offsets.js';
5
+ export const JSON_PLANNER_ID = 'json/v1';
6
+ /** The one rule this planner has. */
7
+ export const MEMBER_COLLAPSE_RULE = 'member-collapse';
8
+ /**
9
+ * The JSON planner: **members are the units, not lines.**
10
+ *
11
+ * A pretty-printed tool result — a test report, an API response, a bench log — puts
12
+ * its biggest values on single lines, and a line planner cannot cut a line. This one
13
+ * scans the document once with positions, so each object member (`"key": value`) and
14
+ * array element is a unit with a byte range, and collapses runs of sibling units the
15
+ * focus does not touch — exactly the structural planner's move, with members for
16
+ * declarations. A matched container is descended into, so the cut lands as deep as
17
+ * the focus reaches; with no focus the root's members are kept as a skeleton and each
18
+ * container beneath collapses to one marker whose outline names its keys.
19
+ *
20
+ * It refuses text that is not JSON ({@link ContentKindError}) rather than
21
+ * approximating — the structural planner's rule, for the same reason.
22
+ */
23
+ export class JsonPlanner {
24
+ id = JSON_PLANNER_ID;
25
+ #options;
26
+ constructor(options = {}) {
27
+ this.#options = options;
28
+ }
29
+ plan(input) {
30
+ return Promise.resolve(planJson(input, this.#options));
31
+ }
32
+ }
33
+ /**
34
+ * The synchronous core, exported like {@link planLexical}. Deterministic; every
35
+ * candidate is priced through the input's {@link MarkerPricing}, never estimated.
36
+ *
37
+ * @throws {ContentKindError} when the text does not parse as a JSON object or array.
38
+ */
39
+ export function planJson(input, options = {}) {
40
+ const pricing = requirePricing(input);
41
+ if (probeKind(input.text) !== 'json') {
42
+ throw new ContentKindError(`smelt: the json planner was asked to plan text that does not parse as a JSON ` +
43
+ `object or array. It refuses rather than approximating — output labelled ` +
44
+ `${JSON_PLANNER_ID} that was really line windows would be undetectable from ` +
45
+ `outside. Use "lexical", or "auto" to pick by content.`);
46
+ }
47
+ const root = scanValue(input.text, skipWs(input.text, 0));
48
+ const units = root.children ?? [];
49
+ const boundaries = [...flatten(units)].flatMap((unit) => [unit.start, unit.end]);
50
+ const toByte = utf8OffsetIndex(input.text, boundaries);
51
+ const caseSensitive = options.caseSensitive ?? false;
52
+ const focus = (input.focus ?? []).filter((term) => term.length > 0);
53
+ const needles = caseSensitive ? focus : focus.map((term) => term.toLowerCase());
54
+ const matches = (unit) => {
55
+ const raw = input.text.slice(unit.start, unit.end);
56
+ const haystack = caseSensitive ? raw : raw.toLowerCase();
57
+ return needles.some((needle) => haystack.includes(needle));
58
+ };
59
+ const elisions = [];
60
+ const collapse = (run) => {
61
+ if (run.length === 0)
62
+ return;
63
+ const candidate = {
64
+ range: { start: toByte.get(run[0].start), end: toByte.get(run[run.length - 1].end) },
65
+ reason: {
66
+ rule: MEMBER_COLLAPSE_RULE,
67
+ explanation: `collapsed ${String(run.length)} sibling ${run.length === 1 ? 'member' : 'members'}`,
68
+ },
69
+ names: run.map((unit) => unit.label),
70
+ };
71
+ if (savingBytes(candidate, pricing) > 0)
72
+ elisions.push(candidate);
73
+ };
74
+ const walk = (siblings, depth) => {
75
+ let run = [];
76
+ for (const unit of siblings) {
77
+ // With a focus, a unit survives when its bytes carry a term; with none, the root
78
+ // is kept as a skeleton and everything beneath it is fair game.
79
+ const kept = needles.length > 0 ? matches(unit) : depth === 0;
80
+ if (!kept) {
81
+ run.push(unit);
82
+ continue;
83
+ }
84
+ collapse(run);
85
+ run = [];
86
+ if (unit.children !== undefined)
87
+ walk(unit.children, depth + 1);
88
+ }
89
+ collapse(run);
90
+ };
91
+ walk(units, 0);
92
+ return { planner: JSON_PLANNER_ID, language: input.language, elisions };
93
+ }
94
+ function* flatten(units) {
95
+ for (const unit of units) {
96
+ yield unit;
97
+ if (unit.children !== undefined)
98
+ yield* flatten(unit.children);
99
+ }
100
+ }
101
+ function requirePricing(input) {
102
+ const pricing = input.pricing;
103
+ if (pricing === undefined)
104
+ throw new MissingMarkerPricingError(JSON_PLANNER_ID);
105
+ return pricing;
106
+ }
107
+ function skipWs(text, i) {
108
+ let j = i;
109
+ while (j < text.length && ' \t\n\r'.includes(text[j]))
110
+ j += 1;
111
+ return j;
112
+ }
113
+ function scanValue(text, i) {
114
+ const ch = text[i];
115
+ if (ch === '{')
116
+ return scanObject(text, i);
117
+ if (ch === '[')
118
+ return scanArray(text, i);
119
+ if (ch === '"')
120
+ return { end: scanString(text, i) };
121
+ let j = i;
122
+ while (j < text.length && !',]} \t\n\r'.includes(text[j]))
123
+ j += 1;
124
+ return { end: j };
125
+ }
126
+ function scanString(text, i) {
127
+ let j = i + 1;
128
+ while (j < text.length) {
129
+ const ch = text[j];
130
+ if (ch === '\\') {
131
+ j += 2;
132
+ continue;
133
+ }
134
+ if (ch === '"')
135
+ return j + 1;
136
+ j += 1;
137
+ }
138
+ return j;
139
+ }
140
+ function scanObject(text, open) {
141
+ const children = [];
142
+ let i = skipWs(text, open + 1);
143
+ while (i < text.length && text[i] !== '}') {
144
+ const keyStart = i;
145
+ const keyEnd = scanString(text, i);
146
+ const label = JSON.parse(text.slice(keyStart, keyEnd));
147
+ i = skipWs(text, keyEnd);
148
+ i = skipWs(text, i + 1); // ':'
149
+ const value = scanValue(text, i);
150
+ children.push({
151
+ start: keyStart,
152
+ end: value.end,
153
+ label,
154
+ ...(value.children === undefined ? {} : { children: value.children }),
155
+ });
156
+ i = skipWs(text, value.end);
157
+ if (text[i] === ',')
158
+ i = skipWs(text, i + 1);
159
+ }
160
+ return { end: i + 1, children };
161
+ }
162
+ function scanArray(text, open) {
163
+ const children = [];
164
+ let i = skipWs(text, open + 1);
165
+ let index = 0;
166
+ while (i < text.length && text[i] !== ']') {
167
+ const value = scanValue(text, i);
168
+ children.push({
169
+ start: i,
170
+ end: value.end,
171
+ label: `[${String(index)}]`,
172
+ ...(value.children === undefined ? {} : { children: value.children }),
173
+ });
174
+ index += 1;
175
+ i = skipWs(text, value.end);
176
+ if (text[i] === ',')
177
+ i = skipWs(text, i + 1);
178
+ }
179
+ return { end: i + 1, children };
180
+ }
181
+ //# sourceMappingURL=json.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json.js","sourceRoot":"","sources":["../../src/plan/json.ts"],"names":[],"mappings":"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","sourcesContent":["import { ContentKindError, MissingMarkerPricingError } from '../errors.ts';\nimport type { ElisionPlan, MarkerPricing, PlanInput, PlannedElision, Planner } from '../types.ts';\n\nimport { savingBytes } from './budget.ts';\nimport { probeKind } from './kind.ts';\nimport { utf8OffsetIndex } from './offsets.ts';\n\nexport const JSON_PLANNER_ID = 'json/v1';\n\n/** The one rule this planner has. */\nexport const MEMBER_COLLAPSE_RULE = 'member-collapse';\n\nexport interface JsonPlannerOptions {\n /** Focus matching is substring, case-insensitive by default. */\n readonly caseSensitive?: boolean;\n}\n\n/**\n * The JSON planner: **members are the units, not lines.**\n *\n * A pretty-printed tool result — a test report, an API response, a bench log — puts\n * its biggest values on single lines, and a line planner cannot cut a line. This one\n * scans the document once with positions, so each object member (`\"key\": value`) and\n * array element is a unit with a byte range, and collapses runs of sibling units the\n * focus does not touch — exactly the structural planner's move, with members for\n * declarations. A matched container is descended into, so the cut lands as deep as\n * the focus reaches; with no focus the root's members are kept as a skeleton and each\n * container beneath collapses to one marker whose outline names its keys.\n *\n * It refuses text that is not JSON ({@link ContentKindError}) rather than\n * approximating — the structural planner's rule, for the same reason.\n */\nexport class JsonPlanner implements Planner {\n readonly id = JSON_PLANNER_ID;\n readonly #options: JsonPlannerOptions;\n\n constructor(options: JsonPlannerOptions = {}) {\n this.#options = options;\n }\n\n plan(input: PlanInput): Promise<ElisionPlan> {\n return Promise.resolve(planJson(input, this.#options));\n }\n}\n\n/** One member or element: where it sits, what to call it, and what it contains. */\ninterface Unit {\n readonly start: number;\n readonly end: number;\n /** The object key, or `[index]` for an array element. */\n readonly label: string;\n /** The members inside, when the value is an object or array. */\n readonly children?: readonly Unit[];\n}\n\n/**\n * The synchronous core, exported like {@link planLexical}. Deterministic; every\n * candidate is priced through the input's {@link MarkerPricing}, never estimated.\n *\n * @throws {ContentKindError} when the text does not parse as a JSON object or array.\n */\nexport function planJson(input: PlanInput, options: JsonPlannerOptions = {}): ElisionPlan {\n const pricing = requirePricing(input);\n if (probeKind(input.text) !== 'json') {\n throw new ContentKindError(\n `smelt: the json planner was asked to plan text that does not parse as a JSON ` +\n `object or array. It refuses rather than approximating — output labelled ` +\n `${JSON_PLANNER_ID} that was really line windows would be undetectable from ` +\n `outside. Use \"lexical\", or \"auto\" to pick by content.`,\n );\n }\n const root = scanValue(input.text, skipWs(input.text, 0));\n const units = root.children ?? [];\n const boundaries = [...flatten(units)].flatMap((unit) => [unit.start, unit.end]);\n const toByte = utf8OffsetIndex(input.text, boundaries);\n const caseSensitive = options.caseSensitive ?? false;\n const focus = (input.focus ?? []).filter((term) => term.length > 0);\n const needles = caseSensitive ? focus : focus.map((term) => term.toLowerCase());\n\n const matches = (unit: Unit): boolean => {\n const raw = input.text.slice(unit.start, unit.end);\n const haystack = caseSensitive ? raw : raw.toLowerCase();\n return needles.some((needle) => haystack.includes(needle));\n };\n\n const elisions: PlannedElision[] = [];\n const collapse = (run: readonly Unit[]): void => {\n if (run.length === 0) return;\n const candidate: PlannedElision = {\n range: { start: toByte.get(run[0]!.start)!, end: toByte.get(run[run.length - 1]!.end)! },\n reason: {\n rule: MEMBER_COLLAPSE_RULE,\n explanation: `collapsed ${String(run.length)} sibling ${run.length === 1 ? 'member' : 'members'}`,\n },\n names: run.map((unit) => unit.label),\n };\n if (savingBytes(candidate, pricing) > 0) elisions.push(candidate);\n };\n\n const walk = (siblings: readonly Unit[], depth: number): void => {\n let run: Unit[] = [];\n for (const unit of siblings) {\n // With a focus, a unit survives when its bytes carry a term; with none, the root\n // is kept as a skeleton and everything beneath it is fair game.\n const kept = needles.length > 0 ? matches(unit) : depth === 0;\n if (!kept) {\n run.push(unit);\n continue;\n }\n collapse(run);\n run = [];\n if (unit.children !== undefined) walk(unit.children, depth + 1);\n }\n collapse(run);\n };\n walk(units, 0);\n\n return { planner: JSON_PLANNER_ID, language: input.language, elisions };\n}\n\nfunction* flatten(units: readonly Unit[]): Generator<Unit> {\n for (const unit of units) {\n yield unit;\n if (unit.children !== undefined) yield* flatten(unit.children);\n }\n}\n\nfunction requirePricing(input: PlanInput): MarkerPricing {\n const pricing: MarkerPricing | undefined = input.pricing;\n if (pricing === undefined) throw new MissingMarkerPricingError(JSON_PLANNER_ID);\n return pricing;\n}\n\n/* ------------------------------------------------------------------------------------\n * A positional scan of already-validated JSON: `probeKind` proved `JSON.parse` accepts\n * the text, so this only has to find where each value starts and ends.\n * ---------------------------------------------------------------------------------- */\n\ninterface Scanned {\n readonly end: number;\n readonly children?: readonly Unit[];\n}\n\nfunction skipWs(text: string, i: number): number {\n let j = i;\n while (j < text.length && ' \\t\\n\\r'.includes(text[j]!)) j += 1;\n return j;\n}\n\nfunction scanValue(text: string, i: number): Scanned {\n const ch = text[i];\n if (ch === '{') return scanObject(text, i);\n if (ch === '[') return scanArray(text, i);\n if (ch === '\"') return { end: scanString(text, i) };\n let j = i;\n while (j < text.length && !',]} \\t\\n\\r'.includes(text[j]!)) j += 1;\n return { end: j };\n}\n\nfunction scanString(text: string, i: number): number {\n let j = i + 1;\n while (j < text.length) {\n const ch = text[j]!;\n if (ch === '\\\\') {\n j += 2;\n continue;\n }\n if (ch === '\"') return j + 1;\n j += 1;\n }\n return j;\n}\n\nfunction scanObject(text: string, open: number): Scanned {\n const children: Unit[] = [];\n let i = skipWs(text, open + 1);\n while (i < text.length && text[i] !== '}') {\n const keyStart = i;\n const keyEnd = scanString(text, i);\n const label = JSON.parse(text.slice(keyStart, keyEnd)) as string;\n i = skipWs(text, keyEnd);\n i = skipWs(text, i + 1); // ':'\n const value = scanValue(text, i);\n children.push({\n start: keyStart,\n end: value.end,\n label,\n ...(value.children === undefined ? {} : { children: value.children }),\n });\n i = skipWs(text, value.end);\n if (text[i] === ',') i = skipWs(text, i + 1);\n }\n return { end: i + 1, children };\n}\n\nfunction scanArray(text: string, open: number): Scanned {\n const children: Unit[] = [];\n let i = skipWs(text, open + 1);\n let index = 0;\n while (i < text.length && text[i] !== ']') {\n const value = scanValue(text, i);\n children.push({\n start: i,\n end: value.end,\n label: `[${String(index)}]`,\n ...(value.children === undefined ? {} : { children: value.children }),\n });\n index += 1;\n i = skipWs(text, value.end);\n if (text[i] === ',') i = skipWs(text, i + 1);\n }\n return { end: i + 1, children };\n}\n"]}