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
package/dist/cli.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { ANALYZERS, citationsAnalyzer, makeCitationsAnalyzer, runAnalyzers, } from "./analyze/index.js";
|
|
2
|
+
import { createContext, extract, repoRoot } from "./extract/index.js";
|
|
3
|
+
import { collectIntent } from "./extract/intent.js";
|
|
4
|
+
import { DEFAULT_MODEL, interpret } from "./interpret/index.js";
|
|
5
|
+
import { labelConcealed } from "./report/conceal.js";
|
|
6
|
+
import { deletedFilesNote, deletedTypeScriptFiles } from "./report/coverage.js";
|
|
7
|
+
import { renderHtml } from "./report/html.js";
|
|
8
|
+
import { renderMarkdown } from "./report/markdown.js";
|
|
9
|
+
import { buildReportModel } from "./report/model.js";
|
|
10
|
+
import { renderPdf } from "./report/pdf.js";
|
|
11
|
+
import { renderTerminal } from "./report/terminal.js";
|
|
12
|
+
import { EXPORT_FORMATS, openReport, shouldSuggestGitignore, writeExport, writeReport, } from "./report/write.js";
|
|
13
|
+
import { reconcile } from "./score/reconcile.js";
|
|
14
|
+
/**
|
|
15
|
+
* Every format `--stdout` can carry. One member today, and a union rather
|
|
16
|
+
* than a boolean for the same reason `IntentSource` is one: a second member
|
|
17
|
+
* is a compile error at every site that decides what stdout holds, instead
|
|
18
|
+
* of a boolean that quietly means "the one other thing". Lives here and not
|
|
19
|
+
* beside EXPORT_FORMATS in `./report/write.js`: that constant belongs to the
|
|
20
|
+
* writer because the writer owns the filenames, and nothing outside this
|
|
21
|
+
* file decides what a stream carries.
|
|
22
|
+
*/
|
|
23
|
+
export const STDOUT_FORMATS = ["md"];
|
|
24
|
+
/** Exported so a test can check that it names the real default model. */
|
|
25
|
+
export const USAGE = `
|
|
26
|
+
urtext — diff review with evidence tiers
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
urtext review [<rev-range>] Review a change (default: working tree vs
|
|
30
|
+
merge-base with the default branch)
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
--no-llm Deterministic analysis only; no API key required. Every
|
|
34
|
+
finding is [verified]; no [inferred] or [model] findings.
|
|
35
|
+
--model ID Model for the interpretation stage (default: ${DEFAULT_MODEL})
|
|
36
|
+
--citations Check every path:line citation in this repository, not only the
|
|
37
|
+
ones pointing into files this range touched
|
|
38
|
+
--json Emit findings as JSON
|
|
39
|
+
--open Open the written report with the platform's default handler
|
|
40
|
+
--export FORMATS
|
|
41
|
+
Also write the review in these formats beside the HTML report,
|
|
42
|
+
sharing its name: a comma-separated list of ${EXPORT_FORMATS.join(" and ")},
|
|
43
|
+
e.g. --export md,pdf
|
|
44
|
+
--stdout md Write the Markdown review to stdout and nothing else; the
|
|
45
|
+
terminal render and every note move to stderr. Cannot be
|
|
46
|
+
combined with --json.
|
|
47
|
+
--help Show this message
|
|
48
|
+
`;
|
|
49
|
+
/**
|
|
50
|
+
* One wording for every way `--export` can be misused — an unknown format,
|
|
51
|
+
* an empty list, a swallowed flag — so the user always sees the full list of
|
|
52
|
+
* what the flag does accept, in the example-led style of the `--model`
|
|
53
|
+
* errors above.
|
|
54
|
+
*/
|
|
55
|
+
function exportUsageError(problem) {
|
|
56
|
+
return new Error(`--export ${problem}; it takes a comma-separated list of ${EXPORT_FORMATS.join(" and ")}, e.g. --export md,pdf.`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Folds one `--export` value into the accumulated formats: comma lists,
|
|
60
|
+
* single values, and a repeated flag all land in the same deduplicated,
|
|
61
|
+
* first-mention-ordered array — a user who writes `--export md --export pdf`
|
|
62
|
+
* is not making a mistake, and `--export md,md` is not asking for two files.
|
|
63
|
+
*/
|
|
64
|
+
function addExportFormats(opts, value) {
|
|
65
|
+
const parts = value
|
|
66
|
+
.split(",")
|
|
67
|
+
.map((part) => part.trim())
|
|
68
|
+
.filter((part) => part !== "");
|
|
69
|
+
if (parts.length === 0)
|
|
70
|
+
throw exportUsageError("got an empty format list");
|
|
71
|
+
for (const part of parts) {
|
|
72
|
+
if (!EXPORT_FORMATS.includes(part)) {
|
|
73
|
+
throw exportUsageError(`cannot write "${part}"`);
|
|
74
|
+
}
|
|
75
|
+
opts.exportFormats ??= [];
|
|
76
|
+
const format = part;
|
|
77
|
+
if (!opts.exportFormats.includes(format))
|
|
78
|
+
opts.exportFormats.push(format);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* One wording for every way `--stdout` can be misused — an unknown format, a
|
|
83
|
+
* missing value, a swallowed flag — so the user always sees the full list of
|
|
84
|
+
* what the flag does accept, exactly as `exportUsageError` above does.
|
|
85
|
+
*/
|
|
86
|
+
function stdoutUsageError(problem) {
|
|
87
|
+
return new Error(`--stdout ${problem}; it takes ${STDOUT_FORMATS.join(" and ")}, e.g. --stdout md.`);
|
|
88
|
+
}
|
|
89
|
+
/** Both spellings fold through here, so neither can accept what the other rejects. */
|
|
90
|
+
function setStdoutFormat(opts, value) {
|
|
91
|
+
if (!STDOUT_FORMATS.includes(value)) {
|
|
92
|
+
throw stdoutUsageError(`cannot write "${value}"`);
|
|
93
|
+
}
|
|
94
|
+
opts.stdout = value;
|
|
95
|
+
}
|
|
96
|
+
export function parseArgs(argv) {
|
|
97
|
+
const opts = {
|
|
98
|
+
command: "review",
|
|
99
|
+
json: false,
|
|
100
|
+
noLlm: false,
|
|
101
|
+
open: false,
|
|
102
|
+
help: false,
|
|
103
|
+
};
|
|
104
|
+
const positional = [];
|
|
105
|
+
// Indexed rather than for-of because `--model` takes a value, and its
|
|
106
|
+
// separated form (`--model ID`) has to consume the next argument. Both forms
|
|
107
|
+
// are accepted: a user who writes `--model=ID` is not making a mistake.
|
|
108
|
+
for (let i = 0; i < argv.length; i++) {
|
|
109
|
+
const arg = argv[i];
|
|
110
|
+
if (arg === "--json")
|
|
111
|
+
opts.json = true;
|
|
112
|
+
else if (arg === "--no-llm")
|
|
113
|
+
opts.noLlm = true;
|
|
114
|
+
else if (arg === "--open")
|
|
115
|
+
opts.open = true;
|
|
116
|
+
else if (arg === "--citations")
|
|
117
|
+
opts.citations = true;
|
|
118
|
+
else if (arg === "--help" || arg === "-h")
|
|
119
|
+
opts.help = true;
|
|
120
|
+
else if (arg.startsWith("--model=")) {
|
|
121
|
+
const value = arg.slice("--model=".length);
|
|
122
|
+
// An empty value is a mistake, not a request for the default: silently
|
|
123
|
+
// defaulting would run a different model than the command line names.
|
|
124
|
+
if (!value)
|
|
125
|
+
throw new Error(`--model needs a model id, e.g. --model ${DEFAULT_MODEL}.`);
|
|
126
|
+
opts.model = value;
|
|
127
|
+
}
|
|
128
|
+
else if (arg === "--model") {
|
|
129
|
+
const value = argv[i + 1];
|
|
130
|
+
// A following flag is the next option, not this one's value — `--model
|
|
131
|
+
// --json` must not silently review with a model called "--json".
|
|
132
|
+
if (!value || value.startsWith("-")) {
|
|
133
|
+
throw new Error(`--model needs a model id, e.g. --model ${DEFAULT_MODEL}.`);
|
|
134
|
+
}
|
|
135
|
+
opts.model = value;
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
138
|
+
else if (arg.startsWith("--export=")) {
|
|
139
|
+
addExportFormats(opts, arg.slice("--export=".length));
|
|
140
|
+
}
|
|
141
|
+
else if (arg === "--export") {
|
|
142
|
+
const value = argv[i + 1];
|
|
143
|
+
// A following flag is the next option, not this one's value — the
|
|
144
|
+
// same rule as `--model` above.
|
|
145
|
+
if (!value || value.startsWith("-"))
|
|
146
|
+
throw exportUsageError("needs a format list");
|
|
147
|
+
addExportFormats(opts, value);
|
|
148
|
+
i++;
|
|
149
|
+
}
|
|
150
|
+
else if (arg.startsWith("--stdout=")) {
|
|
151
|
+
setStdoutFormat(opts, arg.slice("--stdout=".length));
|
|
152
|
+
}
|
|
153
|
+
else if (arg === "--stdout") {
|
|
154
|
+
const value = argv[i + 1];
|
|
155
|
+
// A following flag is the next option, not this one's value — the same
|
|
156
|
+
// rule `--model` and `--export` already apply.
|
|
157
|
+
if (!value || value.startsWith("-"))
|
|
158
|
+
throw stdoutUsageError("needs a format");
|
|
159
|
+
setStdoutFormat(opts, value);
|
|
160
|
+
i++;
|
|
161
|
+
}
|
|
162
|
+
else if (arg.startsWith("-")) {
|
|
163
|
+
// Falling through to the positional slot made a typo'd flag the range,
|
|
164
|
+
// and the user got a raw `git diff` usage dump instead of an answer.
|
|
165
|
+
throw new Error(`Unknown option: ${arg}. Run \`urtext --help\` for usage.`);
|
|
166
|
+
}
|
|
167
|
+
else
|
|
168
|
+
positional.push(arg);
|
|
169
|
+
}
|
|
170
|
+
// After the loop on purpose: `--json --stdout md` and `--stdout md --json`
|
|
171
|
+
// are the same request, and a check inside the loop would only catch one
|
|
172
|
+
// order. See `test/cli.test.ts`, "refuses to put two documents on one
|
|
173
|
+
// stream, in either order".
|
|
174
|
+
if (opts.stdout !== undefined && opts.json) {
|
|
175
|
+
throw new Error(`--stdout ${opts.stdout} and --json cannot both own stdout; pick one.`);
|
|
176
|
+
}
|
|
177
|
+
if (positional.length > 0 && positional[0] === "review")
|
|
178
|
+
positional.shift();
|
|
179
|
+
if (positional.length > 0)
|
|
180
|
+
opts.range = positional[0];
|
|
181
|
+
return opts;
|
|
182
|
+
}
|
|
183
|
+
export async function review(cwd, opts,
|
|
184
|
+
// Defaulted rather than folded into `CliOptions`: every other field here
|
|
185
|
+
// is something a command-line flag sets, and this one varies in three
|
|
186
|
+
// tests that control analyzer failure directly rather than by breaking a
|
|
187
|
+
// real repository — see `test/cli.test.ts`, "exits non-zero when every
|
|
188
|
+
// analyzer fails, even though the output says 'No findings'", "exits
|
|
189
|
+
// zero when some analyzers fail but at least one still produces
|
|
190
|
+
// findings", and "exits non-zero when some analyzers fail and none of
|
|
191
|
+
// them, nor any other, produced a finding".
|
|
192
|
+
analyzers = ANALYZERS,
|
|
193
|
+
// Defaulted for the same reason as `analyzers`: the export renderers are
|
|
194
|
+
// static imports a test cannot make fail from outside, and the
|
|
195
|
+
// degrades-to-a-warning contract needs a failing one — see
|
|
196
|
+
// `test/cli.test.ts`, "degrades a failing export to a warning, leaving
|
|
197
|
+
// findings, exit code, and the other export untouched".
|
|
198
|
+
exporters = { md: renderMarkdown, pdf: renderPdf }) {
|
|
199
|
+
// Anchor at the repository root so `urtext review` behaves the same from
|
|
200
|
+
// any directory inside the repo; every path in play is root-relative.
|
|
201
|
+
const root = await repoRoot(cwd);
|
|
202
|
+
const changeset = await extract(root, opts.range);
|
|
203
|
+
const ctx = createContext(root, changeset.range);
|
|
204
|
+
// A failed analyzer degrades the review rather than ending it, so the
|
|
205
|
+
// failure has to be said out loud — otherwise a partial review is
|
|
206
|
+
// indistinguishable from a clean one.
|
|
207
|
+
const warnings = [];
|
|
208
|
+
// Swapped in by identity, which keeps the `analyzers` parameter's existing
|
|
209
|
+
// default and every test that passes its own list working untouched: a
|
|
210
|
+
// hand-built list contains no `citationsAnalyzer`, so the map is a no-op for
|
|
211
|
+
// it, and the list's length — which the exit-code rule below compares
|
|
212
|
+
// against — is unchanged either way.
|
|
213
|
+
//
|
|
214
|
+
// `onNote` is the whole reason the swap exists. An analyzer returns facts
|
|
215
|
+
// and nothing else, so a citation run that hit a cap, could not read a
|
|
216
|
+
// line's history, or skipped a shallow repository has no way to say so on
|
|
217
|
+
// its own; without this the caps would bite in silence, which is the one
|
|
218
|
+
// thing this check must never do. The channel is the same `warnings` array
|
|
219
|
+
// every other shortfall uses — no new key anywhere.
|
|
220
|
+
const runnable = analyzers.map((a) => a === citationsAnalyzer
|
|
221
|
+
? makeCitationsAnalyzer({
|
|
222
|
+
sweep: opts.citations === true,
|
|
223
|
+
onNote: (note) => warnings.push(note),
|
|
224
|
+
})
|
|
225
|
+
: a);
|
|
226
|
+
let failureCount = 0;
|
|
227
|
+
const facts = await runAnalyzers(changeset, ctx, runnable, (f) => {
|
|
228
|
+
failureCount++;
|
|
229
|
+
warnings.push(`the ${f.analyzer} analyzer failed, so this review is partial: ${f.message}`);
|
|
230
|
+
});
|
|
231
|
+
// Skipped entirely under `--no-llm`: the stage will not run, so the git
|
|
232
|
+
// calls would buy nothing, and `interpret` returns no `intentNote` on that
|
|
233
|
+
// path anyway.
|
|
234
|
+
const intent = opts.noLlm ? undefined : await collectIntent(root, changeset.range);
|
|
235
|
+
const result = await interpret(changeset, facts, {
|
|
236
|
+
disabled: opts.noLlm,
|
|
237
|
+
model: opts.model,
|
|
238
|
+
intent,
|
|
239
|
+
});
|
|
240
|
+
// Whatever stopped the model — the flag, a missing key, a refusal, a
|
|
241
|
+
// truncated response — the review still ran on analyzer facts alone, and
|
|
242
|
+
// that is exactly the "partial" case the analyzer warnings above already
|
|
243
|
+
// exist to announce. One list, one rule: any reason the review fell short
|
|
244
|
+
// of its full pipeline belongs in `warnings`.
|
|
245
|
+
if (result.skipped)
|
|
246
|
+
warnings.push(result.skipped);
|
|
247
|
+
// The same channel as the skip note above, and for the same reason: a
|
|
248
|
+
// review that could not compare the change against a stated intent fell
|
|
249
|
+
// short of its full pipeline, exactly as a skipped interpretation stage
|
|
250
|
+
// did. `interpret` decides the wording; this only carries it.
|
|
251
|
+
if (result.intentNote)
|
|
252
|
+
warnings.push(result.intentNote);
|
|
253
|
+
// How many claim-free standalone reach rows reconcile's filter removed.
|
|
254
|
+
// Not a warning — the filter ran as designed, the review is not partial —
|
|
255
|
+
// but both output surfaces state it, because a single-caller change can
|
|
256
|
+
// otherwise reach the report as nothing at all.
|
|
257
|
+
let suppressed = 0;
|
|
258
|
+
const findings = reconcile(facts, result.claims, (dropped) => {
|
|
259
|
+
// First-claim-wins is deterministic, but the losing claims are model
|
|
260
|
+
// output the reader never sees — and a review that silently discarded
|
|
261
|
+
// part of what the model said is partial in exactly the sense this list
|
|
262
|
+
// exists to disclose.
|
|
263
|
+
warnings.push(`the model made ${dropped} further claim${dropped === 1 ? "" : "s"} about already-explained findings; ${dropped === 1 ? "it is" : "they are"} not shown`);
|
|
264
|
+
}, (count) => {
|
|
265
|
+
suppressed = count;
|
|
266
|
+
});
|
|
267
|
+
// Two independent reasons a review has to fail loudly rather than exit
|
|
268
|
+
// clean, both about the same hazard at different sizes: a report that
|
|
269
|
+
// looks successful to a script when nothing trustworthy backs it.
|
|
270
|
+
//
|
|
271
|
+
// `allAnalyzersFailed` is unconditional on `findings` — `interpret` is not
|
|
272
|
+
// skipped merely because `facts` came back empty (only `--no-llm`, a
|
|
273
|
+
// missing API key, or an empty changeset skip it), so every analyzer
|
|
274
|
+
// dying does not by itself stop the model from being asked and producing
|
|
275
|
+
// a standalone claim, which `reconcile` still turns into a finding. A
|
|
276
|
+
// model claim with no analyzer fact behind it is exactly what the "no
|
|
277
|
+
// evidence" case exists to distrust — the deterministic half is the part
|
|
278
|
+
// that has to work, and a review whose findings are entirely unverified
|
|
279
|
+
// is not a clean review.
|
|
280
|
+
//
|
|
281
|
+
// `someFailedNothingShown` covers the same hazard at partial failure: a
|
|
282
|
+
// script sees exit 0 and "No findings", which reads as "this range is
|
|
283
|
+
// clean" when the truer reading is "some of what would have found
|
|
284
|
+
// something never ran". A partial failure that still produced real
|
|
285
|
+
// findings is not this case, and stays exit 0 — the findings are real and
|
|
286
|
+
// the shortfall is already stated in `warnings`, so failing it would
|
|
287
|
+
// throw away good output over a degradation the tool already discloses.
|
|
288
|
+
const allAnalyzersFailed = analyzers.length > 0 && failureCount === analyzers.length;
|
|
289
|
+
const someFailedNothingShown = failureCount > 0 && findings.length === 0;
|
|
290
|
+
const exitCode = allAnalyzersFailed || someFailedNothingShown ? 1 : 0;
|
|
291
|
+
// A review this broken does not get a report: the whole reason a nonzero
|
|
292
|
+
// exit code exists here is that a report sitting on disk looks like a
|
|
293
|
+
// successful run to anyone who only checks whether one was produced, and
|
|
294
|
+
// writing one here would recreate that exact appearance under the fix
|
|
295
|
+
// that was supposed to remove it.
|
|
296
|
+
let reportPath;
|
|
297
|
+
let markdown;
|
|
298
|
+
const exportFormats = opts.exportFormats ?? [];
|
|
299
|
+
const exportPaths = {};
|
|
300
|
+
if (exitCode === 0) {
|
|
301
|
+
try {
|
|
302
|
+
reportPath = await writeReport(root,
|
|
303
|
+
// `warnings` already carries `result.skipped` (pushed above, where
|
|
304
|
+
// every reason a review fell short goes). Passing it separately as
|
|
305
|
+
// well is what printed the skipped-stage line twice in the banner of
|
|
306
|
+
// every `--no-llm` run.
|
|
307
|
+
renderHtml(changeset, findings, { model: result.model, warnings, suppressed }));
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
// A degraded review beats no review, the same rule `runAnalyzers`
|
|
311
|
+
// already applies to a single dead analyzer above — the findings and
|
|
312
|
+
// everything else this run computed are real regardless of whether
|
|
313
|
+
// the report describing them made it to disk, and rejecting here
|
|
314
|
+
// would discard all of it over a filesystem problem this review's
|
|
315
|
+
// own content had nothing to do with.
|
|
316
|
+
warnings.push(`could not write the report: ${err instanceof Error ? err.message : String(err)}`);
|
|
317
|
+
}
|
|
318
|
+
// Gated on `reportPath`, not just the exit code: the exports share the
|
|
319
|
+
// HTML report's stem (see `writeExport`), so a run whose report failed
|
|
320
|
+
// to write has nothing to anchor them to — and the same
|
|
321
|
+
// no-report-on-broken-runs reasoning applies to every format equally.
|
|
322
|
+
if (!reportPath && exportFormats.length > 0) {
|
|
323
|
+
// That gate is a mechanism the user cannot see: without this line,
|
|
324
|
+
// asked-for exports simply never appear, with only the report's own
|
|
325
|
+
// warning printed — an output that silently fails to exist is the
|
|
326
|
+
// exact opposite of what this tool is for. A second warning beside the
|
|
327
|
+
// report's rather than a rewording of it, so a user who asked for no
|
|
328
|
+
// exports never finds this sentence in their failure story — see
|
|
329
|
+
// `test/cli.test.ts`, "discloses that requested exports were skipped
|
|
330
|
+
// when the report itself could not be written".
|
|
331
|
+
warnings.push(`could not write the ${exportFormats.join(", ")} export${exportFormats.length === 1 ? "" : "s"}: no report was written to anchor ${exportFormats.length === 1 ? "it" : "them"}`);
|
|
332
|
+
}
|
|
333
|
+
// One model, one renderer, one string: `--stdout md` and `--export md`
|
|
334
|
+
// cannot diverge. The gate widens past `reportPath` for the stream and
|
|
335
|
+
// only for the stream — the export loop below keeps its own gate, because
|
|
336
|
+
// the stem argument is about files pairing on disk and a stream is not a
|
|
337
|
+
// file. See `test/cli.test.ts`, "gives the stream and the file
|
|
338
|
+
// byte-identical Markdown from one model".
|
|
339
|
+
if (exportFormats.length > 0 || opts.stdout !== undefined) {
|
|
340
|
+
// Built once, and every requested export walks this one instance.
|
|
341
|
+
// `renderHtml` above still builds its own internally — its public
|
|
342
|
+
// signature takes the raw pieces and is out of this change's scope.
|
|
343
|
+
const exportModel = buildReportModel(changeset, findings, {
|
|
344
|
+
model: result.model,
|
|
345
|
+
warnings,
|
|
346
|
+
suppressed,
|
|
347
|
+
});
|
|
348
|
+
if (opts.stdout === "md") {
|
|
349
|
+
try {
|
|
350
|
+
markdown = exporters.md(exportModel);
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
// The same degradation rule the exports below apply: a renderer
|
|
354
|
+
// that threw costs the run that one document, never the findings or
|
|
355
|
+
// the exit code. stdout is then empty on a zero-exit run, which the
|
|
356
|
+
// action reads as a failed review rather than as a clean one.
|
|
357
|
+
warnings.push(`could not render the md review for stdout: ${err instanceof Error ? err.message : String(err)}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (reportPath) {
|
|
361
|
+
for (const format of exportFormats) {
|
|
362
|
+
try {
|
|
363
|
+
const content = format === "md" ? exporters.md(exportModel) : await exporters.pdf(exportModel);
|
|
364
|
+
exportPaths[format] = await writeExport(reportPath, format, content);
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
// The same degradation rule as the HTML report above: an export
|
|
368
|
+
// that failed to render or write costs the run that one file,
|
|
369
|
+
// never the findings or the exit code.
|
|
370
|
+
warnings.push(`could not write the ${format} export: ${err instanceof Error ? err.message : String(err)}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (opts.json) {
|
|
377
|
+
const counts = { verified: 0, inferred: 0, model: 0 };
|
|
378
|
+
for (const f of findings)
|
|
379
|
+
counts[f.tier]++;
|
|
380
|
+
// What the analyzers did not look at, in the machine-readable output too.
|
|
381
|
+
// Both renderers say it (see `deletedFilesNote`); a script reading `--json`
|
|
382
|
+
// could not see it at all, which made "stated the same way on every
|
|
383
|
+
// surface" false and left the one consumer that cannot read prose blind to
|
|
384
|
+
// the gap. The array is always present so a consumer can test it without
|
|
385
|
+
// branching on the key; the sentence is there only when there is one.
|
|
386
|
+
const deleted = deletedTypeScriptFiles(changeset);
|
|
387
|
+
return {
|
|
388
|
+
output: JSON.stringify({
|
|
389
|
+
range: changeset.range,
|
|
390
|
+
counts,
|
|
391
|
+
findings,
|
|
392
|
+
// Always present, zero included, so a consumer can test it
|
|
393
|
+
// without branching on the key — the same rule as `coverage`'s
|
|
394
|
+
// array below. Nonzero means reconcile's standalone-reach filter
|
|
395
|
+
// removed that many claim-free low-signal rows.
|
|
396
|
+
suppressed,
|
|
397
|
+
warnings,
|
|
398
|
+
coverage: {
|
|
399
|
+
deletedTypeScriptFiles: deleted,
|
|
400
|
+
...(deleted.length > 0 ? { note: deletedFilesNote(deleted) } : {}),
|
|
401
|
+
},
|
|
402
|
+
model: result.model,
|
|
403
|
+
skipped: result.skipped,
|
|
404
|
+
reportPath,
|
|
405
|
+
// Present exactly when `--export` was given — a consumer that
|
|
406
|
+
// asked can test the object without branching on the key, and one
|
|
407
|
+
// that did not ask never sees a field about a feature it did not
|
|
408
|
+
// use. A requested export that failed (or a nonzero-exit run,
|
|
409
|
+
// which writes nothing) is a missing key inside the object, with
|
|
410
|
+
// the reason in `warnings`.
|
|
411
|
+
...(exportFormats.length > 0 ? { exportPaths } : {}),
|
|
412
|
+
}, null, 2),
|
|
413
|
+
exitCode,
|
|
414
|
+
reportPath,
|
|
415
|
+
markdown,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
let output = renderTerminal(changeset, findings, reportPath, warnings, result.model, suppressed);
|
|
419
|
+
// One path line per written export, right under the "Full report" line the
|
|
420
|
+
// walker prints — labeled like every other path this surface shows, and
|
|
421
|
+
// only for exports that were actually written: a failed one already has
|
|
422
|
+
// its warning in the notes above.
|
|
423
|
+
for (const format of exportFormats) {
|
|
424
|
+
const written = exportPaths[format];
|
|
425
|
+
if (written)
|
|
426
|
+
output += ` ${format} export: ${labelConcealed(written)}\n`;
|
|
427
|
+
}
|
|
428
|
+
// Detection without action: urtext asks git whether the repository already
|
|
429
|
+
// ignores `.urtext/` (see `shouldSuggestGitignore` in report/write.ts,
|
|
430
|
+
// which also absorbs a git failure at this late stage — the review has
|
|
431
|
+
// already succeeded) but never writes to any ignore file itself — editing
|
|
432
|
+
// a file the repository's owner tracks is not this tool's call to make.
|
|
433
|
+
if (reportPath && (await shouldSuggestGitignore(root))) {
|
|
434
|
+
output += ` Tip: add ".urtext/" to this repository's .gitignore — review reports otherwise show up as untracked files.\n`;
|
|
435
|
+
}
|
|
436
|
+
return { output, exitCode, reportPath, markdown };
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Acts on `--open`. `openReport` ignores an absent path, which is right for it
|
|
440
|
+
* and wrong as the whole behaviour: a user who asked for the report to be
|
|
441
|
+
* opened and gets no window is owed the reason. There are two — the review
|
|
442
|
+
* failed hard enough that no report is written, and the write itself failed —
|
|
443
|
+
* and the output above states whichever applies, so this points at that rather
|
|
444
|
+
* than guessing which one it was.
|
|
445
|
+
*
|
|
446
|
+
* Separate from `main` because `main` reads `process.argv` and writes to the
|
|
447
|
+
* real stderr, so neither branch could be reached from a test through it. See
|
|
448
|
+
* `test/cli.test.ts`, "--open".
|
|
449
|
+
*/
|
|
450
|
+
export function openOrExplain(reportPath, onMessage, open = openReport) {
|
|
451
|
+
if (reportPath) {
|
|
452
|
+
open(reportPath);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
onMessage("urtext: --open had nothing to open; no report was written (see the notes above).\n");
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Which stream carries which document. Extracted from `main` for the reason
|
|
459
|
+
* `openOrExplain` was: `main` reads `process.argv` and writes to the real
|
|
460
|
+
* process streams, so neither branch is reachable from a test through it.
|
|
461
|
+
* Under `--stdout md` the Markdown owns stdout alone and the human render —
|
|
462
|
+
* notes, path lines, tip — moves to stderr; otherwise nothing moves. An
|
|
463
|
+
* absent `markdown` empties stdout rather than falling back to `output`: a
|
|
464
|
+
* review body sitting in a pipe looks like a successful review to anyone who
|
|
465
|
+
* only checks whether one arrived. See `test/cli.test.ts`, "--stdout md puts
|
|
466
|
+
* the Markdown on stdout and every other line on stderr" and "empties stdout
|
|
467
|
+
* entirely when the run produced no Markdown".
|
|
468
|
+
*/
|
|
469
|
+
export function streamsFor(result, opts) {
|
|
470
|
+
if (opts.stdout === undefined)
|
|
471
|
+
return { stdout: result.output, stderr: "" };
|
|
472
|
+
return { stdout: result.markdown ?? "", stderr: result.output };
|
|
473
|
+
}
|
|
474
|
+
export async function main() {
|
|
475
|
+
try {
|
|
476
|
+
// Inside the try: argument parsing now rejects unknown flags, and that
|
|
477
|
+
// message deserves the same one-line treatment as any other failure.
|
|
478
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
479
|
+
if (opts.help) {
|
|
480
|
+
process.stdout.write(USAGE);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const result = await review(process.cwd(), opts);
|
|
484
|
+
const { stdout, stderr } = streamsFor(result, opts);
|
|
485
|
+
// Guarded on non-empty, which is what "and nothing else" costs: the
|
|
486
|
+
// normalization below would otherwise turn an empty stdout into a lone
|
|
487
|
+
// newline on a broken `--stdout md` run. `output` is never empty — the
|
|
488
|
+
// terminal walker always prints a banner — so the default path writes
|
|
489
|
+
// exactly the bytes it wrote before this change.
|
|
490
|
+
if (stdout)
|
|
491
|
+
process.stdout.write(stdout.endsWith("\n") ? stdout : stdout + "\n");
|
|
492
|
+
if (stderr)
|
|
493
|
+
process.stderr.write(stderr.endsWith("\n") ? stderr : stderr + "\n");
|
|
494
|
+
process.exitCode = result.exitCode;
|
|
495
|
+
if (opts.open)
|
|
496
|
+
openOrExplain(result.reportPath, (m) => process.stderr.write(m));
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
process.stderr.write(`urtext: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
500
|
+
process.exitCode = 1;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ChangedFile, type RevRange } from "../types.js";
|
|
2
|
+
type ParsedFile = Omit<ChangedFile, "symbols">;
|
|
3
|
+
/**
|
|
4
|
+
* Parse `git diff` output. Expects `--no-color`; hunk context width does not
|
|
5
|
+
* matter because only the ranges are read.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseUnifiedDiff(text: string): ParsedFile[];
|
|
8
|
+
/**
|
|
9
|
+
* Raw diff text for a range. `-M` turns a delete/add pair into a rename so
|
|
10
|
+
* the analyzers can compare a file against its former self; it says nothing
|
|
11
|
+
* about untracked files, which `git diff` does not report at all — see
|
|
12
|
+
* countUntracked.
|
|
13
|
+
*
|
|
14
|
+
* `core.quotePath=false` keeps non-ASCII paths as literal UTF-8 instead of
|
|
15
|
+
* git's default C-style escaping (`"a/caf\303\251.ts"`), which the header
|
|
16
|
+
* regex cannot read.
|
|
17
|
+
*/
|
|
18
|
+
export declare function diffText(cwd: string, range: RevRange): Promise<string>;
|
|
19
|
+
/**
|
|
20
|
+
* How many untracked files the diff left out. Untracked files are invisible
|
|
21
|
+
* to `git diff`, so a newly created module — the case this tool exists for —
|
|
22
|
+
* produces no finding. Supporting them properly is a later plan; until then
|
|
23
|
+
* the count is reported so the silence is at least visible.
|
|
24
|
+
*
|
|
25
|
+
* urtext's own reports are excluded. `writeReport` leaves them in the
|
|
26
|
+
* repository under `REPORT_DIR`, so in a repository that does not ignore that
|
|
27
|
+
* directory every past review added one to this count — the second review
|
|
28
|
+
* announced an untracked file it had not reviewed, the third announced two,
|
|
29
|
+
* under the heading "This review is partial", about files urtext wrote itself
|
|
30
|
+
* and there is nothing to review in. Filtered by prefix here rather than by a
|
|
31
|
+
* `:(exclude)` pathspec so the reason lives next to the count that had it
|
|
32
|
+
* wrong.
|
|
33
|
+
*/
|
|
34
|
+
export declare function countUntracked(cwd: string): Promise<number>;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { git } from "./git.js";
|
|
2
|
+
import { REPORT_DIR, WORKTREE, } from "../types.js";
|
|
3
|
+
const HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
4
|
+
/**
|
|
5
|
+
* Parse `git diff` output. Expects `--no-color`; hunk context width does not
|
|
6
|
+
* matter because only the ranges are read.
|
|
7
|
+
*/
|
|
8
|
+
export function parseUnifiedDiff(text) {
|
|
9
|
+
const files = [];
|
|
10
|
+
let current = null;
|
|
11
|
+
for (const line of text.split("\n")) {
|
|
12
|
+
if (line.startsWith("diff --git ")) {
|
|
13
|
+
// "diff --git a/x b/y" — take the b-side as the path; a rename or a
|
|
14
|
+
// /dev/null marker later in the header corrects it.
|
|
15
|
+
const m = line.match(/^diff --git a\/(.*) b\/(.*)$/);
|
|
16
|
+
if (!m) {
|
|
17
|
+
// A header we cannot read means we do not know which file this entry
|
|
18
|
+
// is about. Recording it with an empty path would send every later
|
|
19
|
+
// stage — including readAt — at the repository root, and produce
|
|
20
|
+
// findings attributed to a file that does not exist. Skipping the
|
|
21
|
+
// entry loses one file; inventing one loses the reader's trust.
|
|
22
|
+
current = null;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
current = {
|
|
26
|
+
path: m[2],
|
|
27
|
+
status: "modified",
|
|
28
|
+
hunks: [],
|
|
29
|
+
};
|
|
30
|
+
files.push(current);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!current)
|
|
34
|
+
continue;
|
|
35
|
+
if (line.startsWith("new file mode")) {
|
|
36
|
+
current.status = "added";
|
|
37
|
+
}
|
|
38
|
+
else if (line.startsWith("deleted file mode")) {
|
|
39
|
+
current.status = "deleted";
|
|
40
|
+
}
|
|
41
|
+
else if (line.startsWith("rename from ")) {
|
|
42
|
+
current.status = "renamed";
|
|
43
|
+
current.previousPath = line.slice("rename from ".length);
|
|
44
|
+
}
|
|
45
|
+
else if (line.startsWith("rename to ")) {
|
|
46
|
+
current.path = line.slice("rename to ".length);
|
|
47
|
+
}
|
|
48
|
+
else if (line.startsWith("--- ") && line.endsWith("/dev/null")) {
|
|
49
|
+
current.status = "added";
|
|
50
|
+
}
|
|
51
|
+
else if (line.startsWith("+++ ") && line.endsWith("/dev/null")) {
|
|
52
|
+
current.status = "deleted";
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const h = line.match(HUNK);
|
|
56
|
+
if (h) {
|
|
57
|
+
const hunk = {
|
|
58
|
+
oldStart: Number(h[1]),
|
|
59
|
+
oldLines: h[2] === undefined ? 1 : Number(h[2]),
|
|
60
|
+
newStart: Number(h[3]),
|
|
61
|
+
newLines: h[4] === undefined ? 1 : Number(h[4]),
|
|
62
|
+
};
|
|
63
|
+
current.hunks.push(hunk);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Raw diff text for a range. `-M` turns a delete/add pair into a rename so
|
|
71
|
+
* the analyzers can compare a file against its former self; it says nothing
|
|
72
|
+
* about untracked files, which `git diff` does not report at all — see
|
|
73
|
+
* countUntracked.
|
|
74
|
+
*
|
|
75
|
+
* `core.quotePath=false` keeps non-ASCII paths as literal UTF-8 instead of
|
|
76
|
+
* git's default C-style escaping (`"a/caf\303\251.ts"`), which the header
|
|
77
|
+
* regex cannot read.
|
|
78
|
+
*/
|
|
79
|
+
export async function diffText(cwd, range) {
|
|
80
|
+
const args = [
|
|
81
|
+
"-c",
|
|
82
|
+
"core.quotePath=false",
|
|
83
|
+
"diff",
|
|
84
|
+
"--no-color",
|
|
85
|
+
"-U0",
|
|
86
|
+
"-M",
|
|
87
|
+
range.from,
|
|
88
|
+
];
|
|
89
|
+
if (range.to !== WORKTREE)
|
|
90
|
+
args.push(range.to);
|
|
91
|
+
return git(args, cwd);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* How many untracked files the diff left out. Untracked files are invisible
|
|
95
|
+
* to `git diff`, so a newly created module — the case this tool exists for —
|
|
96
|
+
* produces no finding. Supporting them properly is a later plan; until then
|
|
97
|
+
* the count is reported so the silence is at least visible.
|
|
98
|
+
*
|
|
99
|
+
* urtext's own reports are excluded. `writeReport` leaves them in the
|
|
100
|
+
* repository under `REPORT_DIR`, so in a repository that does not ignore that
|
|
101
|
+
* directory every past review added one to this count — the second review
|
|
102
|
+
* announced an untracked file it had not reviewed, the third announced two,
|
|
103
|
+
* under the heading "This review is partial", about files urtext wrote itself
|
|
104
|
+
* and there is nothing to review in. Filtered by prefix here rather than by a
|
|
105
|
+
* `:(exclude)` pathspec so the reason lives next to the count that had it
|
|
106
|
+
* wrong.
|
|
107
|
+
*/
|
|
108
|
+
export async function countUntracked(cwd) {
|
|
109
|
+
const out = await git(["ls-files", "--others", "--exclude-standard", "-z"], cwd);
|
|
110
|
+
// `ls-files` reports root-relative paths with forward slashes on every
|
|
111
|
+
// platform, so this prefix is the whole test.
|
|
112
|
+
const ours = `${REPORT_DIR}/`;
|
|
113
|
+
return out
|
|
114
|
+
.split("\0")
|
|
115
|
+
.filter((p) => p.length > 0 && !p.startsWith(ours)).length;
|
|
116
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type RevRange } from "../types.js";
|
|
2
|
+
/** Run git and return stdout. Throws with stderr attached on failure. */
|
|
3
|
+
export declare function git(args: string[], cwd: string): Promise<string>;
|
|
4
|
+
export declare function repoRoot(cwd: string): Promise<string>;
|
|
5
|
+
/**
|
|
6
|
+
* The branch changes are measured against. Prefers the remote's declared
|
|
7
|
+
* HEAD, then common names, remote before local.
|
|
8
|
+
*/
|
|
9
|
+
export declare function defaultBranch(cwd: string): Promise<string>;
|
|
10
|
+
export declare function resolveRange(cwd: string, spec?: string): Promise<RevRange>;
|
|
11
|
+
/** File contents at a revision, or null when the file is absent there. */
|
|
12
|
+
export declare function readAt(cwd: string, rev: string, path: string): Promise<string | null>;
|