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.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/analyze/blast-radius.d.ts +28 -0
- package/dist/analyze/blast-radius.js +163 -0
- package/dist/analyze/canonical.d.ts +27 -0
- package/dist/analyze/canonical.js +74 -0
- package/dist/analyze/citations.d.ts +256 -0
- package/dist/analyze/citations.js +945 -0
- package/dist/analyze/effects.d.ts +15 -0
- package/dist/analyze/effects.js +255 -0
- package/dist/analyze/fact.d.ts +42 -0
- package/dist/analyze/fact.js +46 -0
- package/dist/analyze/guards.d.ts +70 -0
- package/dist/analyze/guards.js +211 -0
- package/dist/analyze/index.d.ts +26 -0
- package/dist/analyze/index.js +52 -0
- package/dist/analyze/program.d.ts +15 -0
- package/dist/analyze/program.js +229 -0
- package/dist/analyze/surface.d.ts +48 -0
- package/dist/analyze/surface.js +396 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +12 -0
- package/dist/cli.d.ts +110 -0
- package/dist/cli.js +502 -0
- package/dist/extract/diff.d.ts +35 -0
- package/dist/extract/diff.js +116 -0
- package/dist/extract/git.d.ts +12 -0
- package/dist/extract/git.js +247 -0
- package/dist/extract/index.d.ts +4 -0
- package/dist/extract/index.js +57 -0
- package/dist/extract/intent.d.ts +64 -0
- package/dist/extract/intent.js +238 -0
- package/dist/extract/scope.d.ts +160 -0
- package/dist/extract/scope.js +284 -0
- package/dist/extract/symbols.d.ts +24 -0
- package/dist/extract/symbols.js +230 -0
- package/dist/interpret/client.d.ts +27 -0
- package/dist/interpret/client.js +80 -0
- package/dist/interpret/index.d.ts +41 -0
- package/dist/interpret/index.js +86 -0
- package/dist/interpret/prompt.d.ts +23 -0
- package/dist/interpret/prompt.js +128 -0
- package/dist/interpret/schema.d.ts +74 -0
- package/dist/interpret/schema.js +103 -0
- package/dist/report/conceal.d.ts +63 -0
- package/dist/report/conceal.js +129 -0
- package/dist/report/coverage.d.ts +43 -0
- package/dist/report/coverage.js +56 -0
- package/dist/report/html.d.ts +4 -0
- package/dist/report/html.js +634 -0
- package/dist/report/markdown.d.ts +2 -0
- package/dist/report/markdown.js +168 -0
- package/dist/report/model.d.ts +303 -0
- package/dist/report/model.js +289 -0
- package/dist/report/pdf.d.ts +2 -0
- package/dist/report/pdf.js +217 -0
- package/dist/report/terminal.d.ts +2 -0
- package/dist/report/terminal.js +206 -0
- package/dist/report/write.d.ts +105 -0
- package/dist/report/write.js +160 -0
- package/dist/score/index.d.ts +94 -0
- package/dist/score/index.js +572 -0
- package/dist/score/reach.d.ts +126 -0
- package/dist/score/reach.js +320 -0
- package/dist/score/reconcile.d.ts +52 -0
- package/dist/score/reconcile.js +208 -0
- package/dist/types.d.ts +221 -0
- package/dist/types.js +10 -0
- package/fonts/DejaVuSans-Bold.ttf +0 -0
- package/fonts/DejaVuSans-Oblique.ttf +0 -0
- package/fonts/DejaVuSans.ttf +0 -0
- package/fonts/DejaVuSansMono.ttf +0 -0
- package/fonts/LICENSE +187 -0
- 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
|
+
}
|