laxy-verify 1.2.3 → 1.3.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/README.md +23 -0
- package/dist/audit/broken-links.d.ts +25 -25
- package/dist/audit/broken-links.js +97 -97
- package/dist/badge.d.ts +2 -1
- package/dist/badge.js +18 -14
- package/dist/cli.js +1246 -1233
- package/dist/config.d.ts +102 -102
- package/dist/config.js +360 -360
- package/dist/entitlement.d.ts +15 -13
- package/dist/entitlement.js +98 -94
- package/dist/init.js +132 -132
- package/dist/lighthouse.d.ts +37 -37
- package/dist/lighthouse.js +231 -231
- package/dist/report-markdown.d.ts +53 -53
- package/dist/report-markdown.js +407 -407
- package/dist/security-audit.d.ts +17 -17
- package/dist/security-audit.js +127 -127
- package/dist/verification-core/report.js +526 -526
- package/dist/verification-core/types.d.ts +164 -164
- package/dist/visual-diff.d.ts +33 -33
- package/dist/visual-diff.js +213 -213
- package/package.json +1 -1
- package/dist/ai-analysis.d.ts +0 -28
- package/dist/ai-analysis.js +0 -32
- package/dist/compare-env.d.ts +0 -23
- package/dist/compare-env.js +0 -55
- package/dist/init-analysis.d.ts +0 -6
- package/dist/init-analysis.js +0 -302
- package/dist/route-discovery.d.ts +0 -7
- package/dist/route-discovery.js +0 -108
package/dist/report-markdown.js
CHANGED
|
@@ -1,407 +1,407 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.shouldWriteMarkdownReport = shouldWriteMarkdownReport;
|
|
37
|
-
exports.getMarkdownReportPath = getMarkdownReportPath;
|
|
38
|
-
exports.buildMarkdownReport = buildMarkdownReport;
|
|
39
|
-
const path = __importStar(require("node:path"));
|
|
40
|
-
const visual_diff_js_1 = require("./visual-diff.js");
|
|
41
|
-
function titleCasePlan(plan) {
|
|
42
|
-
switch (plan) {
|
|
43
|
-
case "pro":
|
|
44
|
-
return "Pro";
|
|
45
|
-
case "team":
|
|
46
|
-
return "Team";
|
|
47
|
-
default:
|
|
48
|
-
return "Free";
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
function titleCaseVerdict(verdict) {
|
|
52
|
-
return verdict
|
|
53
|
-
.split("-")
|
|
54
|
-
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
55
|
-
.join(" ");
|
|
56
|
-
}
|
|
57
|
-
function formatTimestamp(iso) {
|
|
58
|
-
const date = new Date(iso);
|
|
59
|
-
if (Number.isNaN(date.getTime()))
|
|
60
|
-
return iso;
|
|
61
|
-
return date.toISOString().replace("T", " ").replace(".000Z", " UTC");
|
|
62
|
-
}
|
|
63
|
-
function sentenceForVerdict(view) {
|
|
64
|
-
switch (view.verdict) {
|
|
65
|
-
case "client-ready":
|
|
66
|
-
return "Yes. This run collected enough evidence to support a client-ready call.";
|
|
67
|
-
case "release-ready":
|
|
68
|
-
return "Yes. This run collected enough evidence to support a release-ready call.";
|
|
69
|
-
case "hold":
|
|
70
|
-
return "No. This run found blockers that should be fixed before release.";
|
|
71
|
-
case "investigate":
|
|
72
|
-
return "Not yet. The project is standing, but there is not enough confidence to call it release-ready.";
|
|
73
|
-
case "build-failed":
|
|
74
|
-
return "No. The production build failed, so the release should be held immediately.";
|
|
75
|
-
default:
|
|
76
|
-
return "This run did not find an immediate hard blocker, but it is still a shallow release-confidence pass.";
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
function defaultNextActions(result) {
|
|
80
|
-
const view = result.verification?.view;
|
|
81
|
-
if (!view)
|
|
82
|
-
return ["Rerun verification after the project changes are applied."];
|
|
83
|
-
if (view.nextActions.length > 0)
|
|
84
|
-
return view.nextActions;
|
|
85
|
-
switch (view.verdict) {
|
|
86
|
-
case "client-ready":
|
|
87
|
-
return ["Send this version to the client, or rerun verification after meaningful UI or flow changes."];
|
|
88
|
-
case "release-ready":
|
|
89
|
-
return ["Ship this version, or archive this report as release evidence."];
|
|
90
|
-
case "investigate":
|
|
91
|
-
return ["Collect the missing verification evidence, then rerun the command before release."];
|
|
92
|
-
case "build-failed":
|
|
93
|
-
return ["Fix the production build first, then rerun the verification command."];
|
|
94
|
-
case "quick-pass":
|
|
95
|
-
return ["Run another full verification pass after the next meaningful change."];
|
|
96
|
-
default:
|
|
97
|
-
return ["Rerun verification after the blockers are fixed."];
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
function renderChecklist(title, items) {
|
|
101
|
-
if (items.length === 0) {
|
|
102
|
-
return `## ${title}\n\n- None.\n`;
|
|
103
|
-
}
|
|
104
|
-
return `## ${title}\n\n${items.map((item) => `- ${item}`).join("\n")}\n`;
|
|
105
|
-
}
|
|
106
|
-
function renderBuildErrors(errors) {
|
|
107
|
-
if (errors.length === 0)
|
|
108
|
-
return "";
|
|
109
|
-
const trimmed = errors.slice(0, 5).map((error) => error.trim()).filter(Boolean);
|
|
110
|
-
if (trimmed.length === 0)
|
|
111
|
-
return "";
|
|
112
|
-
return [
|
|
113
|
-
"## Build Errors",
|
|
114
|
-
"",
|
|
115
|
-
"```text",
|
|
116
|
-
...trimmed,
|
|
117
|
-
"```",
|
|
118
|
-
"",
|
|
119
|
-
].join("\n");
|
|
120
|
-
}
|
|
121
|
-
function renderE2EFailures(result) {
|
|
122
|
-
const failedScenarios = result.e2e?.results.filter((scenario) => !scenario.passed).slice(0, 5) ?? [];
|
|
123
|
-
if (failedScenarios.length === 0) {
|
|
124
|
-
return "";
|
|
125
|
-
}
|
|
126
|
-
const lines = ["## Failed E2E Scenarios", ""];
|
|
127
|
-
for (const scenario of failedScenarios) {
|
|
128
|
-
lines.push(`### ${scenario.name}`);
|
|
129
|
-
if (scenario.error) {
|
|
130
|
-
lines.push("", `- Error: ${scenario.error}`);
|
|
131
|
-
}
|
|
132
|
-
const failedSteps = scenario.steps.filter((step) => !step.passed).slice(0, 3);
|
|
133
|
-
if (failedSteps.length > 0) {
|
|
134
|
-
lines.push("", "- Failing steps:");
|
|
135
|
-
for (const step of failedSteps) {
|
|
136
|
-
const detail = step.error ? ` - ${step.error}` : "";
|
|
137
|
-
lines.push(` - ${step.description}${detail}`);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
lines.push("");
|
|
141
|
-
}
|
|
142
|
-
return `${lines.join("\n")}\n`;
|
|
143
|
-
}
|
|
144
|
-
function renderMetrics(result) {
|
|
145
|
-
const lines = ["## Verification Evidence", ""];
|
|
146
|
-
lines.push("| Check | Result |");
|
|
147
|
-
lines.push("|---|---|");
|
|
148
|
-
lines.push(`| Build | ${result.build.success ? "Passed" : "Failed"} in ${result.build.durationMs}ms |`);
|
|
149
|
-
if (result.lighthouse) {
|
|
150
|
-
lines.push(`| Lighthouse | P ${result.lighthouse.performance}, A ${result.lighthouse.accessibility}, SEO ${result.lighthouse.seo}, BP ${result.lighthouse.bestPractices} over ${result.lighthouse.runs} run(s) |`);
|
|
151
|
-
}
|
|
152
|
-
else {
|
|
153
|
-
lines.push("| Lighthouse | Skipped |");
|
|
154
|
-
}
|
|
155
|
-
if (result.e2e) {
|
|
156
|
-
lines.push(`| E2E | ${result.e2e.passed}/${result.e2e.total} passed |`);
|
|
157
|
-
}
|
|
158
|
-
const reportInput = result.verification?.report.evidence.input;
|
|
159
|
-
if (typeof reportInput?.viewportIssues === "number" || typeof reportInput?.multiViewportPassed === "boolean") {
|
|
160
|
-
lines.push(`| Multi-viewport | ${reportInput.multiViewportPassed ? "Passed" : "Needs work"}${reportInput.multiViewportSummary ? `, ${reportInput.multiViewportSummary}` : ""} |`);
|
|
161
|
-
}
|
|
162
|
-
if (result.visualDiff) {
|
|
163
|
-
lines.push(`| Visual diff | ${(0, visual_diff_js_1.formatVisualDiffSummary)(result.visualDiff)} |`);
|
|
164
|
-
}
|
|
165
|
-
if (result.typecheck && !result.typecheck.skipped) {
|
|
166
|
-
lines.push(`| TypeScript | ${result.typecheck.passed ? "OK" : `${result.typecheck.errorCount} error(s)`} |`);
|
|
167
|
-
}
|
|
168
|
-
if (result.secretScan && !result.secretScan.skipped) {
|
|
169
|
-
lines.push(`| Secret scan | ${result.secretScan.passed ? "OK" : `${result.secretScan.findings.length} finding(s)`} (${result.secretScan.filesScanned} files) |`);
|
|
170
|
-
}
|
|
171
|
-
if (result.bundleSize && !result.bundleSize.skipped) {
|
|
172
|
-
lines.push(`| Bundle size | ${result.bundleSize.advisory} |`);
|
|
173
|
-
}
|
|
174
|
-
if (result.outdatedCheck && !result.outdatedCheck.skipped) {
|
|
175
|
-
lines.push(`| Outdated deps | ${result.outdatedCheck.advisory} |`);
|
|
176
|
-
}
|
|
177
|
-
if (result.a11yDeep && !result.a11yDeep.skipped) {
|
|
178
|
-
lines.push(`| A11y deep | ${result.a11yDeep.summary} |`);
|
|
179
|
-
}
|
|
180
|
-
if (result.seoDeep && !result.seoDeep.skipped) {
|
|
181
|
-
lines.push(`| SEO deep | ${result.seoDeep.summary} |`);
|
|
182
|
-
}
|
|
183
|
-
if (result.vitalsBudget && !result.vitalsBudget.skipped) {
|
|
184
|
-
lines.push(`| Vitals budget | ${result.vitalsBudget.summary} |`);
|
|
185
|
-
}
|
|
186
|
-
lines.push("");
|
|
187
|
-
return `${lines.join("\n")}\n`;
|
|
188
|
-
}
|
|
189
|
-
function getReportFlavor(view) {
|
|
190
|
-
switch (view.tier) {
|
|
191
|
-
case "pro":
|
|
192
|
-
return "delivery";
|
|
193
|
-
case "team":
|
|
194
|
-
return "release";
|
|
195
|
-
default:
|
|
196
|
-
return "generic";
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
function sectionTitle(flavor, key) {
|
|
200
|
-
if (flavor === "delivery") {
|
|
201
|
-
switch (key) {
|
|
202
|
-
case "title":
|
|
203
|
-
return "Laxy Verify Delivery Report";
|
|
204
|
-
case "decision":
|
|
205
|
-
return "Client Delivery Call";
|
|
206
|
-
case "evidence":
|
|
207
|
-
return "Delivery Evidence";
|
|
208
|
-
case "passes":
|
|
209
|
-
return "What Looks Ready";
|
|
210
|
-
case "blockers":
|
|
211
|
-
return "Client-Facing Blockers";
|
|
212
|
-
case "warnings":
|
|
213
|
-
return "Watch Before Delivery";
|
|
214
|
-
case "nextActions":
|
|
215
|
-
return "Fix Before Sending";
|
|
216
|
-
case "recordedEvidence":
|
|
217
|
-
return "Proof Collected In This Run";
|
|
218
|
-
case "copy":
|
|
219
|
-
return "Copy For AI";
|
|
220
|
-
default:
|
|
221
|
-
return key;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
if (flavor === "release") {
|
|
225
|
-
switch (key) {
|
|
226
|
-
case "title":
|
|
227
|
-
return "Laxy Verify Release Report";
|
|
228
|
-
case "decision":
|
|
229
|
-
return "Release Call";
|
|
230
|
-
case "evidence":
|
|
231
|
-
return "Release Evidence";
|
|
232
|
-
case "passes":
|
|
233
|
-
return "Release Signals That Passed";
|
|
234
|
-
case "blockers":
|
|
235
|
-
return "Release Blockers";
|
|
236
|
-
case "warnings":
|
|
237
|
-
return "Release Risks To Watch";
|
|
238
|
-
case "nextActions":
|
|
239
|
-
return "What Must Happen Next";
|
|
240
|
-
case "recordedEvidence":
|
|
241
|
-
return "Evidence Pack";
|
|
242
|
-
case "copy":
|
|
243
|
-
return "Copy For AI";
|
|
244
|
-
default:
|
|
245
|
-
return key;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
switch (key) {
|
|
249
|
-
case "title":
|
|
250
|
-
return "Laxy Verify Report";
|
|
251
|
-
case "decision":
|
|
252
|
-
return "Decision";
|
|
253
|
-
case "evidence":
|
|
254
|
-
return "Verification Evidence";
|
|
255
|
-
case "passes":
|
|
256
|
-
return "What Passed";
|
|
257
|
-
case "blockers":
|
|
258
|
-
return "Blockers";
|
|
259
|
-
case "warnings":
|
|
260
|
-
return "Warnings";
|
|
261
|
-
case "nextActions":
|
|
262
|
-
return "Next Actions";
|
|
263
|
-
case "recordedEvidence":
|
|
264
|
-
return "Recorded Evidence";
|
|
265
|
-
case "copy":
|
|
266
|
-
return "Copy For AI";
|
|
267
|
-
default:
|
|
268
|
-
return key;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
function renderCopyForAI(result, flavor) {
|
|
272
|
-
const view = result.verification?.view;
|
|
273
|
-
if (!view)
|
|
274
|
-
return "";
|
|
275
|
-
const blockers = view.blockers.map((blocker) => `- ${blocker.title}: ${blocker.action}`);
|
|
276
|
-
const warnings = view.warnings.map((warning) => `- ${warning.title}: ${warning.action}`);
|
|
277
|
-
const evidence = view.failureEvidence.map((item) => `- ${item}`);
|
|
278
|
-
const closingLine = view.verdict === "release-ready"
|
|
279
|
-
? "Use this as release evidence, or rerun after any code change that could affect quality."
|
|
280
|
-
: view.verdict === "client-ready"
|
|
281
|
-
? "Use this as client handoff evidence, or rerun after any code change that could affect user-facing flows."
|
|
282
|
-
: view.verdict === "investigate" && view.blockers.length === 0
|
|
283
|
-
? "Collect the missing verification evidence, then rerun the command and compare the new report."
|
|
284
|
-
: "Please fix the blockers first, then rerun the verification command and compare the new report.";
|
|
285
|
-
const openingLine = flavor === "release"
|
|
286
|
-
? "Use this release report to decide whether the project is truly ready to ship."
|
|
287
|
-
: flavor === "delivery"
|
|
288
|
-
? "Use this delivery report to fix the project before sending it to a client."
|
|
289
|
-
: "Use this verification report to fix the project.";
|
|
290
|
-
const targetLine = flavor === "release"
|
|
291
|
-
? "Goal: reach a release-ready verdict with strong viewport, visual, and user-flow evidence."
|
|
292
|
-
: flavor === "delivery"
|
|
293
|
-
? "Goal: remove client-visible blockers and reach a confident client-ready call."
|
|
294
|
-
: "Goal: fix the blockers and improve confidence on the next run.";
|
|
295
|
-
return [
|
|
296
|
-
`## ${sectionTitle(flavor, "copy")}`,
|
|
297
|
-
"",
|
|
298
|
-
"```text",
|
|
299
|
-
openingLine,
|
|
300
|
-
"",
|
|
301
|
-
`Plan: ${titleCasePlan(result._plan)}`,
|
|
302
|
-
`Question: ${view.question}`,
|
|
303
|
-
`Verdict: ${titleCaseVerdict(view.verdict)}`,
|
|
304
|
-
targetLine,
|
|
305
|
-
"",
|
|
306
|
-
"Priority blockers:",
|
|
307
|
-
...(blockers.length > 0 ? blockers : ["- None listed."]),
|
|
308
|
-
"",
|
|
309
|
-
"Warnings to review after blockers:",
|
|
310
|
-
...(warnings.length > 0 ? warnings : ["- None listed."]),
|
|
311
|
-
"",
|
|
312
|
-
"Evidence from the verification run:",
|
|
313
|
-
...(evidence.length > 0 ? evidence : ["- No extra evidence recorded."]),
|
|
314
|
-
"",
|
|
315
|
-
closingLine,
|
|
316
|
-
"```",
|
|
317
|
-
"",
|
|
318
|
-
].join("\n");
|
|
319
|
-
}
|
|
320
|
-
function shouldWriteMarkdownReport(result) {
|
|
321
|
-
return result.verification?.view.showReportExport === true;
|
|
322
|
-
}
|
|
323
|
-
function getMarkdownReportPath(projectDir) {
|
|
324
|
-
return path.join(projectDir, "laxy-verify-report.md");
|
|
325
|
-
}
|
|
326
|
-
function buildMarkdownReport(projectDir, result) {
|
|
327
|
-
const projectName = path.basename(path.resolve(projectDir));
|
|
328
|
-
const plan = titleCasePlan(result._plan);
|
|
329
|
-
const view = result.verification?.view;
|
|
330
|
-
if (!view) {
|
|
331
|
-
return [
|
|
332
|
-
"# Laxy Verify Report",
|
|
333
|
-
"",
|
|
334
|
-
`Project: ${projectName}`,
|
|
335
|
-
`Generated: ${formatTimestamp(result.timestamp)}`,
|
|
336
|
-
"",
|
|
337
|
-
"No detailed verification report was available for this run.",
|
|
338
|
-
"",
|
|
339
|
-
].join("\n");
|
|
340
|
-
}
|
|
341
|
-
const blockers = view.blockers.map((blocker) => `**${blocker.title}**\n Why it matters: ${blocker.description}\n What to do: ${blocker.action}`);
|
|
342
|
-
const warnings = view.warnings.map((warning) => `**${warning.title}**\n Why it matters: ${warning.description}\n What to do: ${warning.action}`);
|
|
343
|
-
const passes = view.passes.map((check) => `${check.passed ? "Passed" : "Failed"}: ${check.label}`);
|
|
344
|
-
const nextActions = defaultNextActions(result);
|
|
345
|
-
const flavor = getReportFlavor(view);
|
|
346
|
-
const decisionLead = flavor === "release"
|
|
347
|
-
? "This section answers whether the current build is strong enough to call release-ready."
|
|
348
|
-
: flavor === "delivery"
|
|
349
|
-
? "This section answers whether the current build is strong enough to call client-ready."
|
|
350
|
-
: "This section explains the outcome of the current verification run.";
|
|
351
|
-
const atAGlanceLead = flavor === "release"
|
|
352
|
-
? "This report is written for a ship or hold decision."
|
|
353
|
-
: flavor === "delivery"
|
|
354
|
-
? "This report is written for a client handoff decision."
|
|
355
|
-
: "This report is written for a quick verification summary.";
|
|
356
|
-
const decisionLabel = flavor === "release"
|
|
357
|
-
? "Release recommendation"
|
|
358
|
-
: flavor === "delivery"
|
|
359
|
-
? "Client delivery recommendation"
|
|
360
|
-
: "Recommendation";
|
|
361
|
-
return [
|
|
362
|
-
`# ${sectionTitle(flavor, "title")}`,
|
|
363
|
-
"",
|
|
364
|
-
`Project: ${projectName}`,
|
|
365
|
-
`Generated: ${formatTimestamp(result.timestamp)}`,
|
|
366
|
-
`Plan: ${plan}`,
|
|
367
|
-
`Framework: ${result.framework ?? "unknown"}`,
|
|
368
|
-
"",
|
|
369
|
-
"## At A Glance",
|
|
370
|
-
"",
|
|
371
|
-
atAGlanceLead,
|
|
372
|
-
"",
|
|
373
|
-
`Short answer: ${sentenceForVerdict(view)}`,
|
|
374
|
-
`Why: ${view.summary}`,
|
|
375
|
-
`Recommended next move: ${nextActions[0]}`,
|
|
376
|
-
"",
|
|
377
|
-
`## ${sectionTitle(flavor, "decision")}`,
|
|
378
|
-
"",
|
|
379
|
-
decisionLead,
|
|
380
|
-
"",
|
|
381
|
-
`Question: ${view.question}`,
|
|
382
|
-
`Answer: ${titleCaseVerdict(view.verdict)}`,
|
|
383
|
-
`Verdict: ${titleCaseVerdict(view.verdict)}`,
|
|
384
|
-
`${decisionLabel}: ${titleCaseVerdict(view.verdict)}`,
|
|
385
|
-
`Confidence: ${view.confidence}`,
|
|
386
|
-
`Grade: ${result.grade}`,
|
|
387
|
-
"",
|
|
388
|
-
renderMetrics(result).replace("## Verification Evidence", `## ${sectionTitle(flavor, "evidence")}`).trimEnd(),
|
|
389
|
-
"",
|
|
390
|
-
renderChecklist(sectionTitle(flavor, "passes"), passes).trimEnd(),
|
|
391
|
-
"",
|
|
392
|
-
renderChecklist(sectionTitle(flavor, "blockers"), blockers).trimEnd(),
|
|
393
|
-
"",
|
|
394
|
-
renderChecklist(sectionTitle(flavor, "warnings"), warnings).trimEnd(),
|
|
395
|
-
"",
|
|
396
|
-
renderChecklist(sectionTitle(flavor, "nextActions"), nextActions).trimEnd(),
|
|
397
|
-
"",
|
|
398
|
-
renderChecklist(sectionTitle(flavor, "recordedEvidence"), view.failureEvidence).trimEnd(),
|
|
399
|
-
"",
|
|
400
|
-
renderBuildErrors(result.build.errors).trimEnd(),
|
|
401
|
-
renderE2EFailures(result).trimEnd(),
|
|
402
|
-
renderCopyForAI(result, flavor).trimEnd(),
|
|
403
|
-
"",
|
|
404
|
-
]
|
|
405
|
-
.filter(Boolean)
|
|
406
|
-
.join("\n");
|
|
407
|
-
}
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.shouldWriteMarkdownReport = shouldWriteMarkdownReport;
|
|
37
|
+
exports.getMarkdownReportPath = getMarkdownReportPath;
|
|
38
|
+
exports.buildMarkdownReport = buildMarkdownReport;
|
|
39
|
+
const path = __importStar(require("node:path"));
|
|
40
|
+
const visual_diff_js_1 = require("./visual-diff.js");
|
|
41
|
+
function titleCasePlan(plan) {
|
|
42
|
+
switch (plan) {
|
|
43
|
+
case "pro":
|
|
44
|
+
return "Pro";
|
|
45
|
+
case "team":
|
|
46
|
+
return "Team";
|
|
47
|
+
default:
|
|
48
|
+
return "Free";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function titleCaseVerdict(verdict) {
|
|
52
|
+
return verdict
|
|
53
|
+
.split("-")
|
|
54
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
55
|
+
.join(" ");
|
|
56
|
+
}
|
|
57
|
+
function formatTimestamp(iso) {
|
|
58
|
+
const date = new Date(iso);
|
|
59
|
+
if (Number.isNaN(date.getTime()))
|
|
60
|
+
return iso;
|
|
61
|
+
return date.toISOString().replace("T", " ").replace(".000Z", " UTC");
|
|
62
|
+
}
|
|
63
|
+
function sentenceForVerdict(view) {
|
|
64
|
+
switch (view.verdict) {
|
|
65
|
+
case "client-ready":
|
|
66
|
+
return "Yes. This run collected enough evidence to support a client-ready call.";
|
|
67
|
+
case "release-ready":
|
|
68
|
+
return "Yes. This run collected enough evidence to support a release-ready call.";
|
|
69
|
+
case "hold":
|
|
70
|
+
return "No. This run found blockers that should be fixed before release.";
|
|
71
|
+
case "investigate":
|
|
72
|
+
return "Not yet. The project is standing, but there is not enough confidence to call it release-ready.";
|
|
73
|
+
case "build-failed":
|
|
74
|
+
return "No. The production build failed, so the release should be held immediately.";
|
|
75
|
+
default:
|
|
76
|
+
return "This run did not find an immediate hard blocker, but it is still a shallow release-confidence pass.";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function defaultNextActions(result) {
|
|
80
|
+
const view = result.verification?.view;
|
|
81
|
+
if (!view)
|
|
82
|
+
return ["Rerun verification after the project changes are applied."];
|
|
83
|
+
if (view.nextActions.length > 0)
|
|
84
|
+
return view.nextActions;
|
|
85
|
+
switch (view.verdict) {
|
|
86
|
+
case "client-ready":
|
|
87
|
+
return ["Send this version to the client, or rerun verification after meaningful UI or flow changes."];
|
|
88
|
+
case "release-ready":
|
|
89
|
+
return ["Ship this version, or archive this report as release evidence."];
|
|
90
|
+
case "investigate":
|
|
91
|
+
return ["Collect the missing verification evidence, then rerun the command before release."];
|
|
92
|
+
case "build-failed":
|
|
93
|
+
return ["Fix the production build first, then rerun the verification command."];
|
|
94
|
+
case "quick-pass":
|
|
95
|
+
return ["Run another full verification pass after the next meaningful change."];
|
|
96
|
+
default:
|
|
97
|
+
return ["Rerun verification after the blockers are fixed."];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function renderChecklist(title, items) {
|
|
101
|
+
if (items.length === 0) {
|
|
102
|
+
return `## ${title}\n\n- None.\n`;
|
|
103
|
+
}
|
|
104
|
+
return `## ${title}\n\n${items.map((item) => `- ${item}`).join("\n")}\n`;
|
|
105
|
+
}
|
|
106
|
+
function renderBuildErrors(errors) {
|
|
107
|
+
if (errors.length === 0)
|
|
108
|
+
return "";
|
|
109
|
+
const trimmed = errors.slice(0, 5).map((error) => error.trim()).filter(Boolean);
|
|
110
|
+
if (trimmed.length === 0)
|
|
111
|
+
return "";
|
|
112
|
+
return [
|
|
113
|
+
"## Build Errors",
|
|
114
|
+
"",
|
|
115
|
+
"```text",
|
|
116
|
+
...trimmed,
|
|
117
|
+
"```",
|
|
118
|
+
"",
|
|
119
|
+
].join("\n");
|
|
120
|
+
}
|
|
121
|
+
function renderE2EFailures(result) {
|
|
122
|
+
const failedScenarios = result.e2e?.results.filter((scenario) => !scenario.passed).slice(0, 5) ?? [];
|
|
123
|
+
if (failedScenarios.length === 0) {
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
const lines = ["## Failed E2E Scenarios", ""];
|
|
127
|
+
for (const scenario of failedScenarios) {
|
|
128
|
+
lines.push(`### ${scenario.name}`);
|
|
129
|
+
if (scenario.error) {
|
|
130
|
+
lines.push("", `- Error: ${scenario.error}`);
|
|
131
|
+
}
|
|
132
|
+
const failedSteps = scenario.steps.filter((step) => !step.passed).slice(0, 3);
|
|
133
|
+
if (failedSteps.length > 0) {
|
|
134
|
+
lines.push("", "- Failing steps:");
|
|
135
|
+
for (const step of failedSteps) {
|
|
136
|
+
const detail = step.error ? ` - ${step.error}` : "";
|
|
137
|
+
lines.push(` - ${step.description}${detail}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
lines.push("");
|
|
141
|
+
}
|
|
142
|
+
return `${lines.join("\n")}\n`;
|
|
143
|
+
}
|
|
144
|
+
function renderMetrics(result) {
|
|
145
|
+
const lines = ["## Verification Evidence", ""];
|
|
146
|
+
lines.push("| Check | Result |");
|
|
147
|
+
lines.push("|---|---|");
|
|
148
|
+
lines.push(`| Build | ${result.build.success ? "Passed" : "Failed"} in ${result.build.durationMs}ms |`);
|
|
149
|
+
if (result.lighthouse) {
|
|
150
|
+
lines.push(`| Lighthouse | P ${result.lighthouse.performance}, A ${result.lighthouse.accessibility}, SEO ${result.lighthouse.seo}, BP ${result.lighthouse.bestPractices} over ${result.lighthouse.runs} run(s) |`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
lines.push("| Lighthouse | Skipped |");
|
|
154
|
+
}
|
|
155
|
+
if (result.e2e) {
|
|
156
|
+
lines.push(`| E2E | ${result.e2e.passed}/${result.e2e.total} passed |`);
|
|
157
|
+
}
|
|
158
|
+
const reportInput = result.verification?.report.evidence.input;
|
|
159
|
+
if (typeof reportInput?.viewportIssues === "number" || typeof reportInput?.multiViewportPassed === "boolean") {
|
|
160
|
+
lines.push(`| Multi-viewport | ${reportInput.multiViewportPassed ? "Passed" : "Needs work"}${reportInput.multiViewportSummary ? `, ${reportInput.multiViewportSummary}` : ""} |`);
|
|
161
|
+
}
|
|
162
|
+
if (result.visualDiff) {
|
|
163
|
+
lines.push(`| Visual diff | ${(0, visual_diff_js_1.formatVisualDiffSummary)(result.visualDiff)} |`);
|
|
164
|
+
}
|
|
165
|
+
if (result.typecheck && !result.typecheck.skipped) {
|
|
166
|
+
lines.push(`| TypeScript | ${result.typecheck.passed ? "OK" : `${result.typecheck.errorCount} error(s)`} |`);
|
|
167
|
+
}
|
|
168
|
+
if (result.secretScan && !result.secretScan.skipped) {
|
|
169
|
+
lines.push(`| Secret scan | ${result.secretScan.passed ? "OK" : `${result.secretScan.findings.length} finding(s)`} (${result.secretScan.filesScanned} files) |`);
|
|
170
|
+
}
|
|
171
|
+
if (result.bundleSize && !result.bundleSize.skipped) {
|
|
172
|
+
lines.push(`| Bundle size | ${result.bundleSize.advisory} |`);
|
|
173
|
+
}
|
|
174
|
+
if (result.outdatedCheck && !result.outdatedCheck.skipped) {
|
|
175
|
+
lines.push(`| Outdated deps | ${result.outdatedCheck.advisory} |`);
|
|
176
|
+
}
|
|
177
|
+
if (result.a11yDeep && !result.a11yDeep.skipped) {
|
|
178
|
+
lines.push(`| A11y deep | ${result.a11yDeep.summary} |`);
|
|
179
|
+
}
|
|
180
|
+
if (result.seoDeep && !result.seoDeep.skipped) {
|
|
181
|
+
lines.push(`| SEO deep | ${result.seoDeep.summary} |`);
|
|
182
|
+
}
|
|
183
|
+
if (result.vitalsBudget && !result.vitalsBudget.skipped) {
|
|
184
|
+
lines.push(`| Vitals budget | ${result.vitalsBudget.summary} |`);
|
|
185
|
+
}
|
|
186
|
+
lines.push("");
|
|
187
|
+
return `${lines.join("\n")}\n`;
|
|
188
|
+
}
|
|
189
|
+
function getReportFlavor(view) {
|
|
190
|
+
switch (view.tier) {
|
|
191
|
+
case "pro":
|
|
192
|
+
return "delivery";
|
|
193
|
+
case "team":
|
|
194
|
+
return "release";
|
|
195
|
+
default:
|
|
196
|
+
return "generic";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function sectionTitle(flavor, key) {
|
|
200
|
+
if (flavor === "delivery") {
|
|
201
|
+
switch (key) {
|
|
202
|
+
case "title":
|
|
203
|
+
return "Laxy Verify Delivery Report";
|
|
204
|
+
case "decision":
|
|
205
|
+
return "Client Delivery Call";
|
|
206
|
+
case "evidence":
|
|
207
|
+
return "Delivery Evidence";
|
|
208
|
+
case "passes":
|
|
209
|
+
return "What Looks Ready";
|
|
210
|
+
case "blockers":
|
|
211
|
+
return "Client-Facing Blockers";
|
|
212
|
+
case "warnings":
|
|
213
|
+
return "Watch Before Delivery";
|
|
214
|
+
case "nextActions":
|
|
215
|
+
return "Fix Before Sending";
|
|
216
|
+
case "recordedEvidence":
|
|
217
|
+
return "Proof Collected In This Run";
|
|
218
|
+
case "copy":
|
|
219
|
+
return "Copy For AI";
|
|
220
|
+
default:
|
|
221
|
+
return key;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (flavor === "release") {
|
|
225
|
+
switch (key) {
|
|
226
|
+
case "title":
|
|
227
|
+
return "Laxy Verify Release Report";
|
|
228
|
+
case "decision":
|
|
229
|
+
return "Release Call";
|
|
230
|
+
case "evidence":
|
|
231
|
+
return "Release Evidence";
|
|
232
|
+
case "passes":
|
|
233
|
+
return "Release Signals That Passed";
|
|
234
|
+
case "blockers":
|
|
235
|
+
return "Release Blockers";
|
|
236
|
+
case "warnings":
|
|
237
|
+
return "Release Risks To Watch";
|
|
238
|
+
case "nextActions":
|
|
239
|
+
return "What Must Happen Next";
|
|
240
|
+
case "recordedEvidence":
|
|
241
|
+
return "Evidence Pack";
|
|
242
|
+
case "copy":
|
|
243
|
+
return "Copy For AI";
|
|
244
|
+
default:
|
|
245
|
+
return key;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
switch (key) {
|
|
249
|
+
case "title":
|
|
250
|
+
return "Laxy Verify Report";
|
|
251
|
+
case "decision":
|
|
252
|
+
return "Decision";
|
|
253
|
+
case "evidence":
|
|
254
|
+
return "Verification Evidence";
|
|
255
|
+
case "passes":
|
|
256
|
+
return "What Passed";
|
|
257
|
+
case "blockers":
|
|
258
|
+
return "Blockers";
|
|
259
|
+
case "warnings":
|
|
260
|
+
return "Warnings";
|
|
261
|
+
case "nextActions":
|
|
262
|
+
return "Next Actions";
|
|
263
|
+
case "recordedEvidence":
|
|
264
|
+
return "Recorded Evidence";
|
|
265
|
+
case "copy":
|
|
266
|
+
return "Copy For AI";
|
|
267
|
+
default:
|
|
268
|
+
return key;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function renderCopyForAI(result, flavor) {
|
|
272
|
+
const view = result.verification?.view;
|
|
273
|
+
if (!view)
|
|
274
|
+
return "";
|
|
275
|
+
const blockers = view.blockers.map((blocker) => `- ${blocker.title}: ${blocker.action}`);
|
|
276
|
+
const warnings = view.warnings.map((warning) => `- ${warning.title}: ${warning.action}`);
|
|
277
|
+
const evidence = view.failureEvidence.map((item) => `- ${item}`);
|
|
278
|
+
const closingLine = view.verdict === "release-ready"
|
|
279
|
+
? "Use this as release evidence, or rerun after any code change that could affect quality."
|
|
280
|
+
: view.verdict === "client-ready"
|
|
281
|
+
? "Use this as client handoff evidence, or rerun after any code change that could affect user-facing flows."
|
|
282
|
+
: view.verdict === "investigate" && view.blockers.length === 0
|
|
283
|
+
? "Collect the missing verification evidence, then rerun the command and compare the new report."
|
|
284
|
+
: "Please fix the blockers first, then rerun the verification command and compare the new report.";
|
|
285
|
+
const openingLine = flavor === "release"
|
|
286
|
+
? "Use this release report to decide whether the project is truly ready to ship."
|
|
287
|
+
: flavor === "delivery"
|
|
288
|
+
? "Use this delivery report to fix the project before sending it to a client."
|
|
289
|
+
: "Use this verification report to fix the project.";
|
|
290
|
+
const targetLine = flavor === "release"
|
|
291
|
+
? "Goal: reach a release-ready verdict with strong viewport, visual, and user-flow evidence."
|
|
292
|
+
: flavor === "delivery"
|
|
293
|
+
? "Goal: remove client-visible blockers and reach a confident client-ready call."
|
|
294
|
+
: "Goal: fix the blockers and improve confidence on the next run.";
|
|
295
|
+
return [
|
|
296
|
+
`## ${sectionTitle(flavor, "copy")}`,
|
|
297
|
+
"",
|
|
298
|
+
"```text",
|
|
299
|
+
openingLine,
|
|
300
|
+
"",
|
|
301
|
+
`Plan: ${titleCasePlan(result._plan)}`,
|
|
302
|
+
`Question: ${view.question}`,
|
|
303
|
+
`Verdict: ${titleCaseVerdict(view.verdict)}`,
|
|
304
|
+
targetLine,
|
|
305
|
+
"",
|
|
306
|
+
"Priority blockers:",
|
|
307
|
+
...(blockers.length > 0 ? blockers : ["- None listed."]),
|
|
308
|
+
"",
|
|
309
|
+
"Warnings to review after blockers:",
|
|
310
|
+
...(warnings.length > 0 ? warnings : ["- None listed."]),
|
|
311
|
+
"",
|
|
312
|
+
"Evidence from the verification run:",
|
|
313
|
+
...(evidence.length > 0 ? evidence : ["- No extra evidence recorded."]),
|
|
314
|
+
"",
|
|
315
|
+
closingLine,
|
|
316
|
+
"```",
|
|
317
|
+
"",
|
|
318
|
+
].join("\n");
|
|
319
|
+
}
|
|
320
|
+
function shouldWriteMarkdownReport(result) {
|
|
321
|
+
return result.verification?.view.showReportExport === true;
|
|
322
|
+
}
|
|
323
|
+
function getMarkdownReportPath(projectDir) {
|
|
324
|
+
return path.join(projectDir, "laxy-verify-report.md");
|
|
325
|
+
}
|
|
326
|
+
function buildMarkdownReport(projectDir, result) {
|
|
327
|
+
const projectName = path.basename(path.resolve(projectDir));
|
|
328
|
+
const plan = titleCasePlan(result._plan);
|
|
329
|
+
const view = result.verification?.view;
|
|
330
|
+
if (!view) {
|
|
331
|
+
return [
|
|
332
|
+
"# Laxy Verify Report",
|
|
333
|
+
"",
|
|
334
|
+
`Project: ${projectName}`,
|
|
335
|
+
`Generated: ${formatTimestamp(result.timestamp)}`,
|
|
336
|
+
"",
|
|
337
|
+
"No detailed verification report was available for this run.",
|
|
338
|
+
"",
|
|
339
|
+
].join("\n");
|
|
340
|
+
}
|
|
341
|
+
const blockers = view.blockers.map((blocker) => `**${blocker.title}**\n Why it matters: ${blocker.description}\n What to do: ${blocker.action}`);
|
|
342
|
+
const warnings = view.warnings.map((warning) => `**${warning.title}**\n Why it matters: ${warning.description}\n What to do: ${warning.action}`);
|
|
343
|
+
const passes = view.passes.map((check) => `${check.passed ? "Passed" : "Failed"}: ${check.label}`);
|
|
344
|
+
const nextActions = defaultNextActions(result);
|
|
345
|
+
const flavor = getReportFlavor(view);
|
|
346
|
+
const decisionLead = flavor === "release"
|
|
347
|
+
? "This section answers whether the current build is strong enough to call release-ready."
|
|
348
|
+
: flavor === "delivery"
|
|
349
|
+
? "This section answers whether the current build is strong enough to call client-ready."
|
|
350
|
+
: "This section explains the outcome of the current verification run.";
|
|
351
|
+
const atAGlanceLead = flavor === "release"
|
|
352
|
+
? "This report is written for a ship or hold decision."
|
|
353
|
+
: flavor === "delivery"
|
|
354
|
+
? "This report is written for a client handoff decision."
|
|
355
|
+
: "This report is written for a quick verification summary.";
|
|
356
|
+
const decisionLabel = flavor === "release"
|
|
357
|
+
? "Release recommendation"
|
|
358
|
+
: flavor === "delivery"
|
|
359
|
+
? "Client delivery recommendation"
|
|
360
|
+
: "Recommendation";
|
|
361
|
+
return [
|
|
362
|
+
`# ${sectionTitle(flavor, "title")}`,
|
|
363
|
+
"",
|
|
364
|
+
`Project: ${projectName}`,
|
|
365
|
+
`Generated: ${formatTimestamp(result.timestamp)}`,
|
|
366
|
+
`Plan: ${plan}`,
|
|
367
|
+
`Framework: ${result.framework ?? "unknown"}`,
|
|
368
|
+
"",
|
|
369
|
+
"## At A Glance",
|
|
370
|
+
"",
|
|
371
|
+
atAGlanceLead,
|
|
372
|
+
"",
|
|
373
|
+
`Short answer: ${sentenceForVerdict(view)}`,
|
|
374
|
+
`Why: ${view.summary}`,
|
|
375
|
+
`Recommended next move: ${nextActions[0]}`,
|
|
376
|
+
"",
|
|
377
|
+
`## ${sectionTitle(flavor, "decision")}`,
|
|
378
|
+
"",
|
|
379
|
+
decisionLead,
|
|
380
|
+
"",
|
|
381
|
+
`Question: ${view.question}`,
|
|
382
|
+
`Answer: ${titleCaseVerdict(view.verdict)}`,
|
|
383
|
+
`Verdict: ${titleCaseVerdict(view.verdict)}`,
|
|
384
|
+
`${decisionLabel}: ${titleCaseVerdict(view.verdict)}`,
|
|
385
|
+
`Confidence: ${view.confidence}`,
|
|
386
|
+
`Grade: ${result.grade}`,
|
|
387
|
+
"",
|
|
388
|
+
renderMetrics(result).replace("## Verification Evidence", `## ${sectionTitle(flavor, "evidence")}`).trimEnd(),
|
|
389
|
+
"",
|
|
390
|
+
renderChecklist(sectionTitle(flavor, "passes"), passes).trimEnd(),
|
|
391
|
+
"",
|
|
392
|
+
renderChecklist(sectionTitle(flavor, "blockers"), blockers).trimEnd(),
|
|
393
|
+
"",
|
|
394
|
+
renderChecklist(sectionTitle(flavor, "warnings"), warnings).trimEnd(),
|
|
395
|
+
"",
|
|
396
|
+
renderChecklist(sectionTitle(flavor, "nextActions"), nextActions).trimEnd(),
|
|
397
|
+
"",
|
|
398
|
+
renderChecklist(sectionTitle(flavor, "recordedEvidence"), view.failureEvidence).trimEnd(),
|
|
399
|
+
"",
|
|
400
|
+
renderBuildErrors(result.build.errors).trimEnd(),
|
|
401
|
+
renderE2EFailures(result).trimEnd(),
|
|
402
|
+
renderCopyForAI(result, flavor).trimEnd(),
|
|
403
|
+
"",
|
|
404
|
+
]
|
|
405
|
+
.filter(Boolean)
|
|
406
|
+
.join("\n");
|
|
407
|
+
}
|