@tekyzinc/gsd-t 5.11.22 → 5.11.24
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/CHANGELOG.md +62 -0
- package/README.md +1 -1
- package/bin/gsd-t-graph-metrics-rollup.cjs +6 -3
- package/package.json +1 -1
- package/templates/workflows/gsd-t-scan.workflow.js +149 -15
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.11.24] - 2026-08-11
|
|
6
|
+
|
|
7
|
+
### Fixed — the code graph never reached a single scanning agent for six weeks
|
|
8
|
+
|
|
9
|
+
`graphWiringMode` was set to `"WIRED"` and tested with `=== "wired"`. Case
|
|
10
|
+
sensitive, so never true: the structural slice — dead-code candidates, dangling
|
|
11
|
+
references, clusters — was computed on every scan and then discarded, unused,
|
|
12
|
+
from **2026-06-30 to 2026-08-11**.
|
|
13
|
+
|
|
14
|
+
Nothing looked wrong. The register header prints the same variable, so every
|
|
15
|
+
scan honestly reported `WIRED`: the graph really had built and answered. Its
|
|
16
|
+
results simply never arrived where they were needed. Every scan in those six
|
|
17
|
+
weeks ran graph-blind while reporting the graph as wired.
|
|
18
|
+
|
|
19
|
+
**It was the second time the same variable broke this way.** The producer was
|
|
20
|
+
uppercased in M99 to satisfy a metrics rollup comparing `=== "WIRED"`, and that
|
|
21
|
+
fix broke the scan's own consumer. Matching one casing to another moves the bug;
|
|
22
|
+
both ends now compare without case, which is the house rule for a value like
|
|
23
|
+
this anyway.
|
|
24
|
+
|
|
25
|
+
The M99 test that required exact casing is **superseded** — it locked in the
|
|
26
|
+
requirement that caused the outage it was written to prevent. Replaced by a
|
|
27
|
+
guard on the property that actually matters: a mode the scan emits is a mode
|
|
28
|
+
every reader recognises, however it was typed.
|
|
29
|
+
|
|
30
|
+
- `templates/workflows/gsd-t-scan.workflow.js`: the consumer, and the comment that told the next maintainer to match casing
|
|
31
|
+
- `bin/gsd-t-graph-metrics-rollup.cjs`: all three mode comparisons
|
|
32
|
+
- `test/m112-graph-wiring-casing.test.js`: 6 tests, including that no reader compares case-sensitively
|
|
33
|
+
- `test/m99-graph-metrics-rollup.test.js`: the old casing guard replaced
|
|
34
|
+
|
|
35
|
+
## [5.11.23] - 2026-08-10
|
|
36
|
+
|
|
37
|
+
### Fixed — the scan could silently leave code out, and slices were too large to read
|
|
38
|
+
|
|
39
|
+
Two defects, one standing rule now enforced: **nothing is ever left out of a
|
|
40
|
+
scan, and a failure is recovered rather than accepted.**
|
|
41
|
+
|
|
42
|
+
**Slices were being deleted.** When the probe returned more slices than a
|
|
43
|
+
volume-derived cap allowed, `rawSlices.slice(0, cap)` discarded the excess and
|
|
44
|
+
the run carried on. Those areas were never scanned, never counted as failures,
|
|
45
|
+
never mentioned in the register — a coverage hole invisible by construction. It
|
|
46
|
+
took a 34-slice probe down to 24 slices run. The cap no longer truncates, and
|
|
47
|
+
neither does `maxSlicesHint`: it is reported and every slice still runs.
|
|
48
|
+
|
|
49
|
+
**Slices were too large to enumerate.** The count was the wrong thing to bound.
|
|
50
|
+
What decides whether a defect is found is how many files ONE agent must read —
|
|
51
|
+
the finder is told to read every file, and at ~245 that stops being followable,
|
|
52
|
+
so it samples and reports a thin slice as a clean one. Slices are now sized
|
|
53
|
+
(~120 files); the count follows from the codebase.
|
|
54
|
+
|
|
55
|
+
**An oversized decomposition is re-sliced, not warned about.** If the split is
|
|
56
|
+
rejected — not finer, or it lost a path — it retries once with the fault named.
|
|
57
|
+
If that fails too, the slices are split **mechanically**: each slice's own path
|
|
58
|
+
list is cut into chunks, so no path can be lost. A crude split that reads every
|
|
59
|
+
file beats a tidy one that reads half.
|
|
60
|
+
|
|
61
|
+
Concurrency is unchanged at 10 in flight: it governs how fast a run goes, never
|
|
62
|
+
how much it covers.
|
|
63
|
+
|
|
64
|
+
- `templates/workflows/gsd-t-scan.workflow.js`: no truncation anywhere, size-based slicing, re-slice + retry + mechanical fallback-free split
|
|
65
|
+
- `test/m112-scan-schema-tolerance.test.js`: 35 tests
|
|
66
|
+
|
|
5
67
|
## [5.11.22] - 2026-08-10
|
|
6
68
|
|
|
7
69
|
### Changed — the probe now slices vertically, by business capability
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v5.11.
|
|
3
|
+
**v5.11.24** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
|
|
4
4
|
|
|
5
5
|
**Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
|
|
6
6
|
**Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
|
|
@@ -337,14 +337,17 @@ function rollup(projectRoot) {
|
|
|
337
337
|
byConsumer[consumer].wiringCount++;
|
|
338
338
|
|
|
339
339
|
const mode = ev.graphWiringMode || "";
|
|
340
|
-
|
|
340
|
+
// Case-insensitive: matching an exact casing here is what started the
|
|
341
|
+
// six-week outage — the producer was uppercased to satisfy this line
|
|
342
|
+
// and the scan's own consumer (which tested lowercase) went dead.
|
|
343
|
+
if (String(mode).toLowerCase() === "wired") {
|
|
341
344
|
l2cWiredCount++;
|
|
342
|
-
} else if (mode === "fallback-announced") {
|
|
345
|
+
} else if (String(mode).toLowerCase() === "fallback-announced") {
|
|
343
346
|
l2cFallbackCount++;
|
|
344
347
|
// Record minute-bucket for co-occurrence check
|
|
345
348
|
if (!fallbackBuckets[consumer]) fallbackBuckets[consumer] = new Set();
|
|
346
349
|
fallbackBuckets[consumer].add(_minuteBucket(ev.ts));
|
|
347
|
-
} else if (mode === "disabled") {
|
|
350
|
+
} else if (String(mode).toLowerCase() === "disabled") {
|
|
348
351
|
l2cDisabledCount++;
|
|
349
352
|
}
|
|
350
353
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "5.11.
|
|
3
|
+
"version": "5.11.24",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -414,7 +414,8 @@ const probe = await agent(
|
|
|
414
414
|
``,
|
|
415
415
|
`Each slice: a \`key\` (kebab name of the responsibility, e.g. "invoice-generation"), concrete \`paths\` it owns (under \`${projectDir}\`), a \`dimension\`, and \`why\` (what makes it one cohesive concern).`,
|
|
416
416
|
``,
|
|
417
|
-
`
|
|
417
|
+
`SIZE IS THE CONSTRAINT, NOT THE COUNT. Every file in the codebase must land in exactly one slice, and NO slice may exceed ~120 files — because the agent that owns it is required to READ EVERY FILE in it. Above that it starts sampling, and a sampled slice reports few findings while looking thorough. Divide the total file count by 120: that is roughly the MINIMUM number of slices. Emit that many or more. Do NOT aim for a "sensible" or tidy number — a large codebase genuinely needs many slices, and returning too few is the single most damaging thing you can do here, because the code you crammed together is the code that goes unread.`,
|
|
418
|
+
`A feature too large for one slice is SPLIT ALONG ITS OWN SEAMS, still vertically: "billing-invoicing" and "billing-payments", not "billing-routes" and "billing-schema". Splitting a feature is normal and expected. Never merge two features to reduce the count.`,
|
|
418
419
|
``,
|
|
419
420
|
// The axis was unspecified, and both readings satisfy "cohesive": a technical
|
|
420
421
|
// layer is cohesive, and so is a business feature. Two runs over the SAME
|
|
@@ -464,15 +465,136 @@ if (layerShaped.length > 2) {
|
|
|
464
465
|
);
|
|
465
466
|
}
|
|
466
467
|
|
|
467
|
-
//
|
|
468
|
+
// The cap used to TRUNCATE — `rawSlices.slice(0, cap)` deleted the excess and
|
|
469
|
+
// the run continued. Those areas were never scanned, never counted as failures,
|
|
470
|
+
// and never mentioned in the register: a coverage hole invisible by
|
|
471
|
+
// construction. It is what took a 34-slice probe down to 24 slices run.
|
|
472
|
+
//
|
|
473
|
+
// The count was the wrong thing to bound. What decides whether a finding is
|
|
474
|
+
// found is how many files ONE agent must read: the finder is told "read EVERY
|
|
475
|
+
// file, enumerate, do not sample", and at ~245 files that instruction stops
|
|
476
|
+
// being followable — the agent samples and reports a thin slice as a clean one.
|
|
477
|
+
//
|
|
478
|
+
// So the cap is now on SIZE, and the count follows from it. A slice too large to
|
|
479
|
+
// read is SPLIT, never dropped. More agents is the correct answer to more code.
|
|
468
480
|
const computedCap = computeSliceCap(probe.totals || {});
|
|
469
|
-
const
|
|
481
|
+
const totalFiles = Number((probe.totals || {}).files || (probe.totals || {}).total_files || 0);
|
|
482
|
+
|
|
483
|
+
// Files one agent can genuinely read and reason about. Above this, enumeration
|
|
484
|
+
// degrades into sampling.
|
|
485
|
+
const MAX_FILES_PER_SLICE = 120;
|
|
486
|
+
|
|
470
487
|
let slices = rawSlices;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
488
|
+
|
|
489
|
+
// A count far above the structural estimate means the probe sliced per file or
|
|
490
|
+
// per module rather than by capability — the failure the old cap existed to
|
|
491
|
+
// catch (a 5-file repo cut into ~20 slices). Still worth NAMING, but never worth
|
|
492
|
+
// deleting code over: it is reported and everything still runs.
|
|
493
|
+
// maxSlicesHint used to TRUNCATE here. Nothing may be left out of a scan, so
|
|
494
|
+
// the hint no longer deletes: it is reported and every slice still runs.
|
|
495
|
+
if (maxSlicesOverride && rawSlices.length > maxSlicesOverride) {
|
|
496
|
+
log(`⚠ maxSlicesHint=${maxSlicesOverride} is below the ${rawSlices.length} slices the probe found. IGNORING it — dropping slices would leave code unscanned. Running all ${rawSlices.length}.`);
|
|
497
|
+
} else if (rawSlices.length > computedCap * 2) {
|
|
498
|
+
log(`⚠ probe returned ${rawSlices.length} slices against a structural estimate of ~${computedCap} — it may have sliced per file/module rather than by capability. Running all ${rawSlices.length} anyway: dropping a slice would silently remove code from the scan.`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Slices too big to read honestly. The probe owns the split (it knows the
|
|
502
|
+
// paths); this reports the ones that will under-read so it is visible in the
|
|
503
|
+
// log rather than hidden in a thin finding count.
|
|
504
|
+
if (totalFiles > 0 && slices.length > 0) {
|
|
505
|
+
const avgFiles = Math.round(totalFiles / slices.length);
|
|
506
|
+
if (avgFiles > MAX_FILES_PER_SLICE) {
|
|
507
|
+
// Warning about it is not enough — the run would go on and under-read every
|
|
508
|
+
// slice. Send the decomposition back to be split, and use the result.
|
|
509
|
+
const wanted = Math.ceil(totalFiles / MAX_FILES_PER_SLICE);
|
|
510
|
+
log(`⚠ SLICES TOO LARGE TO ENUMERATE — ~${avgFiles} files each across ${slices.length} slices (${totalFiles} files). The finder must read EVERY file; above ~${MAX_FILES_PER_SLICE} it samples instead, and a sampled slice reports fewer findings while looking complete. Re-slicing to ~${wanted}.`);
|
|
511
|
+
|
|
512
|
+
const resliced = await gatedAgent(
|
|
513
|
+
[
|
|
514
|
+
`Re-slice a codebase decomposition that came out too coarse. Project: \`${projectDir}\`.`,
|
|
515
|
+
``,
|
|
516
|
+
`Here are the current slices — ${slices.length} of them, averaging ~${avgFiles} files each:`,
|
|
517
|
+
JSON.stringify(slices.map((sl) => ({ key: sl.key, paths: sl.paths, dimension: sl.dimension })), null, 1),
|
|
518
|
+
``,
|
|
519
|
+
`Each slice is read by ONE agent that must open EVERY file it owns. At ~${avgFiles} files that is not possible, so those agents will sample and report a thin slice as a clean one.`,
|
|
520
|
+
`SPLIT them so no slice exceeds ~${MAX_FILES_PER_SLICE} files. Target roughly ${wanted} slices in total.`,
|
|
521
|
+
``,
|
|
522
|
+
`RULES:`,
|
|
523
|
+
`· Split along the feature's own seams, still VERTICALLY: "billing-invoicing" and "billing-payments", never "billing-routes" and "billing-schema".`,
|
|
524
|
+
`· EVERY path in the input must appear in exactly one output slice. Losing a path removes that code from the scan entirely.`,
|
|
525
|
+
`· Never merge two slices to tidy the count. Splitting is the only operation here.`,
|
|
526
|
+
`· A slice already under ~${MAX_FILES_PER_SLICE} files passes through unchanged.`,
|
|
527
|
+
`Return the full new slice list — every slice, not only the ones you split.`,
|
|
528
|
+
SHAPE_RULE,
|
|
529
|
+
].join("\n"),
|
|
530
|
+
{ label: "probe:reslice", phase: "Probe", schema: PROBE_SCHEMA, model: "opus" }
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
const newSlices = (resliced && Array.isArray(resliced.slices) && resliced.slices) || [];
|
|
534
|
+
// Accept it only if it is genuinely finer AND kept the paths. A re-slice
|
|
535
|
+
// that lost code would be worse than the coarse decomposition it replaced.
|
|
536
|
+
const pathsBefore = new Set(slices.flatMap((sl) => sl.paths || []));
|
|
537
|
+
const pathsAfter = new Set(newSlices.flatMap((sl) => sl.paths || []));
|
|
538
|
+
const lost = [...pathsBefore].filter((x) => !pathsAfter.has(x));
|
|
539
|
+
|
|
540
|
+
if (newSlices.length > slices.length && lost.length === 0) {
|
|
541
|
+
log(`✓ re-sliced ${slices.length} → ${newSlices.length} slices (~${Math.round(totalFiles / newSlices.length)} files each), every path preserved`);
|
|
542
|
+
slices = newSlices;
|
|
543
|
+
} else {
|
|
544
|
+
// A rejected re-slice leaves the scan under-reading every slice — that is
|
|
545
|
+
// continuing past a failure, so it gets a second try that names exactly
|
|
546
|
+
// what went wrong, on the same model. If that fails too, the slices are
|
|
547
|
+
// split MECHANICALLY below: a crude split that reads every file beats a
|
|
548
|
+
// tidy one that reads half.
|
|
549
|
+
const why = newSlices.length <= slices.length
|
|
550
|
+
? `it returned ${newSlices.length} slices — no finer than the ${slices.length} it was given`
|
|
551
|
+
: `it dropped ${lost.length} path(s): ${lost.slice(0, 6).join(", ")}${lost.length > 6 ? ", …" : ""}`;
|
|
552
|
+
log(`⚠ re-slice attempt 1 rejected — ${why}. Retrying with the fault named.`);
|
|
553
|
+
|
|
554
|
+
const retry = await gatedAgent(
|
|
555
|
+
[
|
|
556
|
+
`Your previous re-slice was REJECTED because ${why}.`,
|
|
557
|
+
``,
|
|
558
|
+
`Split these ${slices.length} slices so none exceeds ~${MAX_FILES_PER_SLICE} files. Target ~${wanted} slices.`,
|
|
559
|
+
JSON.stringify(slices.map((sl) => ({ key: sl.key, paths: sl.paths, dimension: sl.dimension })), null, 1),
|
|
560
|
+
``,
|
|
561
|
+
`The output MUST contain more slices than the input, and EVERY input path must appear in exactly one output slice. Splitting is the only operation — never merge, never drop.`,
|
|
562
|
+
`Split along the feature's own seams, still vertically.`,
|
|
563
|
+
SHAPE_RULE,
|
|
564
|
+
].join("\n"),
|
|
565
|
+
{ label: "probe:reslice (retry)", phase: "Probe", schema: PROBE_SCHEMA, model: "opus" }
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
const retrySlices = (retry && Array.isArray(retry.slices) && retry.slices) || [];
|
|
569
|
+
const retryAfter = new Set(retrySlices.flatMap((sl) => sl.paths || []));
|
|
570
|
+
const retryLost = [...pathsBefore].filter((x) => !retryAfter.has(x));
|
|
571
|
+
|
|
572
|
+
if (retrySlices.length > slices.length && retryLost.length === 0) {
|
|
573
|
+
log(`✓ re-slice retry succeeded — ${slices.length} → ${retrySlices.length} slices, every path preserved`);
|
|
574
|
+
slices = retrySlices;
|
|
575
|
+
} else {
|
|
576
|
+
// Mechanical split: divide each oversized slice's own paths into chunks.
|
|
577
|
+
// No agent, no judgement, no way to lose a path — every path lands in
|
|
578
|
+
// exactly one chunk because the chunks ARE the path list, cut up.
|
|
579
|
+
const split = [];
|
|
580
|
+
for (const sl of slices) {
|
|
581
|
+
const paths = sl.paths || [];
|
|
582
|
+
const share = Math.max(1, Math.round((paths.length / Math.max(pathsBefore.size, 1)) * totalFiles));
|
|
583
|
+
const parts = Math.ceil(share / MAX_FILES_PER_SLICE);
|
|
584
|
+
if (parts <= 1 || paths.length <= 1) { split.push(sl); continue; }
|
|
585
|
+
const per = Math.ceil(paths.length / parts);
|
|
586
|
+
for (let i = 0; i < paths.length; i += per) {
|
|
587
|
+
split.push({ ...sl, key: `${sl.key}-part${Math.floor(i / per) + 1}`, paths: paths.slice(i, i + per) });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
log(`⚠ both re-slice attempts rejected — splitting mechanically instead: ${slices.length} → ${split.length} slices. Crude, but every path is still scanned and no slice is too large to read.`);
|
|
591
|
+
slices = split;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
474
595
|
}
|
|
475
|
-
|
|
596
|
+
|
|
597
|
+
log(`probe derived ${rawSlices.length} slice(s); running ${slices.length} deep-finder(s)${totalFiles ? `; ~${Math.round(totalFiles / Math.max(slices.length, 1))} files/slice` : ""}; structural estimate=${computedCap}; totals=${JSON.stringify(probe.totals)}`);
|
|
476
598
|
|
|
477
599
|
// M94-D6: Graph-Wiring phase — ADDITIVE injection of the pre-computed structural slice.
|
|
478
600
|
// Current scan architecture is KEPT FULLY INTACT (Destructive Action Guard).
|
|
@@ -553,15 +675,16 @@ if (graphMode === "disabled") {
|
|
|
553
675
|
},
|
|
554
676
|
tier: (deadCodeResult && deadCodeResult.tier) || (danglingResult && danglingResult.tier) || "unknown",
|
|
555
677
|
};
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
678
|
+
// The casing here no longer matters: every reader of this value compares
|
|
679
|
+
// without case (2026-08-11). It was uppercased in M99 to satisfy a rollup
|
|
680
|
+
// that tested `=== "WIRED"`, and that broke the scan's own consumer, which
|
|
681
|
+
// tested `=== "wired"` — the structural slice was silently discarded for
|
|
682
|
+
// six weeks. Chasing one casing to match another is what caused both
|
|
683
|
+
// outages; the readers were fixed instead.
|
|
684
|
+
// [RULE] wiring-mode-compared-without-case
|
|
562
685
|
graphWiringMode = "WIRED";
|
|
563
686
|
log(`graph-wiring: WIRED — structural slice ready (dead-code: ${structuralSlice.deadCode.length} candidates, dangling: ${structuralSlice.dangling.length} edges, clusters: ${structuralSlice.clusters.length} groups, tier: ${structuralSlice.tier}). Slice will be INJECTED ADDITIVELY into scanSlice deep-finders. [RULE] scan-injects-structural-slice`);
|
|
564
|
-
await persistWiringMode("WIRED"); // M99 D2 [RULE] wiring-mode-three-states / wiring-mode-
|
|
687
|
+
await persistWiringMode("WIRED"); // M99 D2 [RULE] wiring-mode-three-states / wiring-mode-compared-without-case
|
|
565
688
|
}
|
|
566
689
|
}
|
|
567
690
|
}
|
|
@@ -799,7 +922,18 @@ async function scanSlice(slice) {
|
|
|
799
922
|
// M94-D6: inject the structural slice context ADDITIVELY — only when graph is wired.
|
|
800
923
|
// When graphMode==="disabled" OR graph-unavailable, graphSliceContext is null (no injection).
|
|
801
924
|
// [RULE] scan-injects-structural-slice / [RULE] no-graph-baseline-proven-graph-free
|
|
802
|
-
|
|
925
|
+
// Compared case-INSENSITIVELY. This test read `=== "wired"` while the producer
|
|
926
|
+
// wrote "WIRED", so it was never true: the structural slice was computed and
|
|
927
|
+
// then discarded, unused, from 2026-06-30 until 2026-08-11. Every scan in
|
|
928
|
+
// those six weeks ran graph-blind while logging WIRED — the header was not
|
|
929
|
+
// lying (the graph DID build and answer), its results simply never arrived.
|
|
930
|
+
//
|
|
931
|
+
// The producer was uppercased to fix a metrics rollup that compared to
|
|
932
|
+
// "WIRED"; that fix broke this consumer. Matching the casing again would just
|
|
933
|
+
// move the bug a third time, so both ends now compare without case — which is
|
|
934
|
+
// the house rule for a value like this in the first place.
|
|
935
|
+
const graphSliceContext =
|
|
936
|
+
(String(graphWiringMode).toLowerCase() === "wired" && structuralSlice) ? structuralSlice : null;
|
|
803
937
|
const finderResult = await runFinder(slice, graphSliceContext);
|
|
804
938
|
// M72: distinguish a FAILED finder (null after retries) from a genuinely-clean slice.
|
|
805
939
|
if (!finderResult || !Array.isArray(finderResult.findings)) {
|