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,206 @@
|
|
|
1
|
+
import { labelConcealed } from "./conceal.js";
|
|
2
|
+
import { buildReportModel, location, NO_FINDINGS_COPY, plainText, TIER_ORDER, TIER_WORD, } from "./model.js";
|
|
3
|
+
/**
|
|
4
|
+
* The terminal surface, a walker over the report model. Every sentence, tier,
|
|
5
|
+
* glyph, ordering, and disclosure printed here is decided by
|
|
6
|
+
* `buildReportModel` — what this file owns is format mechanics: spacing,
|
|
7
|
+
* indentation, line wrapping, the gutter labels, and the width-limited
|
|
8
|
+
* excerpt cut. Model text arrives with concealment already applied
|
|
9
|
+
* (structurally, as `ConcealSegment` arrays, or as labelled strings for
|
|
10
|
+
* identifier-shaped fields), so this walker joins segments through
|
|
11
|
+
* `plainText` and never re-derives concealment for model content.
|
|
12
|
+
*/
|
|
13
|
+
function wrap(text, width, indent) {
|
|
14
|
+
const words = text.split(/\s+/);
|
|
15
|
+
const lines = [];
|
|
16
|
+
let line = "";
|
|
17
|
+
for (const w of words) {
|
|
18
|
+
if (line && (line + " " + w).length > width) {
|
|
19
|
+
lines.push(indent + line);
|
|
20
|
+
line = w;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
line = line ? line + " " + w : w;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (line)
|
|
27
|
+
lines.push(indent + line);
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
/** How many evidence refs to show per finding. A summary, not a dump. The model carries every ref; this cap is this surface's presentation density. */
|
|
31
|
+
const EVIDENCE_SHOWN = 2;
|
|
32
|
+
/** Where the excerpt cut lands, counted in rendered code points. */
|
|
33
|
+
const EXCERPT_WIDTH = 56;
|
|
34
|
+
/** The wrap width for body and reasoning paragraphs. */
|
|
35
|
+
const BODY_WIDTH = 64;
|
|
36
|
+
/**
|
|
37
|
+
* Whitespace-trims an excerpt's edges without ever deleting a concealed
|
|
38
|
+
* segment: only ordinary text segments are trimmed. A raw-string trim would
|
|
39
|
+
* silently drop the whitespace-classed concealing characters at either edge;
|
|
40
|
+
* here they stay visible as their labels, which is the point of labelling
|
|
41
|
+
* them.
|
|
42
|
+
*/
|
|
43
|
+
function trimSegments(segments) {
|
|
44
|
+
const out = segments.map((s) => ({ ...s }));
|
|
45
|
+
while (out.length > 0 && out[0].kind === "text") {
|
|
46
|
+
out[0].text = out[0].text.replace(/^\s+/, "");
|
|
47
|
+
if (out[0].text)
|
|
48
|
+
break;
|
|
49
|
+
out.shift();
|
|
50
|
+
}
|
|
51
|
+
while (out.length > 0 && out[out.length - 1].kind === "text") {
|
|
52
|
+
const last = out[out.length - 1];
|
|
53
|
+
last.text = last.text.replace(/\s+$/, "");
|
|
54
|
+
if (last.text)
|
|
55
|
+
break;
|
|
56
|
+
out.pop();
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Width-limits an excerpt to one terminal line, segment-aware two ways.
|
|
62
|
+
* Counted in code points, not UTF-16 units — `String#slice` counts units, so
|
|
63
|
+
* an astral character straddling the cut left a lone surrogate that renders
|
|
64
|
+
* as U+FFFD; see `test/report/terminal.test.ts`, "never splits a surrogate
|
|
65
|
+
* pair at the excerpt truncation boundary". And the cut never lands inside a
|
|
66
|
+
* concealed segment's label: a bisected label would misstate the code point,
|
|
67
|
+
* so the cut falls before the whole label instead — the design spec's
|
|
68
|
+
* addendum accepts the shorter line this can produce on concealed input near
|
|
69
|
+
* EXCERPT_WIDTH.
|
|
70
|
+
*/
|
|
71
|
+
function excerpt(segments) {
|
|
72
|
+
const trimmed = trimSegments(segments);
|
|
73
|
+
const full = plainText(trimmed);
|
|
74
|
+
if ([...full].length <= EXCERPT_WIDTH)
|
|
75
|
+
return full;
|
|
76
|
+
// One code point of the width is reserved for the ellipsis, as before.
|
|
77
|
+
let budget = EXCERPT_WIDTH - 1;
|
|
78
|
+
let out = "";
|
|
79
|
+
for (const s of trimmed) {
|
|
80
|
+
if (s.kind === "concealed") {
|
|
81
|
+
const label = `[${s.text}]`;
|
|
82
|
+
if (label.length > budget)
|
|
83
|
+
break;
|
|
84
|
+
out += label;
|
|
85
|
+
budget -= label.length;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const points = [...s.text];
|
|
89
|
+
if (points.length > budget) {
|
|
90
|
+
out += points.slice(0, budget).join("");
|
|
91
|
+
budget = 0;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
out += s.text;
|
|
95
|
+
budget -= points.length;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (budget === 0)
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
return out + "…";
|
|
102
|
+
}
|
|
103
|
+
export function renderTerminal(changeset, findings, reportPath, warnings = [], model, suppressed = 0) {
|
|
104
|
+
const m = buildReportModel(changeset, findings, { model, warnings, suppressed });
|
|
105
|
+
const out = [];
|
|
106
|
+
out.push("");
|
|
107
|
+
out.push(`urtext · ${m.scope}`);
|
|
108
|
+
// The disclosures, in model order — warnings first, then the untracked
|
|
109
|
+
// note — with the deleted-file coverage note beside them: each is a
|
|
110
|
+
// coverage statement no finding below can show, and they come before the
|
|
111
|
+
// findings because a reader has to know the review is partial before
|
|
112
|
+
// reading the list and concluding nothing else was found.
|
|
113
|
+
for (const note of m.notes) {
|
|
114
|
+
out.push(` Note: ${note}`);
|
|
115
|
+
}
|
|
116
|
+
if (m.coverageNote) {
|
|
117
|
+
out.push(` Note: ${m.coverageNote}`);
|
|
118
|
+
}
|
|
119
|
+
// Spacing only: analyzer warnings get a separating blank line before the
|
|
120
|
+
// findings, exactly as this surface always printed them; the untracked and
|
|
121
|
+
// coverage notes alone flow straight into the list.
|
|
122
|
+
if (warnings.length > 0)
|
|
123
|
+
out.push("");
|
|
124
|
+
if (m.findings.length === 0) {
|
|
125
|
+
out.push(` ${NO_FINDINGS_COPY}`);
|
|
126
|
+
out.push("");
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const parts = [];
|
|
130
|
+
for (const tier of TIER_ORDER) {
|
|
131
|
+
if (m.counts[tier])
|
|
132
|
+
parts.push(`${m.counts[tier]} ${TIER_WORD[tier]}`);
|
|
133
|
+
}
|
|
134
|
+
out.push(` EVIDENCE ${parts.join(" · ")}`);
|
|
135
|
+
// Named right under the tier counts, not buried in a footer: a ● or ○
|
|
136
|
+
// badge asserts "a machine looked at this", and that assertion is only
|
|
137
|
+
// checkable if the reader also knows which machine. The gate — a model
|
|
138
|
+
// name AND a model-derived tier below — is the model's; `provenance` is
|
|
139
|
+
// simply absent otherwise.
|
|
140
|
+
if (m.provenance) {
|
|
141
|
+
out.push(` MODEL ${m.provenance}`);
|
|
142
|
+
}
|
|
143
|
+
// Under the provenance line, or under EVIDENCE when there is none, and
|
|
144
|
+
// before the blank line that separates the header from the findings: the
|
|
145
|
+
// badge is explained before the reader meets it.
|
|
146
|
+
if (m.beyondIntentLegend) {
|
|
147
|
+
out.push(` ${m.beyondIntentLegend}`);
|
|
148
|
+
}
|
|
149
|
+
out.push("");
|
|
150
|
+
for (const f of m.findings) {
|
|
151
|
+
out.push(` ${f.glyph} ${plainText(f.headline)} [${f.tier}]` +
|
|
152
|
+
(f.beyondIntent ? ` (${f.beyondIntent})` : ""));
|
|
153
|
+
for (const paragraph of f.body) {
|
|
154
|
+
out.push(...wrap(plainText(paragraph), BODY_WIDTH, " "));
|
|
155
|
+
}
|
|
156
|
+
if (f.tier === "model" && f.modelNote) {
|
|
157
|
+
// A model-tier finding's whole body is the model's prose, carried in
|
|
158
|
+
// `modelNote` so it can never travel without attribution. On this
|
|
159
|
+
// surface the [model] badge on the headline is that attribution, as
|
|
160
|
+
// it always was, and the prose prints as the body.
|
|
161
|
+
out.push(...wrap(plainText(f.modelNote.text), BODY_WIDTH, " "));
|
|
162
|
+
}
|
|
163
|
+
else if (f.modelNote && m.modelName) {
|
|
164
|
+
// The analyzer's paragraphs already said what was found; this is the
|
|
165
|
+
// model's added explanation of why it matters, labelled rather than
|
|
166
|
+
// folded silently into the same paragraph — a reader deciding how
|
|
167
|
+
// much to trust an [inferred] finding needs to see the two apart.
|
|
168
|
+
//
|
|
169
|
+
// Gated on the recorded model name, not just the note's presence:
|
|
170
|
+
// model prose with no name attached to it is the one rendering state
|
|
171
|
+
// this surface must never produce, and the provenance line above is
|
|
172
|
+
// under the same gate. See `test/report/terminal.test.ts`, "never
|
|
173
|
+
// prints model prose without its attribution".
|
|
174
|
+
out.push(...wrap(`model: ${plainText(f.modelNote.text)}`, BODY_WIDTH, " "));
|
|
175
|
+
}
|
|
176
|
+
// The evidence is the point. A tier badge with nothing checkable under
|
|
177
|
+
// it asks the reader to take the claim on faith, which is exactly what
|
|
178
|
+
// "verified" is supposed to replace.
|
|
179
|
+
for (const e of f.evidence.slice(0, EVIDENCE_SHOWN)) {
|
|
180
|
+
out.push(` ${location(e)} ${excerpt(e.excerpt)}`);
|
|
181
|
+
}
|
|
182
|
+
const rest = f.evidence.length - EVIDENCE_SHOWN;
|
|
183
|
+
if (rest > 0)
|
|
184
|
+
out.push(` … ${rest} more`);
|
|
185
|
+
out.push("");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Composed by the model (see `ReportModel.filterNote`); absent entirely
|
|
189
|
+
// when nothing was suppressed — see `test/report/terminal.test.ts`,
|
|
190
|
+
// "prints no filter footnote when nothing was suppressed".
|
|
191
|
+
if (m.filterNote) {
|
|
192
|
+
out.push(` ${m.filterNote}`);
|
|
193
|
+
out.push("");
|
|
194
|
+
}
|
|
195
|
+
// Outside the findings branch on purpose: a clean review (no findings)
|
|
196
|
+
// still writes a report, and the reader needs to be told where it went
|
|
197
|
+
// exactly as much as a reader who scrolled past a full list of findings
|
|
198
|
+
// does — see `test/report/terminal.test.ts`, "prints the report path even
|
|
199
|
+
// when there are no findings". The path is the one string on this surface
|
|
200
|
+
// that does not come from the model, so the walker labels it itself.
|
|
201
|
+
if (reportPath) {
|
|
202
|
+
out.push(` Full report: ${labelConcealed(reportPath)}`);
|
|
203
|
+
out.push("");
|
|
204
|
+
}
|
|
205
|
+
return out.join("\n");
|
|
206
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem-safe, lexicographically sortable timestamp for a report's
|
|
3
|
+
* filename. `Date#toISOString` already sorts lexicographically in step with
|
|
4
|
+
* time — fixed field widths, most-significant field first, UTC so there is
|
|
5
|
+
* no offset to compare across — but a colon is one of the handful of
|
|
6
|
+
* characters Windows forbids in a filename, and `toISOString` puts two of
|
|
7
|
+
* them in the time-of-day. Substituting a hyphen keeps every other property
|
|
8
|
+
* of the format and loses none of its sort order, since a hyphen still sorts
|
|
9
|
+
* before every digit in ASCII.
|
|
10
|
+
*/
|
|
11
|
+
export declare function reportTimestamp(date?: Date): string;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the reviewed repository already ignores `.urtext/`, read-only.
|
|
14
|
+
* urtext writes into the repository it is reviewing, but it must never edit
|
|
15
|
+
* a file the repository's owner tracks — a diff review tool that patches
|
|
16
|
+
* your .gitignore to tidy up after itself has changed the very thing it was
|
|
17
|
+
* asked to look at, and the next review of the working tree would report
|
|
18
|
+
* that change as if the user had made it. The caller decides what to do
|
|
19
|
+
* with a `false` result (see `review` in `cli.ts`, which prints a one-line
|
|
20
|
+
* suggestion rather than acting on it).
|
|
21
|
+
*
|
|
22
|
+
* Delegated to `git check-ignore` rather than reading and pattern-matching
|
|
23
|
+
* `.gitignore` directly: a repository can also exclude a path through
|
|
24
|
+
* `.git/info/exclude`, a user's global `core.excludesFile`, or a glob like
|
|
25
|
+
* `**\/.urtext/` that a root-only `.gitignore` read would never recognise as
|
|
26
|
+
* covering it — git already resolves all of those into one answer, so
|
|
27
|
+
* re-deriving a subset of that logic here would only reproduce it worse.
|
|
28
|
+
*
|
|
29
|
+
* The query names a file inside the directory, not the directory itself. On
|
|
30
|
+
* git for Windows, a CRLF-encoded `.gitignore` whose blank lines are a bare
|
|
31
|
+
* carriage return makes `check-ignore` match ANY query ending in a slash
|
|
32
|
+
* against one of those blank lines — the first real repository this tool
|
|
33
|
+
* reviewed answered "ignored" for a directory nothing ignored, and the tip
|
|
34
|
+
* was silently withheld. A child path never ends in a slash, so it is immune;
|
|
35
|
+
* and because a directory pattern like `.urtext/` covers everything beneath
|
|
36
|
+
* it, the child matches every pattern shape that covers the directory —
|
|
37
|
+
* whether or not either of them exists on disk. The probe file is never
|
|
38
|
+
* created; it exists only as a question.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isUrtextGitignored(root: string): Promise<boolean>;
|
|
41
|
+
/**
|
|
42
|
+
* Whether `review` in `../cli.ts` should print its gitignore tip. Distinct
|
|
43
|
+
* from `isUrtextGitignored`, whose propagate-real-failures contract is right
|
|
44
|
+
* for a function answering a question about the repository — but wrong for
|
|
45
|
+
* this call site: the tip is printed after the review has succeeded and the
|
|
46
|
+
* report is on disk, so a git failure here (a repository gone bad between
|
|
47
|
+
* the review and this last lookup) used to reject the whole `review()` call
|
|
48
|
+
* — discarding the completed terminal output while leaving a report on disk
|
|
49
|
+
* beside a nonzero exit, the exact disagreement this module's write path is
|
|
50
|
+
* designed to avoid. A tip that cannot be verified is simply not offered.
|
|
51
|
+
*/
|
|
52
|
+
export declare function shouldSuggestGitignore(root: string): Promise<boolean>;
|
|
53
|
+
/**
|
|
54
|
+
* Writes the rendered report under the repository root, not `process.cwd()`
|
|
55
|
+
* — `urtext review` must land the file in the same place whether it runs at
|
|
56
|
+
* the root or three directories down, and only the root is stable across
|
|
57
|
+
* both. See test/cli.test.ts, "writes the report under the repository
|
|
58
|
+
* root, whether invoked from the root or a subdirectory".
|
|
59
|
+
*/
|
|
60
|
+
export declare function writeReport(root: string, html: string): Promise<string>;
|
|
61
|
+
/**
|
|
62
|
+
* Every format `--export` can write, in the order the flag's usage copy
|
|
63
|
+
* names them. Owned beside the writer so the parser in `../cli.ts`, the
|
|
64
|
+
* writer below, and the `exportPaths` keys in the `--json` output all read
|
|
65
|
+
* from one list.
|
|
66
|
+
*/
|
|
67
|
+
export declare const EXPORT_FORMATS: readonly ["md", "pdf"];
|
|
68
|
+
export type ExportFormat = (typeof EXPORT_FORMATS)[number];
|
|
69
|
+
/**
|
|
70
|
+
* Writes one export beside the already-written HTML report, sharing its
|
|
71
|
+
* timestamp stem — `review-<stamp>.md` next to `review-<stamp>.html` — so a
|
|
72
|
+
* run's outputs sort and pair by name. Derived from the report's path rather
|
|
73
|
+
* than a second `reportTimestamp()` call, which could cross a millisecond
|
|
74
|
+
* boundary and split one run's files across two stems. Takes the report path
|
|
75
|
+
* as its anchor deliberately: an export cannot be written when no report
|
|
76
|
+
* was, the same rule `review` in `../cli.ts` applies to nonzero-exit runs.
|
|
77
|
+
*/
|
|
78
|
+
export declare function writeExport(reportPath: string, format: ExportFormat, content: string | Buffer): Promise<string>;
|
|
79
|
+
/**
|
|
80
|
+
* The subset of a `ChildProcess` the opener touches — `on("error", ...)` and
|
|
81
|
+
* `unref()` — so a test can inject a stand-in that never launches a real
|
|
82
|
+
* process without also implementing the rest of `ChildProcess`'s surface.
|
|
83
|
+
* `spawn`'s real return value satisfies this structurally.
|
|
84
|
+
*/
|
|
85
|
+
export interface OpenedProcess {
|
|
86
|
+
on(event: "error", listener: (err: Error) => void): void;
|
|
87
|
+
unref(): void;
|
|
88
|
+
}
|
|
89
|
+
/** The subset of `child_process.spawn` the opener needs. */
|
|
90
|
+
export type SpawnFn = (command: string, args: readonly string[], options: {
|
|
91
|
+
detached: boolean;
|
|
92
|
+
stdio: "ignore";
|
|
93
|
+
}) => OpenedProcess;
|
|
94
|
+
/**
|
|
95
|
+
* Opens a written report with the platform's default handler. A no-op when
|
|
96
|
+
* no report was written — see test/report/write.test.ts, "is a no-op when
|
|
97
|
+
* no report was written".
|
|
98
|
+
*
|
|
99
|
+
* Detached and unreferenced so the opener's own lifetime never holds the CLI
|
|
100
|
+
* process open waiting for it, and given an `error` listener for the same
|
|
101
|
+
* reason: an unhandled `error` on a `ChildProcess` — the opener binary
|
|
102
|
+
* missing, most likely — would otherwise surface as an uncaught exception
|
|
103
|
+
* in a process that has already finished its own work.
|
|
104
|
+
*/
|
|
105
|
+
export declare function openReport(path: string | undefined, spawnFn?: SpawnFn): void;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { git } from "../extract/git.js";
|
|
5
|
+
import { REPORT_DIR } from "../types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Filesystem-safe, lexicographically sortable timestamp for a report's
|
|
8
|
+
* filename. `Date#toISOString` already sorts lexicographically in step with
|
|
9
|
+
* time — fixed field widths, most-significant field first, UTC so there is
|
|
10
|
+
* no offset to compare across — but a colon is one of the handful of
|
|
11
|
+
* characters Windows forbids in a filename, and `toISOString` puts two of
|
|
12
|
+
* them in the time-of-day. Substituting a hyphen keeps every other property
|
|
13
|
+
* of the format and loses none of its sort order, since a hyphen still sorts
|
|
14
|
+
* before every digit in ASCII.
|
|
15
|
+
*/
|
|
16
|
+
export function reportTimestamp(date = new Date()) {
|
|
17
|
+
return date.toISOString().replace(/:/g, "-");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `git check-ignore` distinguishes a match from a non-match by exit status,
|
|
21
|
+
* not by output — with `-q` it prints nothing either way. The exit status
|
|
22
|
+
* this function reads as "no rule matched" is not an error, just the
|
|
23
|
+
* negative answer; anything else (a repository problem, for instance) is a
|
|
24
|
+
* real failure and must propagate rather than be read as "not ignored".
|
|
25
|
+
*/
|
|
26
|
+
function meansNotIgnored(err) {
|
|
27
|
+
return err instanceof Error && "code" in err && err.code === 1;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Whether the reviewed repository already ignores `.urtext/`, read-only.
|
|
31
|
+
* urtext writes into the repository it is reviewing, but it must never edit
|
|
32
|
+
* a file the repository's owner tracks — a diff review tool that patches
|
|
33
|
+
* your .gitignore to tidy up after itself has changed the very thing it was
|
|
34
|
+
* asked to look at, and the next review of the working tree would report
|
|
35
|
+
* that change as if the user had made it. The caller decides what to do
|
|
36
|
+
* with a `false` result (see `review` in `cli.ts`, which prints a one-line
|
|
37
|
+
* suggestion rather than acting on it).
|
|
38
|
+
*
|
|
39
|
+
* Delegated to `git check-ignore` rather than reading and pattern-matching
|
|
40
|
+
* `.gitignore` directly: a repository can also exclude a path through
|
|
41
|
+
* `.git/info/exclude`, a user's global `core.excludesFile`, or a glob like
|
|
42
|
+
* `**\/.urtext/` that a root-only `.gitignore` read would never recognise as
|
|
43
|
+
* covering it — git already resolves all of those into one answer, so
|
|
44
|
+
* re-deriving a subset of that logic here would only reproduce it worse.
|
|
45
|
+
*
|
|
46
|
+
* The query names a file inside the directory, not the directory itself. On
|
|
47
|
+
* git for Windows, a CRLF-encoded `.gitignore` whose blank lines are a bare
|
|
48
|
+
* carriage return makes `check-ignore` match ANY query ending in a slash
|
|
49
|
+
* against one of those blank lines — the first real repository this tool
|
|
50
|
+
* reviewed answered "ignored" for a directory nothing ignored, and the tip
|
|
51
|
+
* was silently withheld. A child path never ends in a slash, so it is immune;
|
|
52
|
+
* and because a directory pattern like `.urtext/` covers everything beneath
|
|
53
|
+
* it, the child matches every pattern shape that covers the directory —
|
|
54
|
+
* whether or not either of them exists on disk. The probe file is never
|
|
55
|
+
* created; it exists only as a question.
|
|
56
|
+
*/
|
|
57
|
+
export async function isUrtextGitignored(root) {
|
|
58
|
+
try {
|
|
59
|
+
await git(["check-ignore", "-q", `${REPORT_DIR}/probe`], root);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (meansNotIgnored(err))
|
|
64
|
+
return false;
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether `review` in `../cli.ts` should print its gitignore tip. Distinct
|
|
70
|
+
* from `isUrtextGitignored`, whose propagate-real-failures contract is right
|
|
71
|
+
* for a function answering a question about the repository — but wrong for
|
|
72
|
+
* this call site: the tip is printed after the review has succeeded and the
|
|
73
|
+
* report is on disk, so a git failure here (a repository gone bad between
|
|
74
|
+
* the review and this last lookup) used to reject the whole `review()` call
|
|
75
|
+
* — discarding the completed terminal output while leaving a report on disk
|
|
76
|
+
* beside a nonzero exit, the exact disagreement this module's write path is
|
|
77
|
+
* designed to avoid. A tip that cannot be verified is simply not offered.
|
|
78
|
+
*/
|
|
79
|
+
export async function shouldSuggestGitignore(root) {
|
|
80
|
+
try {
|
|
81
|
+
return !(await isUrtextGitignored(root));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Writes the rendered report under the repository root, not `process.cwd()`
|
|
89
|
+
* — `urtext review` must land the file in the same place whether it runs at
|
|
90
|
+
* the root or three directories down, and only the root is stable across
|
|
91
|
+
* both. See test/cli.test.ts, "writes the report under the repository
|
|
92
|
+
* root, whether invoked from the root or a subdirectory".
|
|
93
|
+
*/
|
|
94
|
+
export async function writeReport(root, html) {
|
|
95
|
+
const dir = join(root, REPORT_DIR);
|
|
96
|
+
await mkdir(dir, { recursive: true });
|
|
97
|
+
const path = join(dir, `review-${reportTimestamp()}.html`);
|
|
98
|
+
await writeFile(path, html, "utf8");
|
|
99
|
+
return path;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Every format `--export` can write, in the order the flag's usage copy
|
|
103
|
+
* names them. Owned beside the writer so the parser in `../cli.ts`, the
|
|
104
|
+
* writer below, and the `exportPaths` keys in the `--json` output all read
|
|
105
|
+
* from one list.
|
|
106
|
+
*/
|
|
107
|
+
export const EXPORT_FORMATS = ["md", "pdf"];
|
|
108
|
+
/**
|
|
109
|
+
* Writes one export beside the already-written HTML report, sharing its
|
|
110
|
+
* timestamp stem — `review-<stamp>.md` next to `review-<stamp>.html` — so a
|
|
111
|
+
* run's outputs sort and pair by name. Derived from the report's path rather
|
|
112
|
+
* than a second `reportTimestamp()` call, which could cross a millisecond
|
|
113
|
+
* boundary and split one run's files across two stems. Takes the report path
|
|
114
|
+
* as its anchor deliberately: an export cannot be written when no report
|
|
115
|
+
* was, the same rule `review` in `../cli.ts` applies to nonzero-exit runs.
|
|
116
|
+
*/
|
|
117
|
+
export async function writeExport(reportPath, format, content) {
|
|
118
|
+
const path = reportPath.replace(/\.html$/, `.${format}`);
|
|
119
|
+
await writeFile(path, content);
|
|
120
|
+
return path;
|
|
121
|
+
}
|
|
122
|
+
function openerCommand(path) {
|
|
123
|
+
if (process.platform === "win32") {
|
|
124
|
+
// Not `cmd /c start`: `cmd.exe` re-tokenizes its own already-built
|
|
125
|
+
// command line by shell rules, under which `&`, `|`, `<`, and `>` are
|
|
126
|
+
// operators rather than path characters — a repository rooted at
|
|
127
|
+
// `C:\R&D\...` produced a report path that opened fine up to the `&`
|
|
128
|
+
// and then ran whatever followed it as a second command. `rundll32` is
|
|
129
|
+
// launched directly, with no shell in between: Node builds the child's
|
|
130
|
+
// command line without cmd, and neither `CreateProcess` nor rundll32's
|
|
131
|
+
// own tail parsing treats `&`, `|`, `<`, or `>` as operators — rundll32
|
|
132
|
+
// hands everything after the entry-point name to `FileProtocolHandler`
|
|
133
|
+
// as one raw string. `url.dll,FileProtocolHandler` is the standard
|
|
134
|
+
// entry point for "open this with whatever the shell would open it
|
|
135
|
+
// with", for a local path exactly as much as for a URL.
|
|
136
|
+
return { command: "rundll32", args: ["url.dll,FileProtocolHandler", path] };
|
|
137
|
+
}
|
|
138
|
+
if (process.platform === "darwin")
|
|
139
|
+
return { command: "open", args: [path] };
|
|
140
|
+
return { command: "xdg-open", args: [path] };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Opens a written report with the platform's default handler. A no-op when
|
|
144
|
+
* no report was written — see test/report/write.test.ts, "is a no-op when
|
|
145
|
+
* no report was written".
|
|
146
|
+
*
|
|
147
|
+
* Detached and unreferenced so the opener's own lifetime never holds the CLI
|
|
148
|
+
* process open waiting for it, and given an `error` listener for the same
|
|
149
|
+
* reason: an unhandled `error` on a `ChildProcess` — the opener binary
|
|
150
|
+
* missing, most likely — would otherwise surface as an uncaught exception
|
|
151
|
+
* in a process that has already finished its own work.
|
|
152
|
+
*/
|
|
153
|
+
export function openReport(path, spawnFn = spawn) {
|
|
154
|
+
if (!path)
|
|
155
|
+
return;
|
|
156
|
+
const { command, args } = openerCommand(path);
|
|
157
|
+
const child = spawnFn(command, args, { detached: true, stdio: "ignore" });
|
|
158
|
+
child.on("error", () => { });
|
|
159
|
+
child.unref();
|
|
160
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Claim, Fact, Finding, Tier } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tunable in one place on purpose: these weights will need adjusting once
|
|
4
|
+
* they have been run against real diffs.
|
|
5
|
+
*/
|
|
6
|
+
export declare const WEIGHTS: {
|
|
7
|
+
factKind: {
|
|
8
|
+
guard_removed: number;
|
|
9
|
+
signature_changed: number;
|
|
10
|
+
export_removed: number;
|
|
11
|
+
effect_added: number;
|
|
12
|
+
blast_radius: number;
|
|
13
|
+
export_added: number;
|
|
14
|
+
effect_removed: number;
|
|
15
|
+
citation_rot: number;
|
|
16
|
+
};
|
|
17
|
+
effect: {
|
|
18
|
+
network: number;
|
|
19
|
+
database: number;
|
|
20
|
+
process: number;
|
|
21
|
+
filesystem: number;
|
|
22
|
+
env: number;
|
|
23
|
+
timing: number;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export declare function scoreFact(fact: Fact): number;
|
|
27
|
+
/**
|
|
28
|
+
* The lowest score any real analyzer fact can produce, across every fact
|
|
29
|
+
* kind and — for the two effect kinds, whose score also depends on which
|
|
30
|
+
* effect fired — every effect. Computed by actually calling `scoreFact` on
|
|
31
|
+
* a synthetic fact of each shape, not by reading `WEIGHTS.factKind` and
|
|
32
|
+
* taking its minimum directly: that minimum (shared by `blast_radius` and
|
|
33
|
+
* `effect_removed`) is a real, producible score — `effect_removed` ×
|
|
34
|
+
* `network` or `database` reaches it exactly, and so does `blast_radius` at
|
|
35
|
+
* one reference — but it is not the *lowest* producible score. `scoreFact`
|
|
36
|
+
* also multiplies `effect_removed`'s (and `effect_added`'s) base by an
|
|
37
|
+
* effect weight that can sit below `network`/`database`'s (`timing` is the
|
|
38
|
+
* lowest), which takes `effect_removed` down further still — a score
|
|
39
|
+
* `WEIGHTS.factKind` has no entry for,
|
|
40
|
+
* because it only lists each kind's base, not what the formula built on top
|
|
41
|
+
* of that base can still produce. Calling `scoreFact` directly means this
|
|
42
|
+
* tracks the real formula (the effect multiplier, the blast_radius log
|
|
43
|
+
* curve) instead of a hand-copied table that cannot see below its own
|
|
44
|
+
* bases. `reconcile.ts` derives `MODEL_CEILING` from this, so a claim can
|
|
45
|
+
* never be scored above the weakest thing an analyzer can find.
|
|
46
|
+
*/
|
|
47
|
+
export declare function minPossibleAnalyzerScore(): number;
|
|
48
|
+
/**
|
|
49
|
+
* The evidence tier for a finding, from what produced it.
|
|
50
|
+
*
|
|
51
|
+
* - `verified` — an analyzer found it and can point at the code.
|
|
52
|
+
* - `inferred` — the model explained something an analyzer found. The fact
|
|
53
|
+
* is still true; the *explanation* is the model's, so the finding is only
|
|
54
|
+
* as good as that explanation.
|
|
55
|
+
* - `model` — the model alone. Nothing mechanical corroborates it.
|
|
56
|
+
*
|
|
57
|
+
* A fact always beats a claim: if there is a fact, the tier can never be
|
|
58
|
+
* `model`, because something machine-checked is underneath it.
|
|
59
|
+
*/
|
|
60
|
+
export declare function tierFor(fact: Fact | undefined, claim: Claim | undefined): Tier;
|
|
61
|
+
/**
|
|
62
|
+
* Longest signature text, in code points, that a finding body renders
|
|
63
|
+
* verbatim on either side of a was→now sentence. Deliberately far below
|
|
64
|
+
* `MAX_SIGNATURE_LENGTH` (the storage cap in `../analyze/surface.ts`): that
|
|
65
|
+
* one bounds what a fact *carries*, this one bounds what a sentence *shows*.
|
|
66
|
+
* The first dogfood run printed a JWT-sized string literal verbatim into a
|
|
67
|
+
* body, which both drowned the sentence and republished a secret-looking
|
|
68
|
+
* literal in a report — the middle-truncated rendering is readability plus
|
|
69
|
+
* soft redaction. Nothing is hidden from a reader who wants the full text:
|
|
70
|
+
* it remains in the diff itself, at the declaration every such finding
|
|
71
|
+
* anchors its evidence to. `test/comment-contract.test.ts` derives part of
|
|
72
|
+
* its forbidden set from this, so comments name it rather than restating
|
|
73
|
+
* its value.
|
|
74
|
+
*/
|
|
75
|
+
export declare const MAX_RENDERED_SIGNATURE = 120;
|
|
76
|
+
export declare function toFinding(fact: Fact): Finding;
|
|
77
|
+
/**
|
|
78
|
+
* `rank` plus the map a model claim needs to find a fact that no longer has
|
|
79
|
+
* a finding of its own: `absorbedBy` maps a folded/grouped fact's id to the
|
|
80
|
+
* id of the finding that now speaks for it (see `foldReach`,
|
|
81
|
+
* `groupAddedExports`, and `groupSignatureChanges` — the three places facts
|
|
82
|
+
* disappear this way). Chained here because a fact can be absorbed twice in
|
|
83
|
+
* a row — a blast_radius fact folded into an `export_added` or
|
|
84
|
+
* `signature_changed` sibling whose own finding is then itself collapsed
|
|
85
|
+
* into its file's group — and only this function, which runs the fold and
|
|
86
|
+
* both grouping passes itself, sees every step. `reconcile.ts` is the only
|
|
87
|
+
* caller that needs this; everything else calls `rank`, the one-line
|
|
88
|
+
* delegate below, which just discards it.
|
|
89
|
+
*/
|
|
90
|
+
export declare function rankWithAbsorption(facts: Fact[]): {
|
|
91
|
+
findings: Finding[];
|
|
92
|
+
absorbedBy: Map<string, string>;
|
|
93
|
+
};
|
|
94
|
+
export declare function rank(facts: Fact[]): Finding[];
|