@tea-agent/loop-agent 0.25.4 → 0.25.5
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/AGENTS.md +6 -0
- package/CHANGELOG.md +40 -0
- package/dist/commands/client-recovery.js +209 -62
- package/dist/executors/dag-pi-executor.js +80 -15
- package/dist/executors/model-routing.js +1 -1
- package/dist/executors/shell-executor.js +127 -0
- package/dist/executors/shell-write-guard.js +21 -7
- package/dist/worker/console/repo-fingerprint.js +7 -1
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +964 -0
- package/dist/workflows/dag/backend-test-case-manifest.js +39 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +219 -16
- package/dist/workflows/dag/convergence/controller.js +134 -9
- package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
- package/dist/workflows/dag/init-hybrid.js +262 -75
- package/dist/workflows/dag/node-execution.js +64 -11
- package/dist/workflows/dag/prompt.js +118 -4
- package/dist/workflows/dag/retry-policy.js +5 -4
- package/dist/workflows/dag/scheduler.js +32 -5
- package/dist/workflows/dag/types.js +7 -4
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/architecture/dag-execution.md +7 -4
- package/docs/architecture/runtime-boundaries.md +1 -1
- package/docs/templates/agent-dag.base.json +1 -1
- package/docs/templates/agent-dag.final-verification.json +1 -1
- package/docs/templates/agent-dag.supervised-implementation.json +1 -1
- package/docs/templates/backend-test-dag.json +40 -13
- package/docs/templates/frontend-test-dag.json +32 -2
- package/docs/templates/hybrid-dag.json +1 -1
- package/examples/decision-gate-agent-dag.json +1 -1
- package/examples/example-dag.json +1 -1
- package/examples/hybrid-loop-agent-dag.json +1 -1
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- package/skills/loop-agent/references/model-routing.md +1 -1
|
@@ -10,6 +10,7 @@ import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend
|
|
|
10
10
|
import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
|
|
11
11
|
import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
|
|
12
12
|
import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
|
|
13
|
+
import { renderFrontendTestL5Report } from "../workflows/dag/frontend-test-l5-report.js";
|
|
13
14
|
import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
|
|
14
15
|
import { renderFrontendTestHtmlReport } from "../workflows/dag/frontend-test-html-report.js";
|
|
15
16
|
import { formatFrontendVerificationTraceStdout, runFrontendVerificationTraceGate, } from "../workflows/dag/frontend-verification-trace.js";
|
|
@@ -20,6 +21,7 @@ import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from
|
|
|
20
21
|
import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } from "../workflows/dag/frontend-lint-baseline.js";
|
|
21
22
|
import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
|
|
22
23
|
import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
|
|
24
|
+
import { analyzeBackendTestCaseCoverage, analyzeBackendTestMarkdownPytestCorrespondence, materializeBackendTestCaseManifestFromFacts, } from "../workflows/dag/backend-test-case-coverage-analysis.js";
|
|
23
25
|
import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
|
|
24
26
|
import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, collectJacocoCoverage, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, renderBackendTestL5Dashboard, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
25
27
|
import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
|
|
@@ -372,6 +374,14 @@ async function readRequiredRunReport(reportsDir, filename) {
|
|
|
372
374
|
throw new Error(`missing required upstream report: reports/${filename}`);
|
|
373
375
|
}
|
|
374
376
|
}
|
|
377
|
+
async function readAdvisoryRunReport(reportsDir, filename, title) {
|
|
378
|
+
try {
|
|
379
|
+
return await readFile(path.join(reportsDir, filename), "utf8");
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return `# ${title}\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Missing advisory upstream report: reports/${filename}\n`;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
375
385
|
async function executeBackendTestPipeline(input, meta) {
|
|
376
386
|
const pipeline = input.task.shell?.backendTestPipeline;
|
|
377
387
|
const started = Date.now();
|
|
@@ -423,6 +433,48 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
423
433
|
durationMs: Date.now() - started,
|
|
424
434
|
};
|
|
425
435
|
}
|
|
436
|
+
const sourceBinding = meta.spec.sourceBinding;
|
|
437
|
+
if (!sourceBinding) {
|
|
438
|
+
const coverage = "# Backend Test Case Coverage Analysis\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Coverage analysis requires spec.sourceBinding; node 4 and node 7 fail closed on the same task/source binding contract.\n";
|
|
439
|
+
const coveragePath = await writeRunReport(meta.runDir, "backend-test-case-coverage-analysis.md", coverage);
|
|
440
|
+
outputs.push(`caseCoverage=${coveragePath}`, coverage);
|
|
441
|
+
return {
|
|
442
|
+
ok: false,
|
|
443
|
+
stdout: outputs.join("\n\n"),
|
|
444
|
+
stderr: "backend-test markdown-cases requires spec.sourceBinding",
|
|
445
|
+
failureCategory: "invalid-output",
|
|
446
|
+
durationMs: Date.now() - started,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
const normalizedBinding = {
|
|
450
|
+
taskId: sourceBinding.taskId,
|
|
451
|
+
requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ?? "source/requirement.md",
|
|
452
|
+
requirementSha256: sourceBinding.sources.find((s) => s.kind === "requirement")?.sha256 ?? "0".repeat(64),
|
|
453
|
+
referencePaths: sourceBinding.sources.filter((s) => s.kind === "reference").map((s) => s.path),
|
|
454
|
+
requirementIds: sourceBinding.requirementIds,
|
|
455
|
+
};
|
|
456
|
+
try {
|
|
457
|
+
const coverage = await analyzeBackendTestCaseCoverage({ workspaceRoot: input.cwd, sourceBinding: normalizedBinding });
|
|
458
|
+
const coveragePath = await writeRunReport(meta.runDir, "backend-test-case-coverage-analysis.md", coverage.markdown);
|
|
459
|
+
const contractsDir = path.join(meta.runDir, "contracts");
|
|
460
|
+
await mkdir(contractsDir, { recursive: true });
|
|
461
|
+
const factsPath = path.join(contractsDir, "backend-test-case-coverage-facts.json");
|
|
462
|
+
await writeFile(factsPath, JSON.stringify(coverage.facts, null, 2), "utf8");
|
|
463
|
+
outputs.push(`caseCoverage=${coveragePath}`, `caseCoverageFacts=${factsPath}`, coverage.markdown);
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
467
|
+
const coverage = `# Backend Test Case Coverage Analysis\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Coverage analysis crashed and is treated as an infrastructure failure: ${message}\n`;
|
|
468
|
+
const coveragePath = await writeRunReport(meta.runDir, "backend-test-case-coverage-analysis.md", coverage);
|
|
469
|
+
outputs.push(`caseCoverage=${coveragePath}`, coverage);
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
stdout: outputs.join("\n\n"),
|
|
473
|
+
stderr: `backend-test coverage analysis crashed: ${message}`,
|
|
474
|
+
failureCategory: "invalid-output",
|
|
475
|
+
durationMs: Date.now() - started,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
426
478
|
}
|
|
427
479
|
else if (pipeline === "markdown-traceability") {
|
|
428
480
|
let report;
|
|
@@ -434,6 +486,60 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
434
486
|
}
|
|
435
487
|
const reportPath = await writeRunReport(meta.runDir, "backend-test-traceability.md", report);
|
|
436
488
|
outputs.push(`traceability=${reportPath}`, report);
|
|
489
|
+
const sourceBinding = meta.spec.sourceBinding;
|
|
490
|
+
if (!sourceBinding) {
|
|
491
|
+
const correspondence = "# Backend Test Markdown → pytest Correspondence\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Correspondence analysis requires spec.sourceBinding; nodes 4, 6 and 7 fail closed on the same task/source binding contract.\n";
|
|
492
|
+
const correspondencePath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence.md", correspondence);
|
|
493
|
+
outputs.push(`correspondence=${correspondencePath}`, correspondence);
|
|
494
|
+
return {
|
|
495
|
+
ok: false,
|
|
496
|
+
stdout: outputs.join("\n\n"),
|
|
497
|
+
stderr: "backend-test markdown-traceability requires spec.sourceBinding",
|
|
498
|
+
failureCategory: "invalid-output",
|
|
499
|
+
durationMs: Date.now() - started,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const correspondence = await analyzeBackendTestMarkdownPytestCorrespondence({ workspaceRoot: input.cwd, taskId: sourceBinding.taskId });
|
|
504
|
+
const correspondencePath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence.md", correspondence.markdown);
|
|
505
|
+
const contractsDir = path.join(meta.runDir, "contracts");
|
|
506
|
+
await mkdir(contractsDir, { recursive: true });
|
|
507
|
+
const factsPath = path.join(contractsDir, "backend-test-markdown-pytest-correspondence-facts.json");
|
|
508
|
+
await writeFile(factsPath, JSON.stringify(correspondence.facts, null, 2), "utf8");
|
|
509
|
+
outputs.push(`correspondence=${correspondencePath}`, `correspondenceFacts=${factsPath}`, correspondence.markdown);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
513
|
+
const correspondence = `# Backend Test Markdown → pytest Correspondence\n\n## Status\n\nUNAVAILABLE\n\n## Findings\n\n- Correspondence analysis crashed and is treated as an infrastructure failure: ${message}\n`;
|
|
514
|
+
const correspondencePath = await writeRunReport(meta.runDir, "backend-test-markdown-pytest-correspondence.md", correspondence);
|
|
515
|
+
outputs.push(`correspondence=${correspondencePath}`, correspondence);
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
stdout: outputs.join("\n\n"),
|
|
519
|
+
stderr: `backend-test correspondence analysis crashed: ${message}`,
|
|
520
|
+
failureCategory: "invalid-output",
|
|
521
|
+
durationMs: Date.now() - started,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
else if (pipeline === "markdown-manifest") {
|
|
526
|
+
const sourceBinding = meta.spec.sourceBinding;
|
|
527
|
+
if (!sourceBinding)
|
|
528
|
+
throw new Error("backend-test markdown-manifest requires spec.sourceBinding");
|
|
529
|
+
const manifest = await materializeBackendTestCaseManifestFromFacts({
|
|
530
|
+
runDir: meta.runDir,
|
|
531
|
+
workspaceRoot: input.cwd,
|
|
532
|
+
sourceBinding: {
|
|
533
|
+
taskId: sourceBinding.taskId,
|
|
534
|
+
requirementPath: sourceBinding.sources.find((s) => s.kind === "requirement")?.path ?? "source/requirement.md",
|
|
535
|
+
requirementSha256: sourceBinding.sources.find((s) => s.kind === "requirement")?.sha256 ?? "0".repeat(64),
|
|
536
|
+
referencePaths: sourceBinding.sources.filter((s) => s.kind === "reference").map((s) => s.path),
|
|
537
|
+
requirementIds: sourceBinding.requirementIds,
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
const manifestPath = path.join(meta.runDir, "contracts", "backend-test-case-manifest.json");
|
|
541
|
+
const summary = manifest.coverageSummary;
|
|
542
|
+
outputs.push(`manifest=${manifestPath}`, `materializationStatus=${manifest.materializationStatus ?? "available"}`, `coverageSummary.explicitAcCount=${summary?.explicitAcCount ?? "unavailable"}`, `coverageSummary.coveredAcCount=${summary?.coveredAcCount ?? "unavailable"}`, `coverageSummary.caseCount=${summary?.caseCount ?? "unavailable"}`, `coverageSummary.generatedCount=${summary?.generatedCount ?? "unavailable"}`, `ruleCoverageSummary.ruleCount=${manifest.ruleCoverageSummary?.ruleCount ?? "unavailable"}`, `correspondenceSummary.exactCorrespondenceCount=${manifest.correspondenceSummary?.exactCorrespondenceCount ?? "unavailable"}`);
|
|
437
543
|
}
|
|
438
544
|
else if (pipeline === "markdown-execute-html") {
|
|
439
545
|
const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
|
|
@@ -601,14 +707,18 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
601
707
|
});
|
|
602
708
|
const cases = await collectBackendTestHumanCaseCatalog(input.cwd);
|
|
603
709
|
const caseValidationSummary = await readRequiredRunReport(reportsDir, "backend-md-case-validation.md");
|
|
710
|
+
const caseCoverageSummary = await readAdvisoryRunReport(reportsDir, "backend-test-case-coverage-analysis.md", "Backend Test Case Coverage Analysis");
|
|
604
711
|
const traceabilitySummary = await readRequiredRunReport(reportsDir, "backend-test-traceability.md");
|
|
712
|
+
const correspondenceSummary = await readAdvisoryRunReport(reportsDir, "backend-test-markdown-pytest-correspondence.md", "Backend Test Markdown → pytest Correspondence");
|
|
605
713
|
const htmlContent = renderBackendTestHtml({
|
|
606
714
|
title: meta.spec.title,
|
|
607
715
|
parsed,
|
|
608
716
|
cases,
|
|
609
717
|
environmentSummary: await readFile(path.join(reportsDir, "backend-test-environment.md"), "utf8"),
|
|
610
718
|
caseValidationSummary,
|
|
719
|
+
caseCoverageSummary,
|
|
611
720
|
traceabilitySummary,
|
|
721
|
+
correspondenceSummary,
|
|
612
722
|
});
|
|
613
723
|
const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
|
|
614
724
|
const facts = renderBackendTestFacts({
|
|
@@ -618,7 +728,9 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
618
728
|
htmlRelativePath: "reports/backend-test.html",
|
|
619
729
|
htmlContent,
|
|
620
730
|
caseValidationSummary,
|
|
731
|
+
caseCoverageSummary,
|
|
621
732
|
traceabilitySummary,
|
|
733
|
+
correspondenceSummary,
|
|
622
734
|
});
|
|
623
735
|
const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
|
|
624
736
|
const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
|
|
@@ -681,7 +793,9 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
681
793
|
parsed,
|
|
682
794
|
metrics: l5Metrics,
|
|
683
795
|
caseValidationSummary,
|
|
796
|
+
caseCoverageSummary,
|
|
684
797
|
traceabilitySummary,
|
|
798
|
+
correspondenceSummary,
|
|
685
799
|
failures: failureSummaries.length > 0 ? failureSummaries : undefined,
|
|
686
800
|
});
|
|
687
801
|
const l5Path = await writeRunReport(meta.runDir, "backend-test-l5-dashboard.html", l5Html);
|
|
@@ -1159,6 +1273,16 @@ async function executeFrontendTestCaseChecklist(input, meta) {
|
|
|
1159
1273
|
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
1160
1274
|
}
|
|
1161
1275
|
}
|
|
1276
|
+
async function executeFrontendTestL5Report(input, meta) {
|
|
1277
|
+
const started = Date.now();
|
|
1278
|
+
try {
|
|
1279
|
+
const output = await renderFrontendTestL5Report({ workspaceRoot: input.cwd, runDir: meta.runDir });
|
|
1280
|
+
return { ok: true, stdout: `Frontend L-5 report: ${output.htmlPath}\nMarkdown: ${output.markdownPath}\nStatus: ${output.metrics.status}`, stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
1281
|
+
}
|
|
1282
|
+
catch (error) {
|
|
1283
|
+
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1162
1286
|
async function executeFrontendTestHtmlReport(input, meta) {
|
|
1163
1287
|
const started = Date.now();
|
|
1164
1288
|
try {
|
|
@@ -1307,6 +1431,9 @@ export async function executeDagShellNode(input, meta) {
|
|
|
1307
1431
|
if (shell?.frontendTestEvidenceValidation) {
|
|
1308
1432
|
return executeFrontendTestEvidenceValidation(input);
|
|
1309
1433
|
}
|
|
1434
|
+
if (shell?.frontendTestL5Report) {
|
|
1435
|
+
return executeFrontendTestL5Report(input, meta);
|
|
1436
|
+
}
|
|
1310
1437
|
if (shell?.frontendTestHtmlReport) {
|
|
1311
1438
|
return executeFrontendTestHtmlReport(input, meta);
|
|
1312
1439
|
}
|
|
@@ -165,8 +165,8 @@ export function validateShellWriteGuard(input) {
|
|
|
165
165
|
return { ok: violations.length === 0, violations };
|
|
166
166
|
}
|
|
167
167
|
export async function readGitStatusPorcelain(cwd, options = {}) {
|
|
168
|
-
const attempts = Math.max(1, options.attempts ??
|
|
169
|
-
const retryDelayMs = Math.max(0, options.retryDelayMs ??
|
|
168
|
+
const attempts = Math.max(1, options.attempts ?? 5);
|
|
169
|
+
const retryDelayMs = Math.max(0, options.retryDelayMs ?? 150);
|
|
170
170
|
let lastError;
|
|
171
171
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
172
172
|
try {
|
|
@@ -179,14 +179,24 @@ export async function readGitStatusPorcelain(cwd, options = {}) {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
const detail = lastError instanceof Error ? lastError.message : String(lastError);
|
|
183
|
+
const exitCode = lastError instanceof Error &&
|
|
184
|
+
"exitCode" in lastError &&
|
|
185
|
+
typeof lastError.exitCode === "number"
|
|
186
|
+
? String(lastError.exitCode)
|
|
187
|
+
: "unavailable";
|
|
188
|
+
const signal = lastError instanceof Error &&
|
|
189
|
+
"signal" in lastError &&
|
|
190
|
+
typeof lastError.signal === "string"
|
|
191
|
+
? lastError.signal
|
|
192
|
+
: "unavailable";
|
|
193
|
+
throw new Error(`git status failed after ${attempts} attempts (exit code=${exitCode}, signal=${signal}, cwd=${path.resolve(cwd)}): ${detail}`, { cause: lastError });
|
|
185
194
|
}
|
|
186
195
|
function readGitStatusPorcelainOnce(cwd) {
|
|
187
196
|
return new Promise((resolve, reject) => {
|
|
188
197
|
const child = spawn("git", ["status", "--porcelain=v1", "--untracked-files=all"], {
|
|
189
198
|
cwd,
|
|
199
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
190
200
|
stdio: ["ignore", "pipe", "pipe"],
|
|
191
201
|
});
|
|
192
202
|
let stdout = "";
|
|
@@ -200,12 +210,16 @@ function readGitStatusPorcelainOnce(cwd) {
|
|
|
200
210
|
stderr += chunk;
|
|
201
211
|
});
|
|
202
212
|
child.on("error", reject);
|
|
203
|
-
child.on("close", (code) => {
|
|
213
|
+
child.on("close", (code, signal) => {
|
|
204
214
|
if (code === 0) {
|
|
205
215
|
resolve(stdout);
|
|
206
216
|
return;
|
|
207
217
|
}
|
|
208
|
-
|
|
218
|
+
const detail = stderr.trim() || stdout.trim() || "no stderr/stdout";
|
|
219
|
+
const error = new Error(`git status failed: ${detail}`);
|
|
220
|
+
error.exitCode = code;
|
|
221
|
+
error.signal = signal;
|
|
222
|
+
reject(error);
|
|
209
223
|
});
|
|
210
224
|
});
|
|
211
225
|
}
|
|
@@ -7,7 +7,13 @@ import path from "node:path";
|
|
|
7
7
|
* (except a bare root). No extra case-folding on macOS/Windows.
|
|
8
8
|
*/
|
|
9
9
|
export function normalizeWorktreeRealpath(repoRoot) {
|
|
10
|
-
|
|
10
|
+
// The legacy JS implementation preserves an input 8.3 path such as
|
|
11
|
+
// C:\\Users\\MICROS~1 on Windows. The native implementation expands the
|
|
12
|
+
// short and long aliases to the same physical path before hashing.
|
|
13
|
+
const absolute = path.resolve(repoRoot);
|
|
14
|
+
let resolved = process.platform === "win32"
|
|
15
|
+
? realpathSync.native(absolute)
|
|
16
|
+
: realpathSync(absolute);
|
|
11
17
|
if (/^[a-zA-Z]:/.test(resolved)) {
|
|
12
18
|
resolved = resolved[0].toUpperCase() + resolved.slice(1);
|
|
13
19
|
}
|