urtext 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/dist/analyze/blast-radius.d.ts +28 -0
  4. package/dist/analyze/blast-radius.js +163 -0
  5. package/dist/analyze/canonical.d.ts +27 -0
  6. package/dist/analyze/canonical.js +74 -0
  7. package/dist/analyze/citations.d.ts +256 -0
  8. package/dist/analyze/citations.js +945 -0
  9. package/dist/analyze/effects.d.ts +15 -0
  10. package/dist/analyze/effects.js +255 -0
  11. package/dist/analyze/fact.d.ts +42 -0
  12. package/dist/analyze/fact.js +46 -0
  13. package/dist/analyze/guards.d.ts +70 -0
  14. package/dist/analyze/guards.js +211 -0
  15. package/dist/analyze/index.d.ts +26 -0
  16. package/dist/analyze/index.js +52 -0
  17. package/dist/analyze/program.d.ts +15 -0
  18. package/dist/analyze/program.js +229 -0
  19. package/dist/analyze/surface.d.ts +48 -0
  20. package/dist/analyze/surface.js +396 -0
  21. package/dist/bin.d.ts +2 -0
  22. package/dist/bin.js +12 -0
  23. package/dist/cli.d.ts +110 -0
  24. package/dist/cli.js +502 -0
  25. package/dist/extract/diff.d.ts +35 -0
  26. package/dist/extract/diff.js +116 -0
  27. package/dist/extract/git.d.ts +12 -0
  28. package/dist/extract/git.js +247 -0
  29. package/dist/extract/index.d.ts +4 -0
  30. package/dist/extract/index.js +57 -0
  31. package/dist/extract/intent.d.ts +64 -0
  32. package/dist/extract/intent.js +238 -0
  33. package/dist/extract/scope.d.ts +160 -0
  34. package/dist/extract/scope.js +284 -0
  35. package/dist/extract/symbols.d.ts +24 -0
  36. package/dist/extract/symbols.js +230 -0
  37. package/dist/interpret/client.d.ts +27 -0
  38. package/dist/interpret/client.js +80 -0
  39. package/dist/interpret/index.d.ts +41 -0
  40. package/dist/interpret/index.js +86 -0
  41. package/dist/interpret/prompt.d.ts +23 -0
  42. package/dist/interpret/prompt.js +128 -0
  43. package/dist/interpret/schema.d.ts +74 -0
  44. package/dist/interpret/schema.js +103 -0
  45. package/dist/report/conceal.d.ts +63 -0
  46. package/dist/report/conceal.js +129 -0
  47. package/dist/report/coverage.d.ts +43 -0
  48. package/dist/report/coverage.js +56 -0
  49. package/dist/report/html.d.ts +4 -0
  50. package/dist/report/html.js +634 -0
  51. package/dist/report/markdown.d.ts +2 -0
  52. package/dist/report/markdown.js +168 -0
  53. package/dist/report/model.d.ts +303 -0
  54. package/dist/report/model.js +289 -0
  55. package/dist/report/pdf.d.ts +2 -0
  56. package/dist/report/pdf.js +217 -0
  57. package/dist/report/terminal.d.ts +2 -0
  58. package/dist/report/terminal.js +206 -0
  59. package/dist/report/write.d.ts +105 -0
  60. package/dist/report/write.js +160 -0
  61. package/dist/score/index.d.ts +94 -0
  62. package/dist/score/index.js +572 -0
  63. package/dist/score/reach.d.ts +126 -0
  64. package/dist/score/reach.js +320 -0
  65. package/dist/score/reconcile.d.ts +52 -0
  66. package/dist/score/reconcile.js +208 -0
  67. package/dist/types.d.ts +221 -0
  68. package/dist/types.js +10 -0
  69. package/fonts/DejaVuSans-Bold.ttf +0 -0
  70. package/fonts/DejaVuSans-Oblique.ttf +0 -0
  71. package/fonts/DejaVuSans.ttf +0 -0
  72. package/fonts/DejaVuSansMono.ttf +0 -0
  73. package/fonts/LICENSE +187 -0
  74. package/package.json +44 -0
@@ -0,0 +1,247 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { WORKTREE } from "../types.js";
6
+ const exec = promisify(execFile);
7
+ /** Run git and return stdout. Throws with stderr attached on failure. */
8
+ export async function git(args, cwd) {
9
+ const { stdout } = await exec("git", args, {
10
+ cwd,
11
+ maxBuffer: 64 * 1024 * 1024,
12
+ // Pin a stable, unlocalized locale for every invocation. isAbsenceError
13
+ // below matches known English substrings in git's stderr; under a
14
+ // non-English LANG/LC_ALL, git would emit translated fatal messages that
15
+ // match nothing, turning a genuine absence into a thrown error instead
16
+ // of null. LC_ALL=C is git's own recommendation for machine-parsed
17
+ // output; LANGUAGE=C additionally covers gettext's LANGUAGE override —
18
+ // moot on textbook GNU gettext, which ignores LANGUAGE when the locale
19
+ // is C, but cheap insurance against implementations that consult it
20
+ // anyway.
21
+ env: { ...process.env, LC_ALL: "C", LANGUAGE: "C" },
22
+ });
23
+ return stdout;
24
+ }
25
+ /**
26
+ * Absolute path to the top of the working tree containing `cwd`.
27
+ *
28
+ * Every path this module handles is repository-root-relative: `git diff`
29
+ * emits root-relative paths regardless of the directory it runs in, and
30
+ * `git show <rev>:<path>` resolves `<path>` from the root unless it starts
31
+ * with `./`. Only the worktree side of readAt touches the filesystem
32
+ * directly, so it must join against the root and not process.cwd() — from a
33
+ * subdirectory the latter reads nothing and makes every file look deleted.
34
+ *
35
+ * Memoized per cwd: a repository's root does not move during a run, and
36
+ * readAt is called several times per changed file, so without this the
37
+ * worktree branch would spawn a git subprocess per read.
38
+ */
39
+ const ROOTS = new Map();
40
+ export function repoRoot(cwd) {
41
+ let root = ROOTS.get(cwd);
42
+ if (!root) {
43
+ root = git(["rev-parse", "--show-toplevel"], cwd).then((out) => out.trim());
44
+ // Do not cache a rejection: a later call with a valid repo should retry.
45
+ root.catch(() => ROOTS.delete(cwd));
46
+ ROOTS.set(cwd, root);
47
+ }
48
+ return root;
49
+ }
50
+ async function exists(cwd, ref) {
51
+ try {
52
+ await git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd);
53
+ return true;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ /**
60
+ * The branch changes are measured against. Prefers the remote's declared
61
+ * HEAD, then common names, remote before local.
62
+ */
63
+ export async function defaultBranch(cwd) {
64
+ // Establish that cwd is a usable git repository before probing for refs.
65
+ // If this throws (git missing from PATH, cwd not a repo, etc.), let it
66
+ // propagate — the "pick an explicit range" message below is only correct
67
+ // once we know we have a repo and simply can't find a default branch.
68
+ await git(["rev-parse", "--git-dir"], cwd);
69
+ try {
70
+ const out = await git(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], cwd);
71
+ const ref = out.trim();
72
+ if (ref)
73
+ return ref.replace("refs/remotes/", "");
74
+ }
75
+ catch {
76
+ // No origin/HEAD; fall through to the candidate list.
77
+ }
78
+ for (const c of ["origin/main", "origin/master", "main", "master"]) {
79
+ if (await exists(cwd, c))
80
+ return c;
81
+ }
82
+ throw new Error("Could not determine the default branch. Pass an explicit range, e.g. `urtext review HEAD~1`.");
83
+ }
84
+ /**
85
+ * Resolves one named revision, or explains why it cannot be.
86
+ *
87
+ * `git()` rejects with the command it ran, and handing that to the CLI's
88
+ * error printer answers a request for a parent revision with
89
+ * "Command failed: git rev-parse ..." — the tool's internals in place of the
90
+ * reader's problem, and the first thing anyone meets on a shallow or
91
+ * single-commit clone. (The revision is spelled without its numeral here:
92
+ * this repository's comment contract reads a bare small integer in a comment
93
+ * as a restated constant.)
94
+ *
95
+ * The message names the cause it can establish rather than listing the
96
+ * causes it cannot tell apart: a shallow repository genuinely may have the
97
+ * revision upstream and simply not have fetched it, which is a different
98
+ * problem with a different fix from a revision that does not exist. See
99
+ * `test/extract/git.test.ts`, "names the missing revision instead of the git
100
+ * command that failed".
101
+ */
102
+ async function resolveRev(cwd, rev) {
103
+ try {
104
+ return (await git(["rev-parse", `${rev}^{commit}`], cwd)).trim();
105
+ }
106
+ catch {
107
+ throw new Error(await unresolvedMessage(cwd, rev));
108
+ }
109
+ }
110
+ /**
111
+ * Why one revision did not resolve, saying only what can be established.
112
+ *
113
+ * Each clause is earned separately. A shallow clone genuinely may hold the
114
+ * revision upstream, which is a different problem with a different fix from
115
+ * a revision that does not exist. A history of one commit genuinely has no
116
+ * parent — but that answers `HEAD~n`, and answers nothing about a mistyped
117
+ * branch name, where it would be a true fact about the repository offered as
118
+ * a false explanation. So the parent clause is earned by the shape of the
119
+ * revision, not by the fact that something failed. See
120
+ * `test/extract/git.test.ts`, "explains a parent reference with the
121
+ * history's length only when that is why it failed".
122
+ */
123
+ async function unresolvedMessage(cwd, rev) {
124
+ const named = `No revision \`${rev}\` in this repository.`;
125
+ const shallow = await git(["rev-parse", "--is-shallow-repository"], cwd)
126
+ .then((out) => out.trim() === "true")
127
+ .catch(() => false);
128
+ if (shallow) {
129
+ return `${named} This is a shallow clone, so it may exist upstream and simply not have been fetched — deepen it with \`git fetch --unshallow\`, or pass a range this clone has.`;
130
+ }
131
+ // `~` and `^` are the only spellings that ask for an ancestor, so they are
132
+ // the only ones a parentless history explains.
133
+ if (/[~^]/.test(rev)) {
134
+ const rootOnly = await git(["rev-list", "--count", "HEAD"], cwd)
135
+ .then((out) => out.trim() === "1")
136
+ .catch(() => false);
137
+ if (rootOnly) {
138
+ return `${named} This repository's history is a single commit, so it has no ancestor to compare against.`;
139
+ }
140
+ }
141
+ return named;
142
+ }
143
+ /**
144
+ * The merge base of two revisions that have already been resolved.
145
+ *
146
+ * Reached only once both names are known to exist, so a failure here is the
147
+ * one thing it can still be: histories with no commit in common. Saying that
148
+ * is only honest because the typo case was ruled out first.
149
+ */
150
+ async function mergeBaseOf(cwd, a, b) {
151
+ try {
152
+ return (await git(["merge-base", a, b], cwd)).trim();
153
+ }
154
+ catch {
155
+ throw new Error(`\`${a}\` and \`${b}\` have no commit in common, so there is no merge base to review from. Use a two-dot range to compare them literally, e.g. \`${a}..${b}\`.`);
156
+ }
157
+ }
158
+ export async function resolveRange(cwd, spec) {
159
+ if (spec) {
160
+ const m = spec.match(/^(.*?)(\.{2,3})(.*)$/);
161
+ if (m) {
162
+ const a = m[1] || "HEAD";
163
+ const separator = m[2];
164
+ const b = m[3] || "HEAD";
165
+ // `A..B` is literally "from A to B". `A...B` is "from where the two
166
+ // diverged to B" — the change B introduced, ignoring whatever A gained
167
+ // meanwhile. Treating the second as the first reports every commit A
168
+ // made since the fork as a reversal in B, which is a false finding
169
+ // about files the branch never touched.
170
+ // Both sides are resolved before either is used, so a range naming a
171
+ // revision this repository does not have is answered about that
172
+ // revision. Without it, a three-dot range's typo reaches `merge-base`
173
+ // and comes back as a claim about the histories having no common
174
+ // ancestor — a confident wrong answer about the repository.
175
+ const left = await resolveRev(cwd, a);
176
+ const to = await resolveRev(cwd, b);
177
+ const from = separator === "..." ? await mergeBaseOf(cwd, a, b) : left;
178
+ return { from, to, label: spec };
179
+ }
180
+ // A bare revision means "from there to the working tree".
181
+ return {
182
+ from: await resolveRev(cwd, spec),
183
+ to: WORKTREE,
184
+ label: `vs ${spec}`,
185
+ };
186
+ }
187
+ const base = await defaultBranch(cwd);
188
+ await resolveRev(cwd, "HEAD");
189
+ await resolveRev(cwd, base);
190
+ const mergeBase = await mergeBaseOf(cwd, "HEAD", base);
191
+ return { from: mergeBase, to: WORKTREE, label: `vs ${base}` };
192
+ }
193
+ /** File contents at a revision, or null when the file is absent there. */
194
+ export async function readAt(cwd, rev, path) {
195
+ if (rev === WORKTREE) {
196
+ try {
197
+ // `path` is repository-root-relative (see repoRoot), so resolve it
198
+ // against the root rather than the caller's working directory.
199
+ return await readFile(join(await repoRoot(cwd), path), "utf8");
200
+ }
201
+ catch (err) {
202
+ // ENOENT ("no such file") means the file is genuinely absent. Anything
203
+ // else (EACCES, EISDIR, ...) is a real failure and must propagate,
204
+ // not be reported as "not there at this revision".
205
+ if (isEnoent(err))
206
+ return null;
207
+ throw err;
208
+ }
209
+ }
210
+ try {
211
+ return await git(["show", `${rev}:${path}`], cwd);
212
+ }
213
+ catch (err) {
214
+ // git's stderr wording varies by case, but these phrasings all mean
215
+ // "the path/revision genuinely does not exist" rather than an
216
+ // environment or repo problem, so only these map to null:
217
+ // - "does not exist in <rev>" — bad path, valid rev
218
+ // - "exists on disk, but not in <rev>" — path is untracked/worktree-only
219
+ // - "unknown revision or path not in the working tree" — bad rev
220
+ // Anything else (invalid object name, corrupt repo, git missing, ...)
221
+ // propagates so callers can't mistake a real failure for absence.
222
+ if (isAbsenceError(err))
223
+ return null;
224
+ throw err;
225
+ }
226
+ }
227
+ function isEnoent(err) {
228
+ return (err instanceof Error &&
229
+ "code" in err &&
230
+ err.code === "ENOENT");
231
+ }
232
+ const ABSENCE_SIGNALS = [
233
+ "does not exist in",
234
+ "exists on disk, but not in",
235
+ // Not reachable via readAt's `${rev}:${path}` show form today (a bad rev
236
+ // there yields "invalid object name" instead) — kept as defense-in-depth
237
+ // in case a future caller invokes git in a form where git emits it.
238
+ "unknown revision or path not in the working tree",
239
+ ];
240
+ function isAbsenceError(err) {
241
+ const stderr = err instanceof Error && "stderr" in err
242
+ ? err.stderr
243
+ : undefined;
244
+ const text = (typeof stderr === "string" ? stderr : undefined) ??
245
+ (err instanceof Error ? err.message : String(err));
246
+ return ABSENCE_SIGNALS.some((signal) => text.includes(signal));
247
+ }
@@ -0,0 +1,4 @@
1
+ import { type AnalysisContext, type Changeset, type RevRange } from "../types.js";
2
+ export { resolveRange, readAt, repoRoot } from "./git.js";
3
+ export declare function createContext(cwd: string, range: RevRange): AnalysisContext;
4
+ export declare function extract(cwd: string, rangeSpec?: string): Promise<Changeset>;
@@ -0,0 +1,57 @@
1
+ import { WORKTREE } from "../types.js";
2
+ import { createProgramAt } from "../analyze/program.js";
3
+ import { countUntracked, diffText, parseUnifiedDiff } from "./diff.js";
4
+ import { readAt, repoRoot, resolveRange } from "./git.js";
5
+ import { isTypeScriptFile, mapSymbols } from "./symbols.js";
6
+ export { resolveRange, readAt, repoRoot } from "./git.js";
7
+ export function createContext(cwd, range) {
8
+ // Memoized per revision, and only ever built on demand: constructing a
9
+ // program parses every TypeScript file in the repository, so an analyzer
10
+ // that never asks for one must not pay for it.
11
+ const programs = new Map();
12
+ return {
13
+ cwd,
14
+ range,
15
+ readAt: (rev, path) => readAt(cwd, rev, path),
16
+ programAt(rev) {
17
+ let program = programs.get(rev);
18
+ if (!program) {
19
+ program = createProgramAt(cwd, rev);
20
+ // Do not cache a rejection: a transient git failure should not
21
+ // poison every later request for this revision.
22
+ program.catch(() => programs.delete(rev));
23
+ programs.set(rev, program);
24
+ }
25
+ return program;
26
+ },
27
+ };
28
+ }
29
+ export async function extract(cwd, rangeSpec) {
30
+ // Everything downstream speaks repository-root-relative paths, so anchor
31
+ // the whole extraction at the root — `urtext review` has to mean the same
32
+ // thing from a subdirectory as it does from the top.
33
+ const root = await repoRoot(cwd);
34
+ const range = await resolveRange(root, rangeSpec);
35
+ const parsed = parseUnifiedDiff(await diffText(root, range));
36
+ const files = [];
37
+ for (const p of parsed) {
38
+ // mapSymbols discards non-TypeScript files anyway; reading them out of
39
+ // git first only pulls lockfiles and binaries into memory as utf8.
40
+ const wanted = isTypeScriptFile(p.path);
41
+ const beforePath = p.previousPath ?? p.path;
42
+ const before = !wanted || p.status === "added"
43
+ ? null
44
+ : await readAt(root, range.from, beforePath);
45
+ const after = !wanted || p.status === "deleted"
46
+ ? null
47
+ : await readAt(root, range.to, p.path);
48
+ files.push({
49
+ ...p,
50
+ symbols: mapSymbols(p.path, before, after, p.hunks),
51
+ });
52
+ }
53
+ // Untracked files only bear on a comparison that ends at the working tree;
54
+ // between two commits there is nothing to have been left out.
55
+ const untrackedCount = range.to === WORKTREE ? await countUntracked(root) : 0;
56
+ return { range, files, untrackedCount };
57
+ }
@@ -0,0 +1,64 @@
1
+ import { type RevRange } from "../types.js";
2
+ /**
3
+ * Where a stated intent came from. One member today; a `--intent` override
4
+ * would add a second, and INTENT_SOURCE_LABEL in `../interpret/prompt.ts`
5
+ * makes adding one a compile error until the prompt block is told how to
6
+ * introduce it.
7
+ */
8
+ export type IntentSource = "commits";
9
+ export interface IntentCommit {
10
+ /** Abbreviated hash, shown so a reader of the prompt can find the commit. */
11
+ hash: string;
12
+ /** First line of the message. */
13
+ subject: string;
14
+ /** Remaining lines, trailers stripped, empty when there is no body. */
15
+ body: string;
16
+ }
17
+ export interface Intent {
18
+ source: IntentSource;
19
+ /** At least one. A zero-commit range yields `undefined`, never an empty Intent. */
20
+ commits: IntentCommit[];
21
+ /** Commits in the range that did not fit MAX_INTENT_COMMITS. Zero when all fit. */
22
+ omitted: number;
23
+ /** True when the range ends at the working tree, so part of the diff is described by no message. */
24
+ endsAtWorkingTree: boolean;
25
+ }
26
+ /**
27
+ * The most commit messages carried into one prompt's stated-intent block.
28
+ * Bounds prompt size on a long range, the same job MAX_FACTS does for facts;
29
+ * see `test/extract/intent.test.ts`, "caps a long range, keeps the newest,
30
+ * and reports the exact omitted count".
31
+ */
32
+ export declare const MAX_INTENT_COMMITS = 30;
33
+ /**
34
+ * The most code points one commit message contributes, subject and body
35
+ * together, after trailer stripping. A single squash-merge body can otherwise
36
+ * consume the whole block's budget and push every other message's intent out
37
+ * of the prompt.
38
+ */
39
+ export declare const MAX_INTENT_MESSAGE_CHARS = 600;
40
+ /** Appended to a message the cap cut, so no sentence merely appears to end. */
41
+ export declare const INTENT_TRUNCATION_MARKER = "\u2026 [message truncated]";
42
+ /** A trailer line — provenance metadata, not prose about the change. */
43
+ export declare const TRAILER_LINE: RegExp;
44
+ /** The one `git log --format` string; the parser reads its separators from here too. */
45
+ export declare const INTENT_LOG_FORMAT = "%h%x1f%s%x1f%b%x1e";
46
+ /**
47
+ * The stated intent for a range: the messages of the non-merge commits in it,
48
+ * bounded by MAX_INTENT_COMMITS with the remainder counted rather than
49
+ * hidden.
50
+ *
51
+ * Two invocations, both bounded. Counting by reading every message instead
52
+ * would be one call but unbounded on a long range; a capped log plus a count
53
+ * is bounded and exact, and both calls resolve the head the same way, so the
54
+ * count and the messages can never describe different ranges.
55
+ *
56
+ * A `git()` rejection from either call returns `undefined` rather than
57
+ * propagating — the same degradation rule the rest of the pipeline applies: a
58
+ * review missing its intent block is a review; a review that died collecting
59
+ * one is not. The absence then travels the ordinary disclosure path in
60
+ * `../interpret/index.ts`, so the user is told either way. See
61
+ * `test/extract/intent.test.ts`, "returns undefined rather than rejecting
62
+ * when git fails".
63
+ */
64
+ export declare function collectIntent(cwd: string, range: RevRange): Promise<Intent | undefined>;
@@ -0,0 +1,238 @@
1
+ import { WORKTREE } from "../types.js";
2
+ import { git } from "./git.js";
3
+ /**
4
+ * The most commit messages carried into one prompt's stated-intent block.
5
+ * Bounds prompt size on a long range, the same job MAX_FACTS does for facts;
6
+ * see `test/extract/intent.test.ts`, "caps a long range, keeps the newest,
7
+ * and reports the exact omitted count".
8
+ */
9
+ export const MAX_INTENT_COMMITS = 30;
10
+ /**
11
+ * The most code points one commit message contributes, subject and body
12
+ * together, after trailer stripping. A single squash-merge body can otherwise
13
+ * consume the whole block's budget and push every other message's intent out
14
+ * of the prompt.
15
+ */
16
+ export const MAX_INTENT_MESSAGE_CHARS = 600;
17
+ /** Appended to a message the cap cut, so no sentence merely appears to end. */
18
+ export const INTENT_TRUNCATION_MARKER = "… [message truncated]";
19
+ /** A trailer line — provenance metadata, not prose about the change. */
20
+ export const TRAILER_LINE = /^[A-Za-z][A-Za-z0-9-]*: /;
21
+ /** The one `git log --format` string; the parser reads its separators from here too. */
22
+ export const INTENT_LOG_FORMAT = "%h%x1f%s%x1f%b%x1e";
23
+ /**
24
+ * The unit and record separator characters INTENT_LOG_FORMAT asks git for,
25
+ * read back out of that one constant rather than written a second time here.
26
+ * A commit body contains newlines by definition, so a newline-delimited parse
27
+ * is wrong on the first multi-line body it meets; a builder and a parser
28
+ * holding private copies of the separators is the other way this goes wrong,
29
+ * and deriving them closes it. The escapes are spelled only inside the format
30
+ * string above and never in a comment — the comment contract's guarded set
31
+ * includes a value the escapes are written with.
32
+ */
33
+ const SEPARATORS = [...INTENT_LOG_FORMAT.matchAll(/%x([0-9A-Fa-f]{2})/g)].map((m) => String.fromCharCode(Number.parseInt(m[1], 16)));
34
+ const FIELD_SEPARATOR = SEPARATORS[0];
35
+ const RECORD_SEPARATOR = SEPARATORS[SEPARATORS.length - 1];
36
+ /**
37
+ * Removes every separator character left inside a field. git escapes nothing
38
+ * in a commit message, so a body can hold the very characters
39
+ * INTENT_LOG_FORMAT delimits records and fields with; the split has already
40
+ * consumed each separator git itself wrote, so one still sitting inside a
41
+ * field is text an author typed — text that would otherwise fabricate
42
+ * structure the log never emitted. See `test/extract/intent.test.ts`, "keeps
43
+ * a planted field separator from fabricating a field".
44
+ */
45
+ function withoutSeparators(text) {
46
+ return SEPARATORS.reduce((scrubbed, separator) => scrubbed.split(separator).join(""), text);
47
+ }
48
+ /**
49
+ * The line terminators a commit message can carry that git does not strip and
50
+ * `intentBlock` in `../interpret/prompt.ts` does not indent: NEL (U+0085),
51
+ * vertical tab (U+000B), form feed (U+000C), LINE SEPARATOR (U+2028), and
52
+ * PARAGRAPH SEPARATOR (U+2029). A consumer may honor any of them as a line
53
+ * break, so wherever the block renders field text on a line, one left in place
54
+ * begins a fresh line with the author's words at column 0. Carriage return and
55
+ * line feed are deliberately absent: those are the body's legitimate line
56
+ * structure, which `intentBlock` splits on and indents, and must survive.
57
+ * Named once here so the two collapses below cannot hold divergent copies of
58
+ * the set.
59
+ */
60
+ const EXOTIC_LINE_BREAKS = "\u0085\u000B\u000C\u2028\u2029";
61
+ /**
62
+ * Collapses line breaks to spaces. A field the prompt block renders as part
63
+ * of its own line structure — the hash and the subject, which share an
64
+ * entry's line — must carry no break of its own: a break there would begin a
65
+ * line with text an author wrote, outside the frame that marks the block as
66
+ * data rather than instruction. Collapsed rather than dropped, so the words
67
+ * stay readable on the entry they belong to. Every break a consumer may honor
68
+ * counts, not just carriage return and line feed but every EXOTIC_LINE_BREAKS
69
+ * member too. See `test/extract/intent.test.ts`, "never lets planted text
70
+ * reach the start of a line in the rendered intent block" and "collapses every
71
+ * line terminator a fabricated field may carry".
72
+ */
73
+ function asOneLine(text) {
74
+ return text.replace(new RegExp(`[\\r\\n${EXOTIC_LINE_BREAKS}]+`, "g"), " ");
75
+ }
76
+ /**
77
+ * Canonicalizes a body's line breaks down to a single character. A carriage
78
+ * return — alone or as a CRLF pair — becomes a line feed: a carriage return
79
+ * is a real line break (UAX #14), so it earns its own indented continuation
80
+ * line rather than being dropped or left to ride mid-line. Every
81
+ * EXOTIC_LINE_BREAKS member becomes a space: those are not a body's prose
82
+ * structure — a body that legitimately spans lines does so on a line feed,
83
+ * never on NEL or a separator — so a space is the honest neutralization.
84
+ *
85
+ * Invariant, and the whole point of the seam: after tameBodyBreaks the only
86
+ * character in a body that any consumer treats as a line break is a line
87
+ * feed. `intentBlock` in `../interpret/prompt.ts` splits on that one
88
+ * character and indents each piece, so the block's idea of a break and a
89
+ * downstream consumer's cannot disagree — the mismatch that let a break the
90
+ * split did not recognize (a lone carriage return, an exotic terminator)
91
+ * ride to column 0. Applied at parse time, so the invariant holds for every
92
+ * consumer of a stored body, a future intent source included. See
93
+ * `test/extract/intent.test.ts`, "no line-break character in a body carries
94
+ * text to column 0".
95
+ */
96
+ function tameBodyBreaks(text) {
97
+ return text
98
+ .replace(/\r\n?/g, "\n")
99
+ .replace(new RegExp(`[${EXOTIC_LINE_BREAKS}]`, "g"), " ");
100
+ }
101
+ /**
102
+ * Drops the run of trailer lines at the tail of a body, together with the
103
+ * blank lines separating it from the prose. Trailers are provenance metadata
104
+ * — co-authorship, sign-off, session links — and on agentic commits they are
105
+ * frequently the majority of the body's bytes. Only the tail run goes: a
106
+ * colon-prefixed line in the middle of a body is prose about the change and
107
+ * stays. See `test/extract/intent.test.ts`, "strips a trailer run at the tail
108
+ * while keeping a colon-prefixed line mid-body".
109
+ */
110
+ function stripTrailers(body) {
111
+ const lines = body.split(/\r?\n/);
112
+ let end = lines.length;
113
+ while (end > 0) {
114
+ const line = lines[end - 1];
115
+ if (line.trim() === "" || TRAILER_LINE.test(line)) {
116
+ end--;
117
+ continue;
118
+ }
119
+ break;
120
+ }
121
+ return lines.slice(0, end).join("\n");
122
+ }
123
+ /**
124
+ * Subject and body together, capped in code points. `String#slice` counts
125
+ * UTF-16 units, so an astral character straddling the cut stores a lone
126
+ * surrogate that every downstream layer then faithfully preserves — the
127
+ * reason `truncateSignature` in `../analyze/surface.ts` counts the same way.
128
+ * A cut message ends with INTENT_TRUNCATION_MARKER, so the model is never
129
+ * shown a sentence that merely appears to end. A message that is empty after
130
+ * stripping keeps its subject: a commit whose body was nothing but trailers
131
+ * still stated an intent in its subject line. See
132
+ * `test/extract/intent.test.ts`, "cuts a long message on a code-point
133
+ * boundary and marks it".
134
+ */
135
+ function capMessage(commit) {
136
+ const subject = [...commit.subject];
137
+ if (subject.length >= MAX_INTENT_MESSAGE_CHARS) {
138
+ return {
139
+ hash: commit.hash,
140
+ subject: subject.slice(0, MAX_INTENT_MESSAGE_CHARS).join("") + INTENT_TRUNCATION_MARKER,
141
+ body: "",
142
+ };
143
+ }
144
+ const budget = MAX_INTENT_MESSAGE_CHARS - subject.length;
145
+ const body = [...commit.body];
146
+ if (body.length <= budget)
147
+ return commit;
148
+ return {
149
+ hash: commit.hash,
150
+ subject: commit.subject,
151
+ body: body.slice(0, budget).join("") + INTENT_TRUNCATION_MARKER,
152
+ };
153
+ }
154
+ /**
155
+ * Oldest first: `git log` emits newest first, and the block reads in the
156
+ * order the change was built. See `test/extract/intent.test.ts`, "lists
157
+ * commits oldest first, the order the change was built in".
158
+ */
159
+ function parseIntentLog(out) {
160
+ const commits = [];
161
+ for (const record of out.split(RECORD_SEPARATOR)) {
162
+ // git writes a newline after each formatted record; it belongs to the
163
+ // separator, not to the next commit's hash.
164
+ const text = record.replace(/^\r?\n/, "");
165
+ if (text === "")
166
+ continue;
167
+ const fields = text.split(FIELD_SEPARATOR);
168
+ // A record short of its fields is malformed output, not a commit with an
169
+ // empty body — dropping it is the honest reading.
170
+ if (fields.length < 3)
171
+ continue;
172
+ // Scrubbed here, at the seam where text becomes structure: this is the
173
+ // only place that can still tell a separator git wrote from one an
174
+ // author typed, and every field a commit is built from passes through
175
+ // it — the hash included.
176
+ const body = stripTrailers(tameBodyBreaks(withoutSeparators(fields.slice(2).join(FIELD_SEPARATOR))));
177
+ commits.push(capMessage({
178
+ hash: asOneLine(withoutSeparators(fields[0])),
179
+ subject: asOneLine(withoutSeparators(fields[1])),
180
+ body,
181
+ }));
182
+ }
183
+ return commits.reverse();
184
+ }
185
+ /**
186
+ * The stated intent for a range: the messages of the non-merge commits in it,
187
+ * bounded by MAX_INTENT_COMMITS with the remainder counted rather than
188
+ * hidden.
189
+ *
190
+ * Two invocations, both bounded. Counting by reading every message instead
191
+ * would be one call but unbounded on a long range; a capped log plus a count
192
+ * is bounded and exact, and both calls resolve the head the same way, so the
193
+ * count and the messages can never describe different ranges.
194
+ *
195
+ * A `git()` rejection from either call returns `undefined` rather than
196
+ * propagating — the same degradation rule the rest of the pipeline applies: a
197
+ * review missing its intent block is a review; a review that died collecting
198
+ * one is not. The absence then travels the ordinary disclosure path in
199
+ * `../interpret/index.ts`, so the user is told either way. See
200
+ * `test/extract/intent.test.ts`, "returns undefined rather than rejecting
201
+ * when git fails".
202
+ */
203
+ export async function collectIntent(cwd, range) {
204
+ const endsAtWorkingTree = range.to === WORKTREE;
205
+ const head = endsAtWorkingTree ? "HEAD" : range.to;
206
+ const span = `${range.from}..${head}`;
207
+ let log;
208
+ let total;
209
+ try {
210
+ log = await git([
211
+ "log",
212
+ "--no-merges",
213
+ "-n",
214
+ String(MAX_INTENT_COMMITS),
215
+ `--format=${INTENT_LOG_FORMAT}`,
216
+ span,
217
+ ], cwd);
218
+ total = await git(["rev-list", "--count", "--no-merges", span], cwd);
219
+ }
220
+ catch {
221
+ return undefined;
222
+ }
223
+ const commits = parseIntentLog(log);
224
+ // A range of nothing but merge commits collects nothing and takes this
225
+ // path, which is the honest result: the merges' own messages state nothing
226
+ // about the code, and the commits they brought in are already in the range.
227
+ if (commits.length === 0)
228
+ return undefined;
229
+ const counted = Number.parseInt(total.trim(), 10);
230
+ return {
231
+ source: "commits",
232
+ commits,
233
+ // Truncation keeps the newest: later commits describe what the change
234
+ // became, and later work commonly amends earlier work.
235
+ omitted: Number.isFinite(counted) ? Math.max(counted - commits.length, 0) : 0,
236
+ endsAtWorkingTree,
237
+ };
238
+ }