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,634 @@
|
|
|
1
|
+
import { codePointLabel, conceals } from "./conceal.js";
|
|
2
|
+
import { BEYOND_INTENT_MARK, buildReportModel, EMPTY_LENS_COPY, LENSES, TIER_GLYPH, TIER_MEANING, TIER_ORDER, TIER_WORD, } from "./model.js";
|
|
3
|
+
/**
|
|
4
|
+
* The HTML surface, a walker over the report model. Every sentence, tier,
|
|
5
|
+
* glyph, lens routing, ordering, and disclosure rendered here is decided by
|
|
6
|
+
* `buildReportModel` — what this file owns is format mechanics (entity
|
|
7
|
+
* escaping, markup, the stylesheet, the tab script) plus the fixed,
|
|
8
|
+
* data-free copy only this surface shows: the lens blurbs, the empty-lens
|
|
9
|
+
* wording, the provenance tail, the legend framing. Model text arrives with
|
|
10
|
+
* concealment already applied — structurally, as `ConcealSegment` arrays
|
|
11
|
+
* this walker wraps in its own markup, or as labelled strings for
|
|
12
|
+
* identifier-shaped fields — so nothing here re-derives concealment for
|
|
13
|
+
* model content. The one scoped exception, recorded in the design spec's
|
|
14
|
+
* addendum, is the API-surface symbol table: symbols are changeset data the
|
|
15
|
+
* model omits by design, so that table reads the changeset directly and
|
|
16
|
+
* applies the concealment defense renderer-side through `visible`.
|
|
17
|
+
*/
|
|
18
|
+
const ESCAPES = {
|
|
19
|
+
"&": "&",
|
|
20
|
+
"<": "<",
|
|
21
|
+
">": ">",
|
|
22
|
+
'"': """,
|
|
23
|
+
"'": "'",
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Markup escaping, and nothing else. Quotes and apostrophes go too, not just
|
|
27
|
+
* the angle brackets, so the result cannot break out of an attribute value.
|
|
28
|
+
* Everything the model hands this walker is untrusted — a signature
|
|
29
|
+
* containing `Array<T>` and a claim containing a literal `<script>` are the
|
|
30
|
+
* same problem, and only one of them is hostile — so format escaping is
|
|
31
|
+
* applied to ALL model-provided text uniformly. Three contexts, one function
|
|
32
|
+
* each:
|
|
33
|
+
*
|
|
34
|
+
* - a segmented content field → `seg` (or `prose`, which builds on it)
|
|
35
|
+
* - the symbol table's changeset data → `visible`
|
|
36
|
+
* - a labelled model string, or a string literal written in this file → `esc`
|
|
37
|
+
*
|
|
38
|
+
* Both wrappers call this one, so the escaping below is the floor under all
|
|
39
|
+
* three contexts, not an alternative to them.
|
|
40
|
+
*
|
|
41
|
+
* Nothing untrusted is ever interpolated into the inline script or the
|
|
42
|
+
* stylesheet, where escaping would not help — the script is a fixed string
|
|
43
|
+
* that reads its data from the DOM. See `test/report/html.test.ts`, "puts
|
|
44
|
+
* no report data inside the inline script".
|
|
45
|
+
*/
|
|
46
|
+
function esc(text) {
|
|
47
|
+
return text.replace(/[&<>"']/g, (c) => ESCAPES[c]);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A segmented model field as HTML: text runs entity-escaped, each concealed
|
|
51
|
+
* segment's bare code-point label wrapped in the `.ctrl` span that keeps it
|
|
52
|
+
* visible (the table and its rationale live in `./conceal.ts`). The
|
|
53
|
+
* distinction arrives structurally from the model, so no label is ever
|
|
54
|
+
* parsed back out of flattened text — source code that literally spells
|
|
55
|
+
* `[U+202E]` stays ordinary text here, and only a real concealed character
|
|
56
|
+
* gets the span.
|
|
57
|
+
*/
|
|
58
|
+
function seg(segments) {
|
|
59
|
+
return segments
|
|
60
|
+
.map((s) => s.kind === "concealed"
|
|
61
|
+
? `<span class="ctrl" title="concealing character">${s.text}</span>`
|
|
62
|
+
: esc(s.text))
|
|
63
|
+
.join("");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Escaped, and with every concealing character replaced by a visible label
|
|
67
|
+
* of its own code point. Only for the API-surface symbol table — the one
|
|
68
|
+
* place this walker renders text that does not come from the model, the
|
|
69
|
+
* scoped exception described in this file's header — so the concealment
|
|
70
|
+
* defense is applied here, renderer-side, exactly where model coverage ends.
|
|
71
|
+
*
|
|
72
|
+
* The raw character is dropped rather than kept beside the label, matching
|
|
73
|
+
* what the model does to its own fields: copying a symbol name out of this
|
|
74
|
+
* report should not carry an invisible payload with it.
|
|
75
|
+
*/
|
|
76
|
+
function visible(text) {
|
|
77
|
+
let out = "";
|
|
78
|
+
for (const ch of esc(text)) {
|
|
79
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
80
|
+
out += conceals(code)
|
|
81
|
+
? `<span class="ctrl" title="concealing character">${codePointLabel(code)}</span>`
|
|
82
|
+
: ch;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A segmented paragraph with `backticked` spans set as inline code. `seg`
|
|
88
|
+
* runs first and introduces no backtick of its own, so the pairs this
|
|
89
|
+
* matches are the author's; the text inside a span is already neutralised by
|
|
90
|
+
* the time the `code` tags go around it. Analyzer bodies and model prose
|
|
91
|
+
* both quote identifiers this way constantly, and a wall of
|
|
92
|
+
* undifferentiated prose is the thing that stops a reader partway down a
|
|
93
|
+
* finding.
|
|
94
|
+
*/
|
|
95
|
+
function prose(segments) {
|
|
96
|
+
return seg(segments).replace(/`([^`\n]+)`/g, "<code>$1</code>");
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The only way model-authored text reaches the page. The model carries the
|
|
100
|
+
* prose, the attribution, and the trust caveat in one `ModelNoteView` — see
|
|
101
|
+
* `./model.js`, where their inseparability is argued — and this block
|
|
102
|
+
* renders all three from that one object, so there is no code path that
|
|
103
|
+
* emits one without the others. See `test/report/html.test.ts`, "never
|
|
104
|
+
* renders model prose outside an attributed block".
|
|
105
|
+
*
|
|
106
|
+
* Contains no nested `div` on purpose: the test above matches from a
|
|
107
|
+
* `model-block` to the next `</div>`, so a nested one would end the match
|
|
108
|
+
* early and let the prose after it escape the check.
|
|
109
|
+
*/
|
|
110
|
+
function modelBlock(note) {
|
|
111
|
+
return [
|
|
112
|
+
`<div class="model-block">`,
|
|
113
|
+
`<span class="model-tag">unverified · ${esc(note.model)}</span>`,
|
|
114
|
+
`<span class="model-text">${prose(note.text)}</span>`,
|
|
115
|
+
`<span class="model-note">${esc(note.caution)}</span>`,
|
|
116
|
+
`</div>`,
|
|
117
|
+
].join("");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* `path:line`, with before-side lines marked — see `EvidenceView.side` in
|
|
121
|
+
* `./model.js` for why the reader is owed that marker. No link: the numbers
|
|
122
|
+
* on a before-side ref count in a revision that need not exist in the
|
|
123
|
+
* working tree, and this report has no repository path to resolve an
|
|
124
|
+
* after-side ref against either, so every location here is text to read or
|
|
125
|
+
* copy rather than something to click. The file path arrives from the model
|
|
126
|
+
* as a labelled string, so only the entity layer is applied.
|
|
127
|
+
*/
|
|
128
|
+
function location(ref) {
|
|
129
|
+
const marker = ref.side === "before"
|
|
130
|
+
? ` <span class="chip chip-before" title="This line number counts in the before revision. It may point somewhere unrelated in the working tree.">before</span>`
|
|
131
|
+
: "";
|
|
132
|
+
return `<span class="loc">${esc(ref.file)}:${ref.line}</span>${marker}`;
|
|
133
|
+
}
|
|
134
|
+
function plural(n, word) {
|
|
135
|
+
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Sentence-leading count. Splicing the raw number in leaves a sentence with
|
|
139
|
+
* a numeral for a subject and, in the singular, a verb that does not agree
|
|
140
|
+
* with it. The narrative is prose a person reads, so the subject is spelled
|
|
141
|
+
* out and each verb is inflected at its own call site.
|
|
142
|
+
*/
|
|
143
|
+
function countWord(n) {
|
|
144
|
+
return n === 1 ? "One" : String(n);
|
|
145
|
+
}
|
|
146
|
+
function evidenceHtml(finding) {
|
|
147
|
+
if (finding.evidence.length === 0) {
|
|
148
|
+
return `<p class="no-evidence">No evidence. Nothing mechanical points at this — read the code before believing it.</p>`;
|
|
149
|
+
}
|
|
150
|
+
// Every ref the model carries, uncapped: this is the surface a reader
|
|
151
|
+
// opens when the terminal's summary was not enough.
|
|
152
|
+
const items = finding.evidence
|
|
153
|
+
.map((e) => `<li>${location(e)}<pre class="excerpt"><code>${seg(e.excerpt)}</code></pre></li>`)
|
|
154
|
+
.join("");
|
|
155
|
+
return `<h4>Evidence</h4><ol class="evidence">${items}</ol>`;
|
|
156
|
+
}
|
|
157
|
+
function reachHtml(finding) {
|
|
158
|
+
const reach = finding.reach;
|
|
159
|
+
if (!reach)
|
|
160
|
+
return "";
|
|
161
|
+
const items = reach.sites.map((s) => `<li>${location(s)}</li>`).join("");
|
|
162
|
+
// The site list is a sample even before the model's cap — the analyzer
|
|
163
|
+
// bounds what it collects while counting every reference — so the two
|
|
164
|
+
// numbers are reported apart rather than as "N of M". The cap and the
|
|
165
|
+
// overflow count are the model's (see `ReachView`); this walker only
|
|
166
|
+
// phrases them.
|
|
167
|
+
const more = reach.overflow > 0 ? `<li class="muted">… ${reach.overflow} more collected</li>` : "";
|
|
168
|
+
return `<h4>Referenced from <span class="muted">(${plural(reach.references, "reference")} in total)</span></h4><ul class="sites">${items}${more}</ul>`;
|
|
169
|
+
}
|
|
170
|
+
function findingCard(finding, lens, rank, numbered) {
|
|
171
|
+
const tier = finding.tier;
|
|
172
|
+
const badge = `<span class="badge badge-${tier}">${finding.glyph} ${esc(TIER_WORD[tier])}${tier === "model" && finding.modelNote ? ` · ${esc(finding.modelNote.model)}` : ""}</span>`;
|
|
173
|
+
// Two rows, not one, so the location cannot push the title around: on one
|
|
174
|
+
// row a `src/interpret/schema.ts:58` and a `b.ts:5` start their titles
|
|
175
|
+
// half a column apart, and file paths vary far more in width than
|
|
176
|
+
// anything else in the headline. Titles still shift by tier, since the
|
|
177
|
+
// badge ahead of them is wider for a model-tier finding that carries the
|
|
178
|
+
// model's name — that one is left alone deliberately: pinning the badge to
|
|
179
|
+
// a fixed width would either truncate a model name or pad every other
|
|
180
|
+
// headline to fit the longest one. The location's side marker is the
|
|
181
|
+
// model's (`FindingView.side`, taken from the anchoring evidence ref).
|
|
182
|
+
const head = [
|
|
183
|
+
`<span class="head-row">`,
|
|
184
|
+
`<span class="rank">${numbered ? rank : ""}</span>`,
|
|
185
|
+
`<span class="chev" aria-hidden="true">▸</span>`,
|
|
186
|
+
badge,
|
|
187
|
+
finding.beyondIntent
|
|
188
|
+
? `<span class="badge badge-intent">${esc(finding.beyondIntent)}</span>`
|
|
189
|
+
: "",
|
|
190
|
+
`<span class="finding-title">${prose(finding.title)}</span>`,
|
|
191
|
+
`</span>`,
|
|
192
|
+
`<span class="head-loc">`,
|
|
193
|
+
location({ file: finding.file, line: finding.line, side: finding.side }),
|
|
194
|
+
`</span>`,
|
|
195
|
+
].join("");
|
|
196
|
+
// One walk covers every tier: a model-tier finding arrives with no body
|
|
197
|
+
// paragraphs and its whole prose in `modelNote`, an inferred finding keeps
|
|
198
|
+
// its analyzer paragraphs and carries the claim's reasoning in `modelNote`
|
|
199
|
+
// beside them, and a claim-free finding has no note at all. Unlike the
|
|
200
|
+
// terminal, the block is not gated on a recorded model name: a model-tier
|
|
201
|
+
// finding's whole body is model prose, and suppressing it would leave a
|
|
202
|
+
// headline with nothing under it, so the model's UNNAMED_MODEL fallback
|
|
203
|
+
// attribution shows instead — visibly incomplete rather than absent.
|
|
204
|
+
const paragraphs = finding.body.map((p) => `<p class="body">${prose(p)}</p>`).join("");
|
|
205
|
+
const note = finding.modelNote ? modelBlock(finding.modelNote) : "";
|
|
206
|
+
return [
|
|
207
|
+
`<li><details class="card card-${tier}" id="${esc(lens)}-f${rank}">`,
|
|
208
|
+
`<summary>${head}</summary>`,
|
|
209
|
+
`<div class="card-body">${paragraphs}${note}${evidenceHtml(finding)}${reachHtml(finding)}</div>`,
|
|
210
|
+
`</details></li>`,
|
|
211
|
+
].join("");
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* A ranked list of cards. `numbered` is off for the filtered lenses: a
|
|
215
|
+
* number beside a finding reads as its rank, and in a lens showing three of
|
|
216
|
+
* eleven findings any number is wrong — the position in the filtered list
|
|
217
|
+
* is not the rank, and the rank is not the position. The cards stay in the
|
|
218
|
+
* model's rank order either way — a pane filters, never reorders. The empty
|
|
219
|
+
* rank cell is kept so the headline and the location under it line up with
|
|
220
|
+
* the numbered lens.
|
|
221
|
+
*/
|
|
222
|
+
function findingList(findings, lens, numbered = false) {
|
|
223
|
+
const items = findings.map((f, i) => findingCard(f, lens, i + 1, numbered)).join("");
|
|
224
|
+
return `<ol class="findings">${items}</ol>`;
|
|
225
|
+
}
|
|
226
|
+
function empty(message) {
|
|
227
|
+
return `<p class="empty">${esc(message)}</p>`;
|
|
228
|
+
}
|
|
229
|
+
function lead(m) {
|
|
230
|
+
const sentences = [
|
|
231
|
+
`This range touches ${plural(m.fileCount, "file")} and ${plural(m.lineCount, "changed line")}.`,
|
|
232
|
+
];
|
|
233
|
+
if (m.findings.length === 0) {
|
|
234
|
+
sentences.push("Nothing in it tripped an analyzer, and no claim stands alone.");
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
sentences.push(m.findings.length === 1
|
|
238
|
+
? "One finding."
|
|
239
|
+
: `${m.findings.length} findings, ranked by what each is likely to cost.`);
|
|
240
|
+
if (m.counts.verified > 0) {
|
|
241
|
+
const n = m.counts.verified;
|
|
242
|
+
sentences.push(`${countWord(n)} rest${n === 1 ? "s" : ""} on something an analyzer can point at in the code.`);
|
|
243
|
+
}
|
|
244
|
+
if (m.counts.inferred > 0) {
|
|
245
|
+
const n = m.counts.inferred;
|
|
246
|
+
sentences.push(`${countWord(n)} pair${n === 1 ? "s" : ""} an analyzer's finding with the model's explanation of why it matters.`);
|
|
247
|
+
}
|
|
248
|
+
if (m.counts.model > 0) {
|
|
249
|
+
const n = m.counts.model;
|
|
250
|
+
sentences.push(`${countWord(n)} come${n === 1 ? "s" : ""} from the model alone — a lead to check, not a result.`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return `<p class="lead">${esc(sentences.join(" "))}</p>`;
|
|
254
|
+
}
|
|
255
|
+
function narrativeLens(m) {
|
|
256
|
+
const body = m.findings.length === 0
|
|
257
|
+
? empty("No findings to narrate.")
|
|
258
|
+
: findingList(m.findings, "narrative", true);
|
|
259
|
+
return lead(m) + body;
|
|
260
|
+
}
|
|
261
|
+
function section(heading, blurb, findings, lens) {
|
|
262
|
+
if (findings.length === 0)
|
|
263
|
+
return "";
|
|
264
|
+
return `<h3>${esc(heading)}</h3><p class="blurb">${esc(blurb)}</p>${findingList(findings, lens)}`;
|
|
265
|
+
}
|
|
266
|
+
function effectsLens(findings) {
|
|
267
|
+
// The classification is the model's (`FindingView.subject`, recovered from
|
|
268
|
+
// the finding's id exactly once, in `buildReportModel`); these filters
|
|
269
|
+
// only gather what it decided, in the order it decided.
|
|
270
|
+
const effects = findings.filter((f) => f.subject === "effect");
|
|
271
|
+
const guards = findings.filter((f) => f.subject === "guard");
|
|
272
|
+
const contracts = findings.filter((f) => f.subject === "surface");
|
|
273
|
+
const parts = [
|
|
274
|
+
section("Effects", "What this change makes the program do to the world outside itself.", effects, "effects"),
|
|
275
|
+
section("Guards", "Checks that ran before and do not run now.", guards, "guards"),
|
|
276
|
+
section("Contracts", "Promises other code was compiled against.", contracts, "contracts"),
|
|
277
|
+
].filter((p) => p !== "");
|
|
278
|
+
// Names all three kinds of finding this lens does not show. It used to name
|
|
279
|
+
// only the first, while the model classifies a standalone reach finding
|
|
280
|
+
// under a subject no section filters on — so a reader was told the
|
|
281
|
+
// narrative held nothing extra except model claims, and it held that too.
|
|
282
|
+
// A citation finding is the third, for the same reason and with the same
|
|
283
|
+
// cost if it goes unnamed. Its clause has to cover every path that can
|
|
284
|
+
// produce one: citations are extracted from TypeScript comments as well as
|
|
285
|
+
// from prose, and the baseline-less path deliberately claims nothing about
|
|
286
|
+
// what the pointer used to do — so the clause names both sources and states
|
|
287
|
+
// only that the pointer does not hold now.
|
|
288
|
+
const note = `<p class="blurb">Built from what the analyzers proved, and not the whole list. A model-only claim has no analyzer behind it to classify. A standalone reach finding — a changed export with callers, and nothing else known about it — reports cost rather than a problem, and belongs to none of these three. A citation finding — a line of prose or a comment in this repository whose pointer into the code does not hold at this revision — belongs to none of them either. All three appear in the narrative.</p>`;
|
|
289
|
+
if (parts.length === 0) {
|
|
290
|
+
// Describes the filter, not the change. A lens is a view over findings
|
|
291
|
+
// the model classified by id prefix, and if that classification ever
|
|
292
|
+
// stops matching what the analyzers emit, this pane is what a user sees
|
|
293
|
+
// — so it must not be able to say "nothing crossed a boundary and no
|
|
294
|
+
// promise moved" while a removed guard sits ranked first in the
|
|
295
|
+
// narrative. An honest empty state degrades to a shrug; the other
|
|
296
|
+
// wording degrades to the tool asserting something false in its own
|
|
297
|
+
// voice, in the one place the tier badges do not reach.
|
|
298
|
+
return note + empty(`${EMPTY_LENS_COPY} The narrative has the full list.`);
|
|
299
|
+
}
|
|
300
|
+
return note + parts.join("");
|
|
301
|
+
}
|
|
302
|
+
const SYMBOL_CHANGE_MARK = {
|
|
303
|
+
added: "+",
|
|
304
|
+
modified: "~",
|
|
305
|
+
removed: "−",
|
|
306
|
+
};
|
|
307
|
+
function surfaceLens(changeset, findings) {
|
|
308
|
+
// The symbol table is the scoped exception named in this file's header:
|
|
309
|
+
// it reads the changeset, not the model, so its text goes through
|
|
310
|
+
// `visible` rather than arriving pre-labelled.
|
|
311
|
+
const rows = [];
|
|
312
|
+
for (const file of changeset.files) {
|
|
313
|
+
for (const sym of file.symbols) {
|
|
314
|
+
if (!sym.exported)
|
|
315
|
+
continue;
|
|
316
|
+
rows.push([
|
|
317
|
+
`<tr class="sym-${sym.change}">`,
|
|
318
|
+
// The mark carries the colour and the word carries the meaning, in
|
|
319
|
+
// one cell: as two columns they said the same thing twice.
|
|
320
|
+
`<td class="change"><span aria-hidden="true">${SYMBOL_CHANGE_MARK[sym.change]}</span> ${esc(sym.change)}</td>`,
|
|
321
|
+
`<td class="mono">${visible(sym.qualifiedName)}</td>`,
|
|
322
|
+
`<td class="muted">${visible(sym.kind)}</td>`,
|
|
323
|
+
`<td class="mono muted">${visible(file.path)}</td>`,
|
|
324
|
+
`</tr>`,
|
|
325
|
+
].join(""));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const surfaceFindings = findings.filter((f) => f.subject === "surface");
|
|
329
|
+
if (rows.length === 0 && surfaceFindings.length === 0) {
|
|
330
|
+
return empty("Nothing in this range matched this view: no exported declaration appears in the symbol map, and no finding is about the public surface.");
|
|
331
|
+
}
|
|
332
|
+
const table = rows.length === 0
|
|
333
|
+
? empty("No exported declaration appears in this range's symbol map, though the findings below are about the public surface.")
|
|
334
|
+
: [
|
|
335
|
+
// What the symbol map actually records, named rather than promised
|
|
336
|
+
// as "every exported declaration". `mapSymbols` (in
|
|
337
|
+
// `../extract/symbols.ts`) reads function, class, interface, type
|
|
338
|
+
// alias, enum, variable, method, and accessor declarations, and this
|
|
339
|
+
// table shows the ones that are module exports — a class member
|
|
340
|
+
// never is.
|
|
341
|
+
//
|
|
342
|
+
// The list of exclusions is checked, not assumed. An enum's
|
|
343
|
+
// individual members are never recorded — a changed member shows
|
|
344
|
+
// as its enum's one row — and neither is a re-export declaration;
|
|
345
|
+
// saying so is what keeps that silence from reading as "nothing
|
|
346
|
+
// changed". Namespaces are a different case and were described
|
|
347
|
+
// wrongly before: their members *are* recorded, qualified (`N.x`),
|
|
348
|
+
// and are not module exports — an importer reaches them through the
|
|
349
|
+
// namespace — so they do not appear in this table either, but for a
|
|
350
|
+
// stated reason rather than because nothing looked.
|
|
351
|
+
`<p class="blurb">Module-level exported functions, classes, interfaces, type aliases, enums, and variables this range touched, read from the symbol map rather than from the findings — so a symbol appears here whether or not anything flagged it. Not an inventory of the whole public surface: enum members and re-export declarations are not recorded at all, so a changed enum member shows only as its enum's row; a declaration exported by a separate <code>export { … }</code> statement rather than by an <code>export</code> modifier is recorded but not counted as an export; a namespace's members are recorded under the namespace rather than as exports of the file; and a deleted file contributes nothing.</p>`,
|
|
352
|
+
`<div class="table-scroll"><table class="surface">`,
|
|
353
|
+
`<thead><tr><th>Change</th><th>Symbol</th><th>Kind</th><th>File</th></tr></thead>`,
|
|
354
|
+
`<tbody>${rows.join("")}</tbody></table></div>`,
|
|
355
|
+
].join("");
|
|
356
|
+
const flagged = surfaceFindings.length === 0
|
|
357
|
+
? `<p class="blurb">No finding in this review is about the public surface.</p>`
|
|
358
|
+
: `<h3>What changed contractually</h3>${findingList(surfaceFindings, "surface")}`;
|
|
359
|
+
return table + flagged;
|
|
360
|
+
}
|
|
361
|
+
function headerHtml(m) {
|
|
362
|
+
// The same numbers as `ReportModel.scope`, phrased this surface's way —
|
|
363
|
+
// dot separators where the terminal's line uses a comma — which is why the
|
|
364
|
+
// model carries the pieces as well as its own composed line.
|
|
365
|
+
const scope = `${plural(m.fileCount, "file")} · ${plural(m.lineCount, "line")} changed · ${m.rangeLabel}`;
|
|
366
|
+
const chips = TIER_ORDER.map((tier) => `<span class="chip chip-${tier}" title="${esc(TIER_MEANING[tier])}">${TIER_GLYPH[tier]} ${m.counts[tier]} ${esc(TIER_WORD[tier])}</span>`).join("");
|
|
367
|
+
// Every reason this run fell short of its full pipeline, in one banner
|
|
368
|
+
// above the findings, gated on the model's `notes` exactly: non-empty
|
|
369
|
+
// means the review is partial and this surface must say so. A reader who
|
|
370
|
+
// does not know an analyzer died, or that the model was never asked,
|
|
371
|
+
// reads a short list as good news.
|
|
372
|
+
const banner = m.notes.length === 0
|
|
373
|
+
? ""
|
|
374
|
+
: [
|
|
375
|
+
`<div class="banner">`,
|
|
376
|
+
`<strong>This review is partial.</strong>`,
|
|
377
|
+
`<ul>${m.notes.map((n) => `<li>${esc(n)}</li>`).join("")}</ul>`,
|
|
378
|
+
`</div>`,
|
|
379
|
+
].join("");
|
|
380
|
+
// Separate from the banner deliberately, which is why the model carries it
|
|
381
|
+
// as `filterNote` rather than a `notes` entry: the filter ran as designed,
|
|
382
|
+
// so its disclosure must not read as the review falling short. See
|
|
383
|
+
// `test/report/html.test.ts`, "discloses the standalone-reach filter with
|
|
384
|
+
// the same sentence the terminal prints".
|
|
385
|
+
const filterNote = m.filterNote ? `<p class="muted">${esc(m.filterNote)}</p>` : "";
|
|
386
|
+
// The gate — a model name AND a model-derived tier below — is the model's;
|
|
387
|
+
// `provenance` is simply absent otherwise. The fixed tail after it is this
|
|
388
|
+
// surface's own data-free copy. Model prose itself is never gated this way
|
|
389
|
+
// — `modelBlock` names an unnamed model instead of going silent, because a
|
|
390
|
+
// model-tier finding's whole body is model prose and suppressing it would
|
|
391
|
+
// leave a headline with nothing under it.
|
|
392
|
+
const provenance = m.provenance
|
|
393
|
+
? `<p class="provenance">${esc(m.provenance)} Everything it wrote is marked.</p>`
|
|
394
|
+
: "";
|
|
395
|
+
// Its own line, deliberately outside the banner — the model carries this
|
|
396
|
+
// as `coverageNote`, apart from `notes`, for exactly this placement:
|
|
397
|
+
// deleting a TypeScript file is routine, and "This review is partial."
|
|
398
|
+
// fired on every diff that did. A banner that a normal change trips is a
|
|
399
|
+
// banner a reader learns to skip, and the things it exists for — a dead
|
|
400
|
+
// analyzer, a model never asked — are the ones that would go unread.
|
|
401
|
+
const coverage = m.coverageNote ? `<p class="coverage">${esc(m.coverageNote)}</p>` : "";
|
|
402
|
+
const legend = TIER_ORDER.map((tier) => `<li><span class="badge badge-${tier}">${TIER_GLYPH[tier]} ${esc(TIER_WORD[tier])}</span> ${esc(TIER_MEANING[tier])}</li>`).join("");
|
|
403
|
+
// Its own item under the same legend, in the same shape as the tier items.
|
|
404
|
+
// The badge here is the model's word, escaped like every other model string.
|
|
405
|
+
const intentLegend = m.beyondIntentLegend
|
|
406
|
+
? `<li><span class="badge badge-intent">${esc(BEYOND_INTENT_MARK)}</span> ${esc(m.beyondIntentLegend)}</li>`
|
|
407
|
+
: "";
|
|
408
|
+
return [
|
|
409
|
+
`<header>`,
|
|
410
|
+
`<h1>urtext</h1>`,
|
|
411
|
+
`<p class="scope">${esc(scope)}</p>`,
|
|
412
|
+
`<div class="chips">${chips}</div>`,
|
|
413
|
+
provenance,
|
|
414
|
+
coverage,
|
|
415
|
+
banner,
|
|
416
|
+
filterNote,
|
|
417
|
+
`<details class="legend"><summary>What the three tiers mean</summary><ul>${legend}${intentLegend}</ul></details>`,
|
|
418
|
+
`</header>`,
|
|
419
|
+
].join("");
|
|
420
|
+
}
|
|
421
|
+
const STYLE = `
|
|
422
|
+
:root {
|
|
423
|
+
color-scheme: light dark;
|
|
424
|
+
--bg: #fbfaf8;
|
|
425
|
+
--panel: #ffffff;
|
|
426
|
+
--ink: #1a1a1c;
|
|
427
|
+
--muted: #5f6068;
|
|
428
|
+
--rule: #e2dfd9;
|
|
429
|
+
--verified: #12674c;
|
|
430
|
+
--inferred: #9a6206;
|
|
431
|
+
--model: #97325c;
|
|
432
|
+
--verified-bg: #e7f2ed;
|
|
433
|
+
--inferred-bg: #f7eedd;
|
|
434
|
+
--model-bg: #f8eaf0;
|
|
435
|
+
--code-bg: #f4f2ee;
|
|
436
|
+
}
|
|
437
|
+
@media (prefers-color-scheme: dark) {
|
|
438
|
+
:root {
|
|
439
|
+
--bg: #131316;
|
|
440
|
+
--panel: #1b1c1f;
|
|
441
|
+
--ink: #e8e7e4;
|
|
442
|
+
--muted: #9a9da4;
|
|
443
|
+
--rule: #2c2d32;
|
|
444
|
+
--verified: #63c9a4;
|
|
445
|
+
--inferred: #e0ac52;
|
|
446
|
+
--model: #e987b0;
|
|
447
|
+
--verified-bg: #12291f;
|
|
448
|
+
--inferred-bg: #2b2313;
|
|
449
|
+
--model-bg: #2c1420;
|
|
450
|
+
--code-bg: #212227;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
* { box-sizing: border-box; }
|
|
454
|
+
body {
|
|
455
|
+
margin: 0;
|
|
456
|
+
background: var(--bg);
|
|
457
|
+
color: var(--ink);
|
|
458
|
+
font: 16px/1.55 ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
459
|
+
overflow-wrap: break-word;
|
|
460
|
+
}
|
|
461
|
+
.page { max-width: 58rem; margin: 0 auto; padding: 2.5rem 1.25rem 5rem; }
|
|
462
|
+
h1 { font-size: 1.05rem; letter-spacing: .18em; text-transform: uppercase; margin: 0 0 .35rem; }
|
|
463
|
+
h3 { font-size: .95rem; margin: 2rem 0 .25rem; }
|
|
464
|
+
h4 { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 1.1rem 0 .35rem; font-weight: 600; }
|
|
465
|
+
header { border-bottom: 1px solid var(--rule); padding-bottom: 1.25rem; }
|
|
466
|
+
.scope { margin: 0 0 .9rem; color: var(--muted); font-size: .93rem; }
|
|
467
|
+
.chips { display: flex; flex-wrap: wrap; gap: .4rem; }
|
|
468
|
+
.chip {
|
|
469
|
+
font-size: .8rem; padding: .12rem .5rem; border-radius: 999px;
|
|
470
|
+
border: 1px solid var(--rule); background: var(--panel); color: var(--muted);
|
|
471
|
+
}
|
|
472
|
+
.chip-verified { color: var(--verified); background: var(--verified-bg); border-color: transparent; }
|
|
473
|
+
.chip-inferred { color: var(--inferred); background: var(--inferred-bg); border-color: transparent; }
|
|
474
|
+
.chip-model { color: var(--model); background: var(--model-bg); border-color: transparent; }
|
|
475
|
+
.chip-before { font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; color: var(--model); background: var(--model-bg); border-color: transparent; }
|
|
476
|
+
.provenance, .coverage { font-size: .85rem; color: var(--muted); margin: .8rem 0 0; }
|
|
477
|
+
.banner {
|
|
478
|
+
margin: 1rem 0 0; padding: .7rem .9rem; border-radius: .4rem;
|
|
479
|
+
background: var(--inferred-bg); border: 1px solid var(--inferred); font-size: .9rem;
|
|
480
|
+
}
|
|
481
|
+
.banner ul { margin: .35rem 0 0; padding-left: 1.1rem; }
|
|
482
|
+
.legend { margin-top: 1rem; font-size: .85rem; color: var(--muted); }
|
|
483
|
+
.legend summary { cursor: pointer; }
|
|
484
|
+
.legend ul { list-style: none; margin: .6rem 0 0; padding: 0; }
|
|
485
|
+
.legend li { margin: .3rem 0; }
|
|
486
|
+
.tabs { display: none; gap: .35rem; margin: 1.5rem 0 .5rem; flex-wrap: wrap; }
|
|
487
|
+
.has-js .tabs { display: flex; }
|
|
488
|
+
.tabs button {
|
|
489
|
+
font: inherit; font-size: .88rem; padding: .35rem .8rem; cursor: pointer;
|
|
490
|
+
color: var(--muted); background: transparent;
|
|
491
|
+
border: 1px solid var(--rule); border-radius: 999px;
|
|
492
|
+
}
|
|
493
|
+
.tabs button[aria-selected="true"] { color: var(--bg); background: var(--ink); border-color: var(--ink); }
|
|
494
|
+
.lens { padding-top: .5rem; }
|
|
495
|
+
.has-js .lens { display: none; }
|
|
496
|
+
.has-js .lens.active { display: block; }
|
|
497
|
+
.lead { font-size: 1.02rem; margin: .75rem 0 1.25rem; }
|
|
498
|
+
.blurb, .empty { color: var(--muted); font-size: .9rem; }
|
|
499
|
+
.empty { padding: 1rem 0; }
|
|
500
|
+
.findings { list-style: none; margin: 0; padding: 0; }
|
|
501
|
+
.card {
|
|
502
|
+
background: var(--panel); border: 1px solid var(--rule);
|
|
503
|
+
border-left: 4px solid var(--rule); border-radius: .4rem;
|
|
504
|
+
margin: .5rem 0; padding: .55rem .8rem;
|
|
505
|
+
}
|
|
506
|
+
.card-verified { border-left-color: var(--verified); }
|
|
507
|
+
.card-inferred { border-left-color: var(--inferred); }
|
|
508
|
+
.card-model { border-left-color: var(--model); border-style: dashed; border-left-style: solid; background: transparent; }
|
|
509
|
+
.card summary { cursor: pointer; display: block; list-style: none; }
|
|
510
|
+
.card summary::-webkit-details-marker { display: none; }
|
|
511
|
+
.head-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: .45rem; }
|
|
512
|
+
.head-loc { display: block; margin: .15rem 0 0 2.35rem; }
|
|
513
|
+
.chev { color: var(--muted); font-size: .75rem; transition: transform .12s ease; display: inline-block; }
|
|
514
|
+
details[open] > summary .chev { transform: rotate(90deg); }
|
|
515
|
+
.rank { color: var(--muted); font-variant-numeric: tabular-nums; font-size: .82rem; min-width: 1.1rem; }
|
|
516
|
+
.badge {
|
|
517
|
+
font-size: .74rem; letter-spacing: .04em; padding: .1rem .45rem; border-radius: .25rem;
|
|
518
|
+
white-space: nowrap;
|
|
519
|
+
}
|
|
520
|
+
.badge-verified { color: var(--verified); background: var(--verified-bg); }
|
|
521
|
+
.badge-inferred { color: var(--inferred); background: var(--inferred-bg); }
|
|
522
|
+
.badge-model { color: var(--model); background: var(--model-bg); }
|
|
523
|
+
.badge-intent { color: var(--model); background: transparent; border: 1px solid var(--model); }
|
|
524
|
+
.loc, .mono { font-family: ui-monospace, "Cascadia Mono", Consolas, "Liberation Mono", monospace; font-size: .84rem; }
|
|
525
|
+
.loc { color: var(--muted); }
|
|
526
|
+
.finding-title { flex: 1 1 16rem; min-width: 0; }
|
|
527
|
+
.card-body { padding: .35rem 0 .5rem 2.35rem; }
|
|
528
|
+
.body { margin: .5rem 0; }
|
|
529
|
+
.model-block {
|
|
530
|
+
display: block; margin: .7rem 0; padding: .55rem .75rem;
|
|
531
|
+
border: 1px dashed var(--model); border-radius: .35rem; background: var(--model-bg);
|
|
532
|
+
}
|
|
533
|
+
.model-tag {
|
|
534
|
+
display: inline-block; font-size: .7rem; text-transform: uppercase; letter-spacing: .07em;
|
|
535
|
+
color: var(--model); font-weight: 700; margin-right: .4rem;
|
|
536
|
+
}
|
|
537
|
+
.model-text { display: inline; }
|
|
538
|
+
.model-note { display: block; margin-top: .4rem; font-size: .8rem; color: var(--muted); }
|
|
539
|
+
.no-evidence { font-size: .9rem; color: var(--model); margin: .6rem 0; }
|
|
540
|
+
.evidence, .sites { margin: 0; padding-left: 1.2rem; }
|
|
541
|
+
.evidence li, .sites li { margin: .35rem 0; }
|
|
542
|
+
.excerpt {
|
|
543
|
+
margin: .2rem 0 .6rem; padding: .45rem .6rem; border-radius: .3rem;
|
|
544
|
+
background: var(--code-bg); overflow-x: auto; max-width: 100%;
|
|
545
|
+
font-family: ui-monospace, "Cascadia Mono", Consolas, "Liberation Mono", monospace;
|
|
546
|
+
font-size: .82rem; line-height: 1.45;
|
|
547
|
+
}
|
|
548
|
+
.muted { color: var(--muted); }
|
|
549
|
+
.table-scroll { overflow-x: auto; max-width: 100%; margin: .75rem 0; }
|
|
550
|
+
table.surface { border-collapse: collapse; font-size: .88rem; min-width: 34rem; }
|
|
551
|
+
table.surface th { text-align: left; font-size: .74rem; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--rule); padding: .3rem .6rem .3rem 0; }
|
|
552
|
+
table.surface td { padding: .22rem .6rem .22rem 0; border-bottom: 1px solid var(--rule); vertical-align: top; }
|
|
553
|
+
td.change { white-space: nowrap; }
|
|
554
|
+
.sym-added td.change { color: var(--verified); }
|
|
555
|
+
.sym-removed td.change { color: var(--model); }
|
|
556
|
+
.sym-modified td.change { color: var(--inferred); }
|
|
557
|
+
code { font-family: ui-monospace, "Cascadia Mono", Consolas, "Liberation Mono", monospace; font-size: .88em; background: var(--code-bg); padding: 0 .2em; border-radius: .2em; }
|
|
558
|
+
.excerpt code { background: none; padding: 0; font-size: inherit; }
|
|
559
|
+
.ctrl {
|
|
560
|
+
font-family: ui-monospace, "Cascadia Mono", Consolas, "Liberation Mono", monospace;
|
|
561
|
+
font-size: .72em; white-space: nowrap; vertical-align: baseline;
|
|
562
|
+
padding: 0 .25em; margin: 0 .1em; border-radius: .2em;
|
|
563
|
+
color: var(--model); background: var(--model-bg); border: 1px solid var(--model);
|
|
564
|
+
}
|
|
565
|
+
`;
|
|
566
|
+
/**
|
|
567
|
+
* Runs before the panes are parsed so a lens switch never flashes three
|
|
568
|
+
* stacked lenses. Everything it does is undone by its own absence: without
|
|
569
|
+
* scripting the class is never set, the tab bar stays hidden, and all three
|
|
570
|
+
* lenses render one after another rather than one of them being unreachable.
|
|
571
|
+
*/
|
|
572
|
+
const HEAD_SCRIPT = `document.documentElement.className = "has-js";`;
|
|
573
|
+
const TAB_SCRIPT = `
|
|
574
|
+
(function () {
|
|
575
|
+
var tabs = document.querySelector(".tabs");
|
|
576
|
+
if (!tabs) return;
|
|
577
|
+
var buttons = Array.prototype.slice.call(tabs.querySelectorAll("button"));
|
|
578
|
+
var panes = Array.prototype.slice.call(document.querySelectorAll(".lens"));
|
|
579
|
+
function show(key) {
|
|
580
|
+
panes.forEach(function (p) { p.classList.toggle("active", p.getAttribute("data-lens") === key); });
|
|
581
|
+
buttons.forEach(function (b) {
|
|
582
|
+
var on = b.getAttribute("data-lens") === key;
|
|
583
|
+
b.setAttribute("aria-selected", on ? "true" : "false");
|
|
584
|
+
b.tabIndex = on ? 0 : -1;
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
buttons.forEach(function (b, i) {
|
|
588
|
+
b.addEventListener("click", function () { show(b.getAttribute("data-lens")); });
|
|
589
|
+
b.addEventListener("keydown", function (e) {
|
|
590
|
+
var step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
|
|
591
|
+
if (!step) return;
|
|
592
|
+
e.preventDefault();
|
|
593
|
+
var next = buttons[(i + step + buttons.length) % buttons.length];
|
|
594
|
+
show(next.getAttribute("data-lens"));
|
|
595
|
+
next.focus();
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
show(buttons[0].getAttribute("data-lens"));
|
|
599
|
+
})();
|
|
600
|
+
`;
|
|
601
|
+
export function renderHtml(changeset, findings, meta) {
|
|
602
|
+
const m = buildReportModel(changeset, findings, meta);
|
|
603
|
+
const panes = {
|
|
604
|
+
narrative: narrativeLens(m),
|
|
605
|
+
effects: effectsLens(m.findings),
|
|
606
|
+
surface: surfaceLens(changeset, m.findings),
|
|
607
|
+
};
|
|
608
|
+
const tabs = LENSES.map((l, i) => `<button type="button" role="tab" data-lens="${l.key}" aria-controls="lens-${l.key}" aria-selected="${i === 0 ? "true" : "false"}">${esc(l.label)}</button>`).join("");
|
|
609
|
+
const sections = LENSES.map((l, i) => `<section class="lens${i === 0 ? " active" : ""}" data-lens="${l.key}" id="lens-${l.key}" role="tabpanel" aria-label="${esc(l.label)}">${panes[l.key]}</section>`).join("");
|
|
610
|
+
return [
|
|
611
|
+
`<!doctype html>`,
|
|
612
|
+
`<html lang="en">`,
|
|
613
|
+
`<head>`,
|
|
614
|
+
`<meta charset="utf-8">`,
|
|
615
|
+
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
616
|
+
// `<title>` is RCDATA, where markup would show literally — the model's
|
|
617
|
+
// labelled range label plus the entity layer keeps it both inert and
|
|
618
|
+
// free of concealing characters, like every attribute value here.
|
|
619
|
+
`<title>urtext — ${esc(m.rangeLabel)}</title>`,
|
|
620
|
+
`<style>${STYLE}</style>`,
|
|
621
|
+
`<script>${HEAD_SCRIPT}</script>`,
|
|
622
|
+
`</head>`,
|
|
623
|
+
`<body>`,
|
|
624
|
+
`<div class="page">`,
|
|
625
|
+
headerHtml(m),
|
|
626
|
+
`<nav class="tabs" role="tablist" aria-label="Lens">${tabs}</nav>`,
|
|
627
|
+
sections,
|
|
628
|
+
`</div>`,
|
|
629
|
+
`<script>${TAB_SCRIPT}</script>`,
|
|
630
|
+
`</body>`,
|
|
631
|
+
`</html>`,
|
|
632
|
+
``,
|
|
633
|
+
].join("\n");
|
|
634
|
+
}
|