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
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>;