@arvorco/relentless 0.3.0 → 0.4.2
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/.claude/commands/relentless.constitution.md +1 -1
- package/.claude/commands/relentless.convert.md +25 -0
- package/.claude/commands/relentless.specify.md +1 -1
- package/.claude/skills/analyze/SKILL.md +113 -40
- package/.claude/skills/analyze/templates/analysis-report.md +138 -0
- package/.claude/skills/checklist/SKILL.md +143 -51
- package/.claude/skills/checklist/templates/checklist.md +43 -11
- package/.claude/skills/clarify/SKILL.md +70 -11
- package/.claude/skills/constitution/SKILL.md +61 -3
- package/.claude/skills/constitution/templates/constitution.md +241 -160
- package/.claude/skills/constitution/templates/prompt.md +150 -20
- package/.claude/skills/convert/SKILL.md +248 -0
- package/.claude/skills/implement/SKILL.md +82 -34
- package/.claude/skills/plan/SKILL.md +136 -27
- package/.claude/skills/plan/templates/plan.md +92 -9
- package/.claude/skills/specify/SKILL.md +110 -19
- package/.claude/skills/specify/scripts/bash/create-new-feature.sh +2 -2
- package/.claude/skills/specify/scripts/bash/setup-plan.sh +1 -1
- package/.claude/skills/specify/templates/spec.md +40 -5
- package/.claude/skills/tasks/SKILL.md +75 -1
- package/.claude/skills/tasks/templates/tasks.md +5 -4
- package/CHANGELOG.md +63 -1
- package/MANUAL.md +40 -0
- package/README.md +263 -11
- package/bin/relentless.ts +292 -5
- package/package.json +2 -2
- package/relentless/config.json +46 -2
- package/relentless/constitution.md +2 -2
- package/relentless/prompt.md +97 -18
- package/src/agents/amp.ts +53 -13
- package/src/agents/claude.ts +70 -15
- package/src/agents/codex.ts +73 -14
- package/src/agents/droid.ts +68 -14
- package/src/agents/exec.ts +96 -0
- package/src/agents/gemini.ts +59 -16
- package/src/agents/opencode.ts +188 -9
- package/src/cli/fallback-order.ts +210 -0
- package/src/cli/index.ts +63 -0
- package/src/cli/mode-flag.ts +198 -0
- package/src/cli/review-flags.ts +192 -0
- package/src/config/loader.ts +16 -1
- package/src/config/schema.ts +157 -2
- package/src/execution/runner.ts +144 -21
- package/src/init/scaffolder.ts +285 -25
- package/src/prd/parser.ts +92 -1
- package/src/prd/types.ts +136 -0
- package/src/review/index.ts +92 -0
- package/src/review/prompt.ts +293 -0
- package/src/review/runner.ts +337 -0
- package/src/review/tasks/docs.ts +529 -0
- package/src/review/tasks/index.ts +80 -0
- package/src/review/tasks/lint.ts +436 -0
- package/src/review/tasks/quality.ts +760 -0
- package/src/review/tasks/security.ts +452 -0
- package/src/review/tasks/test.ts +456 -0
- package/src/review/tasks/typecheck.ts +323 -0
- package/src/review/types.ts +139 -0
- package/src/routing/cascade.ts +310 -0
- package/src/routing/classifier.ts +338 -0
- package/src/routing/estimate.ts +270 -0
- package/src/routing/fallback.ts +512 -0
- package/src/routing/index.ts +124 -0
- package/src/routing/registry.ts +501 -0
- package/src/routing/report.ts +570 -0
- package/src/routing/router.ts +287 -0
- package/src/tui/App.tsx +2 -0
- package/src/tui/TUIRunner.tsx +103 -8
- package/src/tui/components/CurrentStory.tsx +23 -1
- package/src/tui/hooks/useTUI.ts +1 -0
- package/src/tui/types.ts +9 -0
- package/.claude/skills/specify/scripts/bash/update-agent-context.sh +0 -799
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lint Micro-Task
|
|
3
|
+
*
|
|
4
|
+
* Runs `bun run lint --format json` in the project's working directory,
|
|
5
|
+
* parses ESLint issues, and generates fix tasks.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Parses ESLint JSON output with file, line, column, severity, rule, message
|
|
9
|
+
* - Groups issues by file for efficient fixing
|
|
10
|
+
* - Generates fix tasks with priority "high" for errors only
|
|
11
|
+
* - Warnings are logged but NOT added to fixTasks
|
|
12
|
+
* - Reports parsing errors separately from lint violations
|
|
13
|
+
* - Includes autoFixable count for issues fixable with --fix
|
|
14
|
+
* - Includes disabledRulesCount for eslint-disable comments
|
|
15
|
+
* - Provides summary with total files scanned and breakdown by severity
|
|
16
|
+
* - Falls back to standard output parsing if JSON unavailable
|
|
17
|
+
*
|
|
18
|
+
* @module src/review/tasks/lint
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ReviewTaskResult, FixTask } from "../types";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Severity levels for lint issues
|
|
25
|
+
*/
|
|
26
|
+
export type LintSeverity = "error" | "warning";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A parsed ESLint issue
|
|
30
|
+
*/
|
|
31
|
+
export interface LintIssue {
|
|
32
|
+
/** File path */
|
|
33
|
+
file: string;
|
|
34
|
+
/** Line number (1-based) */
|
|
35
|
+
line: number;
|
|
36
|
+
/** Column number (1-based) */
|
|
37
|
+
column: number;
|
|
38
|
+
/** Issue severity */
|
|
39
|
+
severity: LintSeverity;
|
|
40
|
+
/** ESLint rule ID (e.g., "no-unused-vars") */
|
|
41
|
+
rule: string;
|
|
42
|
+
/** Issue message text */
|
|
43
|
+
message: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Summary of lint results
|
|
48
|
+
*/
|
|
49
|
+
export interface LintSummary {
|
|
50
|
+
/** Total files scanned */
|
|
51
|
+
totalFiles: number;
|
|
52
|
+
/** Total error count */
|
|
53
|
+
errorCount: number;
|
|
54
|
+
/** Total warning count */
|
|
55
|
+
warningCount: number;
|
|
56
|
+
/** Number of issues that can be auto-fixed with --fix */
|
|
57
|
+
autoFixable: number;
|
|
58
|
+
/** Number of parsing errors (invalid JS/TS) */
|
|
59
|
+
parsingErrors: number;
|
|
60
|
+
/** Count of eslint-disable comments */
|
|
61
|
+
disabledRulesCount: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Result from parsing lint output
|
|
66
|
+
*/
|
|
67
|
+
export interface LintParseResult {
|
|
68
|
+
/** Parsed lint issues */
|
|
69
|
+
issues: LintIssue[];
|
|
70
|
+
/** Summary statistics */
|
|
71
|
+
summary: LintSummary;
|
|
72
|
+
/** Parsing error message if JSON parsing failed */
|
|
73
|
+
parsingError?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extended result type for lint micro-task
|
|
78
|
+
*/
|
|
79
|
+
export interface LintResult extends ReviewTaskResult {
|
|
80
|
+
/** The command that was executed */
|
|
81
|
+
command: string;
|
|
82
|
+
/** Warning count (non-blocking) */
|
|
83
|
+
warningCount: number;
|
|
84
|
+
/** Number of issues that can be auto-fixed */
|
|
85
|
+
autoFixable?: number;
|
|
86
|
+
/** Number of parsing errors */
|
|
87
|
+
parsingErrors?: number;
|
|
88
|
+
/** Count of eslint-disable comments */
|
|
89
|
+
disabledRulesCount?: number;
|
|
90
|
+
/** Human-readable summary */
|
|
91
|
+
summary?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Options for running lint
|
|
96
|
+
*/
|
|
97
|
+
export interface LintOptions {
|
|
98
|
+
/** Working directory for the command */
|
|
99
|
+
cwd?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* ESLint JSON message format
|
|
104
|
+
*/
|
|
105
|
+
interface ESLintMessage {
|
|
106
|
+
ruleId: string | null;
|
|
107
|
+
severity: 1 | 2;
|
|
108
|
+
message: string;
|
|
109
|
+
line: number;
|
|
110
|
+
column: number;
|
|
111
|
+
fatal?: boolean;
|
|
112
|
+
fix?: { range: [number, number]; text: string };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* ESLint JSON file result format
|
|
117
|
+
*/
|
|
118
|
+
interface ESLintFileResult {
|
|
119
|
+
filePath: string;
|
|
120
|
+
messages: ESLintMessage[];
|
|
121
|
+
errorCount: number;
|
|
122
|
+
warningCount: number;
|
|
123
|
+
fixableErrorCount?: number;
|
|
124
|
+
fixableWarningCount?: number;
|
|
125
|
+
usedDeprecatedRules?: unknown[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse ESLint JSON output into structured issues
|
|
130
|
+
*
|
|
131
|
+
* @param output - Raw ESLint JSON output
|
|
132
|
+
* @returns Parsed issues and summary
|
|
133
|
+
*/
|
|
134
|
+
export function parseLintOutput(output: string): LintParseResult {
|
|
135
|
+
const summary: LintSummary = {
|
|
136
|
+
totalFiles: 0,
|
|
137
|
+
errorCount: 0,
|
|
138
|
+
warningCount: 0,
|
|
139
|
+
autoFixable: 0,
|
|
140
|
+
parsingErrors: 0,
|
|
141
|
+
disabledRulesCount: 0,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const issues: LintIssue[] = [];
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const results: ESLintFileResult[] = JSON.parse(output);
|
|
148
|
+
|
|
149
|
+
summary.totalFiles = results.length;
|
|
150
|
+
|
|
151
|
+
for (const fileResult of results) {
|
|
152
|
+
summary.errorCount += fileResult.errorCount;
|
|
153
|
+
summary.warningCount += fileResult.warningCount;
|
|
154
|
+
summary.autoFixable +=
|
|
155
|
+
(fileResult.fixableErrorCount || 0) +
|
|
156
|
+
(fileResult.fixableWarningCount || 0);
|
|
157
|
+
|
|
158
|
+
for (const message of fileResult.messages) {
|
|
159
|
+
// Track parsing errors
|
|
160
|
+
if (message.fatal || message.ruleId === null) {
|
|
161
|
+
summary.parsingErrors++;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const severity: LintSeverity = message.severity === 2 ? "error" : "warning";
|
|
165
|
+
const rule = message.ruleId || (message.fatal ? "parsing-error" : "unknown");
|
|
166
|
+
|
|
167
|
+
issues.push({
|
|
168
|
+
file: fileResult.filePath,
|
|
169
|
+
line: message.line,
|
|
170
|
+
column: message.column,
|
|
171
|
+
severity,
|
|
172
|
+
rule,
|
|
173
|
+
message: message.message,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { issues, summary };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// JSON parsing failed - return empty with error
|
|
181
|
+
return {
|
|
182
|
+
issues: [],
|
|
183
|
+
summary,
|
|
184
|
+
parsingError:
|
|
185
|
+
error instanceof Error ? error.message : "Failed to parse JSON output",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parse standard ESLint text output (fallback when JSON unavailable)
|
|
192
|
+
*
|
|
193
|
+
* @param output - Raw ESLint text output
|
|
194
|
+
* @returns Parsed issues and summary
|
|
195
|
+
*/
|
|
196
|
+
export function parseFallbackLintOutput(output: string): LintParseResult {
|
|
197
|
+
const summary: LintSummary = {
|
|
198
|
+
totalFiles: 0,
|
|
199
|
+
errorCount: 0,
|
|
200
|
+
warningCount: 0,
|
|
201
|
+
autoFixable: 0,
|
|
202
|
+
parsingErrors: 0,
|
|
203
|
+
disabledRulesCount: 0,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const issues: LintIssue[] = [];
|
|
207
|
+
const lines = output.split("\n");
|
|
208
|
+
let currentFile = "";
|
|
209
|
+
const filesSet = new Set<string>();
|
|
210
|
+
|
|
211
|
+
for (const line of lines) {
|
|
212
|
+
// Check for file path (starts with /)
|
|
213
|
+
if (line.startsWith("/") && !line.includes(":")) {
|
|
214
|
+
currentFile = line.trim();
|
|
215
|
+
filesSet.add(currentFile);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Parse issue line: " 10:5 error message rule-id"
|
|
220
|
+
const match = line.match(/^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}(\S+)\s*$/);
|
|
221
|
+
if (match && currentFile) {
|
|
222
|
+
const severity = match[3] as LintSeverity;
|
|
223
|
+
issues.push({
|
|
224
|
+
file: currentFile,
|
|
225
|
+
line: parseInt(match[1], 10),
|
|
226
|
+
column: parseInt(match[2], 10),
|
|
227
|
+
severity,
|
|
228
|
+
rule: match[5],
|
|
229
|
+
message: match[4].trim(),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (severity === "error") {
|
|
233
|
+
summary.errorCount++;
|
|
234
|
+
} else {
|
|
235
|
+
summary.warningCount++;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
summary.totalFiles = filesSet.size;
|
|
241
|
+
|
|
242
|
+
return { issues, summary };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Group lint issues by file path
|
|
247
|
+
*
|
|
248
|
+
* @param issues - Array of parsed lint issues
|
|
249
|
+
* @returns Record mapping file paths to their issues
|
|
250
|
+
*/
|
|
251
|
+
export function groupIssuesByFile(
|
|
252
|
+
issues: LintIssue[]
|
|
253
|
+
): Record<string, LintIssue[]> {
|
|
254
|
+
const grouped: Record<string, LintIssue[]> = {};
|
|
255
|
+
|
|
256
|
+
for (const issue of issues) {
|
|
257
|
+
if (!grouped[issue.file]) {
|
|
258
|
+
grouped[issue.file] = [];
|
|
259
|
+
}
|
|
260
|
+
grouped[issue.file].push(issue);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return grouped;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Create a fix task from a lint issue
|
|
268
|
+
*
|
|
269
|
+
* @param issue - The parsed lint issue
|
|
270
|
+
* @returns A fix task for the review system
|
|
271
|
+
*/
|
|
272
|
+
function createFixTask(issue: LintIssue): FixTask {
|
|
273
|
+
return {
|
|
274
|
+
type: "lint_fix",
|
|
275
|
+
file: issue.file,
|
|
276
|
+
line: issue.line,
|
|
277
|
+
column: issue.column,
|
|
278
|
+
description: `Fix lint error ${issue.rule} at line ${issue.line}, column ${issue.column}: ${issue.message}`,
|
|
279
|
+
priority: "high",
|
|
280
|
+
rule: issue.rule,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Generate human-readable summary
|
|
286
|
+
*
|
|
287
|
+
* @param summary - Lint summary statistics
|
|
288
|
+
* @returns Human-readable summary string
|
|
289
|
+
*/
|
|
290
|
+
function generateSummary(summary: LintSummary): string {
|
|
291
|
+
const parts: string[] = [];
|
|
292
|
+
|
|
293
|
+
parts.push(`${summary.totalFiles} file${summary.totalFiles !== 1 ? "s" : ""} scanned`);
|
|
294
|
+
|
|
295
|
+
if (summary.errorCount > 0 || summary.warningCount > 0) {
|
|
296
|
+
const issueParts: string[] = [];
|
|
297
|
+
if (summary.errorCount > 0) {
|
|
298
|
+
issueParts.push(`${summary.errorCount} error${summary.errorCount !== 1 ? "s" : ""}`);
|
|
299
|
+
}
|
|
300
|
+
if (summary.warningCount > 0) {
|
|
301
|
+
issueParts.push(`${summary.warningCount} warning${summary.warningCount !== 1 ? "s" : ""}`);
|
|
302
|
+
}
|
|
303
|
+
parts.push(issueParts.join(", "));
|
|
304
|
+
} else {
|
|
305
|
+
parts.push("no issues found");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (summary.autoFixable > 0) {
|
|
309
|
+
parts.push(`${summary.autoFixable} auto-fixable`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (summary.parsingErrors > 0) {
|
|
313
|
+
parts.push(`${summary.parsingErrors} parsing error${summary.parsingErrors !== 1 ? "s" : ""}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return parts.join(", ");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Run the lint micro-task
|
|
321
|
+
*
|
|
322
|
+
* Executes `bun run lint --format json` in the specified working directory,
|
|
323
|
+
* parses the output, and generates fix tasks for any errors found.
|
|
324
|
+
*
|
|
325
|
+
* @param options - Options including working directory
|
|
326
|
+
* @returns LintResult with success status, issues, and fix tasks
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* ```typescript
|
|
330
|
+
* const result = await runLint({ cwd: "/path/to/project" });
|
|
331
|
+
* if (!result.success) {
|
|
332
|
+
* console.log(`${result.errorCount} errors found`);
|
|
333
|
+
* result.fixTasks.forEach(task => console.log(task.description));
|
|
334
|
+
* }
|
|
335
|
+
* ```
|
|
336
|
+
*/
|
|
337
|
+
export async function runLint(options: LintOptions = {}): Promise<LintResult> {
|
|
338
|
+
const cwd = options.cwd || process.cwd();
|
|
339
|
+
const command = "bun run lint --format json";
|
|
340
|
+
const startTime = Date.now();
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
// Spawn the lint process with JSON format
|
|
344
|
+
const proc = Bun.spawn(["bun", "run", "lint", "--format", "json"], {
|
|
345
|
+
cwd,
|
|
346
|
+
stdout: "pipe",
|
|
347
|
+
stderr: "pipe",
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Wait for completion
|
|
351
|
+
const exitCode = await proc.exited;
|
|
352
|
+
const stdout = await proc.stdout.text();
|
|
353
|
+
const stderr = await proc.stderr.text();
|
|
354
|
+
const duration = Date.now() - startTime;
|
|
355
|
+
|
|
356
|
+
// Check for ESLint configuration error
|
|
357
|
+
if (
|
|
358
|
+
stderr.includes("configuration") ||
|
|
359
|
+
stderr.includes("ESLint") ||
|
|
360
|
+
stderr.includes("Config")
|
|
361
|
+
) {
|
|
362
|
+
return {
|
|
363
|
+
taskType: "lint",
|
|
364
|
+
success: false,
|
|
365
|
+
errorCount: 1,
|
|
366
|
+
warningCount: 0,
|
|
367
|
+
fixTasks: [],
|
|
368
|
+
duration,
|
|
369
|
+
command,
|
|
370
|
+
error: stderr.includes("configuration")
|
|
371
|
+
? `ESLint configuration error: ${stderr}`
|
|
372
|
+
: stderr,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Try parsing as JSON first
|
|
377
|
+
let parseResult = parseLintOutput(stdout);
|
|
378
|
+
|
|
379
|
+
// If JSON parsing failed, try fallback format
|
|
380
|
+
if (parseResult.parsingError) {
|
|
381
|
+
parseResult = parseFallbackLintOutput(stdout);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const { issues, summary } = parseResult;
|
|
385
|
+
|
|
386
|
+
// Generate fix tasks only for errors (not warnings)
|
|
387
|
+
const errorIssues = issues.filter((issue) => issue.severity === "error");
|
|
388
|
+
const fixTasks = errorIssues.map(createFixTask);
|
|
389
|
+
|
|
390
|
+
// Check for general command failure (stderr has content but no parsed issues)
|
|
391
|
+
if (exitCode !== 0 && issues.length === 0 && stderr.trim()) {
|
|
392
|
+
return {
|
|
393
|
+
taskType: "lint",
|
|
394
|
+
success: false,
|
|
395
|
+
errorCount: 1,
|
|
396
|
+
warningCount: 0,
|
|
397
|
+
fixTasks: [],
|
|
398
|
+
duration,
|
|
399
|
+
command,
|
|
400
|
+
error: stderr.trim(),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Success if no errors (warnings are OK)
|
|
405
|
+
const success = summary.errorCount === 0 && exitCode === 0;
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
taskType: "lint",
|
|
409
|
+
success,
|
|
410
|
+
errorCount: summary.errorCount,
|
|
411
|
+
warningCount: summary.warningCount,
|
|
412
|
+
fixTasks,
|
|
413
|
+
duration,
|
|
414
|
+
command,
|
|
415
|
+
autoFixable: summary.autoFixable,
|
|
416
|
+
parsingErrors: summary.parsingErrors,
|
|
417
|
+
disabledRulesCount: summary.disabledRulesCount,
|
|
418
|
+
summary: generateSummary(summary),
|
|
419
|
+
};
|
|
420
|
+
} catch (error) {
|
|
421
|
+
const duration = Date.now() - startTime;
|
|
422
|
+
const errorMessage =
|
|
423
|
+
error instanceof Error ? error.message : String(error);
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
taskType: "lint",
|
|
427
|
+
success: false,
|
|
428
|
+
errorCount: 1,
|
|
429
|
+
warningCount: 0,
|
|
430
|
+
fixTasks: [],
|
|
431
|
+
duration,
|
|
432
|
+
command,
|
|
433
|
+
error: `Command execution failed: ${errorMessage}`,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|