@ngockhoale/ukit 3.4.29 → 3.4.30
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 +16 -0
- package/manifests/documentation.yaml +88 -0
- package/package.json +1 -1
- package/src/core/permissionPolicy.js +1 -1
- package/src/index/buildIndex.js +244 -4
- package/src/index/impactContext.js +91 -11
- package/src/index/languageTools.js +126 -0
- package/src/index/paths.js +5 -1
- package/src/index/playbookRegistry.js +108 -9
- package/src/index/queryIndex.js +415 -1
- package/src/index/routeCatalog.js +1 -1
- package/src/index/taskRouting.js +25 -3
- package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
- package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
- package/template_project/.claude/hooks/record-execution.mjs +23 -0
- package/template_project/.claude/hooks/skill-router.sh +105 -7
- package/template_project/.claude/settings.json +21 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
- package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
- package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
- package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
- package/template_project/.claude/ukit/index/query-index.mjs +100 -7
- package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
- package/template_project/.claude/ukit/index/route-task.mjs +208 -31
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +453 -24
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
- package/template_project/.omp/hooks/pre/ukit-bridge.js +32 -4
- package/template_user/playbooks/feature-implementation.md +9 -0
- package/template_user/playbooks/migration.md +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,22 @@ All notable changes to UKit are documented here.
|
|
|
9
9
|
- New `/ukit:ci-check` command + `ci-check.mjs` helper (core pack): prechecks, fail-closed budget guard, local preflight, at most one dispatch per invocation, write-ahead run-id state, `status` (never dispatches), `--ref`, `--dry-run`, per-OS report. Never pushes, merges or publishes.
|
|
10
10
|
- Default-branch bootstrap stub documented in `docs/design/CI-CROSS-PLATFORM-MATRIX.md` (requires separate approval).
|
|
11
11
|
|
|
12
|
+
## 3.4.30 - 2026-10-10
|
|
13
|
+
|
|
14
|
+
**Orchestration: routing, goal/cycle continuation, task ownership, discovery, landing.** Verified with `yarn test:handoff` (184/184) and `yarn release:verify`; live Claude Code two-cycle run-all and the Claude create-only permission denial were exercised on disposable fixtures. Known limits: OMP permission denial is verified through the hook bridge only, and Codex has no pre-tool hook for ownership enforcement. No performance gain is claimed beyond removing the identified redundant Stop-denial / archive-repair round.
|
|
15
|
+
- **Stop-gate evidence capture (P0).** `isVerificationCommand`/`TEST_RUNNER_COMMAND` now accept subshell/group openers, so `(yarn test; echo "exit $?" >> log)` mints a receipt (it previously minted none → endless `missing verification-evidence`). `verdictHistory` retention never evicts the last satisfying PASS; a pipe-masked run is reported as "exit status unproven" instead of "fix the failure". Pipe-masked status is still rejected. Tests: `tests/hooks/stopGateReceiptCapture.test.js`.
|
|
16
|
+
- **F3 ↔ ExitPredicate.** `index:all-done` on a header-only INDEX now reads `archive/cycle-NN/TASK-*.md`; `run-all` additionally requires no pending queued blueprints. Finalizing a cycle never implies run-all completion. Test: `tests/handoff/f3FinalizationPredicate.test.mjs`.
|
|
17
|
+
- **Archive/Goal completion invariant.** F3 now runs `resumable-run.mjs archive-cycle <root> <NN>` before the INDEX reset: the verified INDEX rows are persisted to `archive/cycle-NN/INDEX.md` and terminal statuses stamped into archived task headers; `index:all-done` reads that snapshot (header only for legacy archives, missing row fails closed, rows immutable per cycle). Removes the stale-`ready` repair round. Receipt detection ignores quoted literals and sees `bash -c '<runner>'`. Tests: `archiveCompletionInvariant.test.mjs` (AR-01…15).
|
|
18
|
+
- **Main-agent task ownership.** During implement phases the main agent must hold a claim (`main:<session>` owner) via the existing registry; new CLI `resumable-run.mjs claim|release <root> <TASK-ID> --owner <owner>`.
|
|
19
|
+
- **Routing.** Deterministic greeting fast-path; Vietnamese edit verbs; REFERENCE / CLONE / SQL / REUSE / RUN-ALL signals stamped in the route summary; negated and plan-only prompts never imply implementation; "run all the tests" is a test run, "chạy hết pending tasks" is goal-level run-all; "use <symbol> from Core" stamps reuse-first. Explicit handoff commands keep authority over inferred intent.
|
|
20
|
+
- **Goal vs cycle.** `deriveGoalStatus`; default exit predicate `all(index:all-done)`; quota pause marks `Paused: yes` (structured error records only); a finalized cycle (done cursor + header-only INDEX) releases once.
|
|
21
|
+
- **Task ownership.** Per-task claims with task identity, lease renewal, owner fencing and takeover; wired through the Claude Code and OMP delegation hooks; per-session route-state sidecars fix a cross-session collision. The main agent takes part in the same registry (see above).
|
|
22
|
+
- **Discovery.** Index schema 9: fuzzy symbol candidates (never auto-substituted), global and SQL symbols, caller proof with `unverifiedImpact`, bounded fallback search that reports truncation instead of posing as exhaustive.
|
|
23
|
+
- **Reuse/reference/clone/SQL advisory.** `skill-router.sh` now emits one short source-resolution line for reuse-first, reference/clone and SQL prompts (including the previously silent no-route exit): resolve the named symbol with `query-index.mjs --symbol`, never invent a module, leave the reference untouched, alias vs real column. Found by a live OMP run that invented `import … from 'Core'`. `REFERENCE_TARGET_RE` also accepts "like the reviewed Customer dropdown".
|
|
24
|
+
- **`handoff-fullstack` F3.** Archive step now snapshots verified INDEX rows (`archive-cycle`) so a finished cycle never needs a post-`done` repair round; the final verification for `verify:pass` runs in-session (the receipt is per-session).
|
|
25
|
+
- **Playbook observability.** `ledger.playbookTrace`, `--playbook-status`.
|
|
26
|
+
- **Fixes.** `land-worktree` / `cleanup-task-worktree` accepted only `TASK-C97-NNN`, so every ordinary handoff task (`TASK-001`) was blocked at landing; they now accept `TASK-NNN` and `TASK-<cycle>-NNN` (still no separators or whitespace). `block-dangerous` no longer blocks a quoted-delimiter heredoc whose body merely documents a dangerous command; real commands, unquoted heredocs and heredocs piped to a shell stay denied. `handoff-fullstack` P3 no longer blocks the run when the lite commit agent has no shell. `handoff-create` headless rule.
|
|
27
|
+
|
|
12
28
|
## 3.4.29 - 2026-10-09
|
|
13
29
|
|
|
14
30
|
**Fixes (no new features).**
|
|
@@ -418,6 +418,94 @@ entries:
|
|
|
418
418
|
validation: [none]
|
|
419
419
|
archive_policy: immutable
|
|
420
420
|
notes: C99 measurement-only sub-agent cost audit snapshot; registered in 3.4.25 (was unclassified on 3.4.24)
|
|
421
|
+
- id: docs-analysis-handoff-root-cause-audit
|
|
422
|
+
path: docs/analysis/HANDOFF-ROOT-CAUSE-AUDIT.md
|
|
423
|
+
class: archive
|
|
424
|
+
audience: [maintainer]
|
|
425
|
+
owner: product
|
|
426
|
+
source_of_truth: docs/analysis/HANDOFF-ROOT-CAUSE-AUDIT.md
|
|
427
|
+
merge_strategy: none
|
|
428
|
+
load_policy: never
|
|
429
|
+
validation: [none]
|
|
430
|
+
archive_policy: immutable
|
|
431
|
+
notes: 2026-10-09 handoff root-cause audit (create-scope authority + Stop verification semantics)
|
|
432
|
+
- id: docs-analysis-handoff-remediation-plan
|
|
433
|
+
path: docs/analysis/HANDOFF-REMEDIATION-PLAN.md
|
|
434
|
+
class: archive
|
|
435
|
+
audience: [maintainer]
|
|
436
|
+
owner: product
|
|
437
|
+
source_of_truth: docs/analysis/HANDOFF-REMEDIATION-PLAN.md
|
|
438
|
+
merge_strategy: none
|
|
439
|
+
load_policy: never
|
|
440
|
+
validation: [none]
|
|
441
|
+
archive_policy: immutable
|
|
442
|
+
notes: 2026-10-09 handoff remediation plan (create-scope authority + Stop verification semantics)
|
|
443
|
+
- id: docs-analysis-handoff-regression-test-plan
|
|
444
|
+
path: docs/analysis/HANDOFF-REGRESSION-TEST-PLAN.md
|
|
445
|
+
class: archive
|
|
446
|
+
audience: [maintainer]
|
|
447
|
+
owner: product
|
|
448
|
+
source_of_truth: docs/analysis/HANDOFF-REGRESSION-TEST-PLAN.md
|
|
449
|
+
merge_strategy: none
|
|
450
|
+
load_policy: never
|
|
451
|
+
validation: [none]
|
|
452
|
+
archive_policy: immutable
|
|
453
|
+
notes: 2026-10-09 handoff regression test plan (create-scope authority + Stop verification semantics)
|
|
454
|
+
- id: docs-analysis-orchestration-root-cause-audit
|
|
455
|
+
path: docs/analysis/ORCHESTRATION-ROOT-CAUSE-AUDIT.md
|
|
456
|
+
class: archive
|
|
457
|
+
audience: [maintainer]
|
|
458
|
+
owner: product
|
|
459
|
+
source_of_truth: docs/analysis/ORCHESTRATION-ROOT-CAUSE-AUDIT.md
|
|
460
|
+
merge_strategy: none
|
|
461
|
+
load_policy: never
|
|
462
|
+
validation: [none]
|
|
463
|
+
archive_policy: immutable
|
|
464
|
+
notes: 2026-10-10 orchestration root-cause audit (routing, playbooks, discovery, goal cycles)
|
|
465
|
+
- id: docs-analysis-orchestration-spec
|
|
466
|
+
path: docs/analysis/ORCHESTRATION-SPEC.md
|
|
467
|
+
class: archive
|
|
468
|
+
audience: [maintainer]
|
|
469
|
+
owner: product
|
|
470
|
+
source_of_truth: docs/analysis/ORCHESTRATION-SPEC.md
|
|
471
|
+
merge_strategy: none
|
|
472
|
+
load_policy: never
|
|
473
|
+
validation: [none]
|
|
474
|
+
archive_policy: immutable
|
|
475
|
+
notes: 2026-10-10 orchestration behavior spec
|
|
476
|
+
- id: docs-analysis-orchestration-implementation-plan
|
|
477
|
+
path: docs/analysis/ORCHESTRATION-IMPLEMENTATION-PLAN.md
|
|
478
|
+
class: archive
|
|
479
|
+
audience: [maintainer]
|
|
480
|
+
owner: product
|
|
481
|
+
source_of_truth: docs/analysis/ORCHESTRATION-IMPLEMENTATION-PLAN.md
|
|
482
|
+
merge_strategy: none
|
|
483
|
+
load_policy: never
|
|
484
|
+
validation: [none]
|
|
485
|
+
archive_policy: immutable
|
|
486
|
+
notes: 2026-10-10 orchestration implementation plan
|
|
487
|
+
- id: docs-analysis-orchestration-regression-tests
|
|
488
|
+
path: docs/analysis/ORCHESTRATION-REGRESSION-TESTS.md
|
|
489
|
+
class: archive
|
|
490
|
+
audience: [maintainer]
|
|
491
|
+
owner: product
|
|
492
|
+
source_of_truth: docs/analysis/ORCHESTRATION-REGRESSION-TESTS.md
|
|
493
|
+
merge_strategy: none
|
|
494
|
+
load_policy: never
|
|
495
|
+
validation: [none]
|
|
496
|
+
archive_policy: immutable
|
|
497
|
+
notes: 2026-10-10 orchestration regression test inventory
|
|
498
|
+
- id: docs-analysis-orchestration-final-verification
|
|
499
|
+
path: docs/analysis/ORCHESTRATION-FINAL-VERIFICATION.md
|
|
500
|
+
class: archive
|
|
501
|
+
audience: [maintainer]
|
|
502
|
+
owner: product
|
|
503
|
+
source_of_truth: docs/analysis/ORCHESTRATION-FINAL-VERIFICATION.md
|
|
504
|
+
merge_strategy: none
|
|
505
|
+
load_policy: never
|
|
506
|
+
validation: [none]
|
|
507
|
+
archive_policy: immutable
|
|
508
|
+
notes: 2026-10-10 orchestration final verification and release readiness
|
|
421
509
|
- id: docs-analysis-c100-frag-a-d
|
|
422
510
|
path: docs/analysis/c100/frag-A-D.md
|
|
423
511
|
class: archive
|
package/package.json
CHANGED
|
@@ -36,7 +36,7 @@ export const CANONICAL_DENY = Object.freeze([
|
|
|
36
36
|
{ id: 'rm-rf-parent', claude: 'Bash(rm -rf ..:*)', omp: 'rm -rf ..', codex: 'rm -rf ..', hardDeny: true },
|
|
37
37
|
{ id: 'rm-rf-dot', claude: 'Bash(rm -rf .:*)', omp: 'rm -rf .', codex: 'rm -rf .', hardDeny: true },
|
|
38
38
|
{ id: 'dd-raw-device', claude: 'Bash(dd if=/dev/:*)', omp: 'dd if=/dev/*', codex: 'dd if=/dev/', hardDeny: true },
|
|
39
|
-
{ id: 'write-raw-device', claude: 'Bash(*> /dev/sda
|
|
39
|
+
{ id: 'write-raw-device', claude: 'Bash(*> /dev/sda*)', omp: '*> /dev/sda*', codex: '> /dev/sda', hardDeny: true },
|
|
40
40
|
{ id: 'mkfs', claude: 'Bash(mkfs:*)', omp: 'mkfs.*', codex: 'mkfs', hardDeny: true },
|
|
41
41
|
{ id: 'fork-bomb', claude: 'Bash(:(){ :|:& };::*)', omp: ':(){ :|:& };:*', codex: ':(){ :|:& };:', hardDeny: true },
|
|
42
42
|
]);
|
package/src/index/buildIndex.js
CHANGED
|
@@ -1681,7 +1681,7 @@ function computeSignature(content, lineStartIndex) {
|
|
|
1681
1681
|
export function extractSymbols(filePath, content) {
|
|
1682
1682
|
const symbols = [];
|
|
1683
1683
|
const lineStarts = computeLineStarts(content);
|
|
1684
|
-
const addSymbol = (name, type, matchIndex = null) => {
|
|
1684
|
+
const addSymbol = (name, type, matchIndex = null, extra = null) => {
|
|
1685
1685
|
if (!name) {
|
|
1686
1686
|
return;
|
|
1687
1687
|
}
|
|
@@ -1691,7 +1691,7 @@ export function extractSymbols(filePath, content) {
|
|
|
1691
1691
|
}
|
|
1692
1692
|
const line = lineNumberForIndex(lineStarts, matchIndex);
|
|
1693
1693
|
const signature = computeSignature(content, lineStarts[line - 1]);
|
|
1694
|
-
symbols.push({ name, type, filePath, line, signature });
|
|
1694
|
+
symbols.push(extra ? { name, type, filePath, line, signature, ...extra } : { name, type, filePath, line, signature });
|
|
1695
1695
|
};
|
|
1696
1696
|
const addMatches = (regex, type, groupIndex = 1) => {
|
|
1697
1697
|
for (const match of content.matchAll(regex)) {
|
|
@@ -1713,7 +1713,26 @@ export function extractSymbols(filePath, content) {
|
|
|
1713
1713
|
addMatches(new RegExp(`CREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'index');
|
|
1714
1714
|
addMatches(new RegExp(`CREATE\\s+(?:TYPE|DOMAIN|SEQUENCE|MATERIALIZED\\s+VIEW)\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'type');
|
|
1715
1715
|
addMatches(new RegExp(`ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'table');
|
|
1716
|
-
|
|
1716
|
+
|
|
1717
|
+
// Column-level symbols: CREATE TABLE bodies (with parent + schema kept so
|
|
1718
|
+
// same-named columns in different schemas/tables stay distinct) and view
|
|
1719
|
+
// output aliases (`expr AS alias`).
|
|
1720
|
+
const sqlObjects = findSqlObjects(content);
|
|
1721
|
+
for (const [objectIndex, sqlObject] of sqlObjects.entries()) {
|
|
1722
|
+
if (sqlObject.type === 'table') {
|
|
1723
|
+
for (const column of extractSqlTableColumns(content, sqlObject)) {
|
|
1724
|
+
addSymbol(column.name, 'column', column.matchIndex, { parent: sqlObject.name, schema: sqlObject.schema });
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
if (sqlObject.type === 'view') {
|
|
1728
|
+
const bodyEnd = objectIndex + 1 < sqlObjects.length ? sqlObjects[objectIndex + 1].matchIndex : content.length;
|
|
1729
|
+
for (const alias of extractSqlViewAliases(content.slice(sqlObject.matchIndex, Math.min(bodyEnd, sqlObject.matchIndex + 20000)))) {
|
|
1730
|
+
addSymbol(alias.name, 'column', sqlObject.matchIndex + alias.matchIndex, { parent: sqlObject.name, schema: sqlObject.schema });
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
return dedupeBy(symbols, (item) => `${item.filePath}:${item.type}:${item.name}:${item.parent ?? ''}:${item.schema ?? ''}`);
|
|
1717
1736
|
}
|
|
1718
1737
|
|
|
1719
1738
|
addMatches(/export\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'function');
|
|
@@ -1727,6 +1746,32 @@ export function extractSymbols(filePath, content) {
|
|
|
1727
1746
|
addMatches(/defineComponent\(\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
|
|
1728
1747
|
addMatches(/export\s+default\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
|
|
1729
1748
|
|
|
1749
|
+
// Global-registered frontend functions: reachable from anywhere without an
|
|
1750
|
+
// import edge, so impact analysis must treat them as verified dependencies.
|
|
1751
|
+
addMatches(/\b(?:window|globalThis|global)\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*=/g, 'global');
|
|
1752
|
+
addMatches(/\b(?:window|globalThis|global)\s*\[\s*['"]([^'"]+)['"]\s*\]\s*=/g, 'global');
|
|
1753
|
+
addMatches(/\b(?:app|vueApp)\s*\.\s*config\s*\.\s*globalProperties\s*\.\s*(\$?[A-Za-z_$][A-Za-z0-9_$]*)\s*=/g, 'global');
|
|
1754
|
+
addMatches(/\bVue\s*\.\s*prototype\s*\.\s*(\$?[A-Za-z_$][A-Za-z0-9_$]*)\s*=/g, 'global');
|
|
1755
|
+
addMatches(/\b(?:app|vueApp)\s*\.\s*provide\s*\(\s*['"]([^'"]+)['"]/g, 'global');
|
|
1756
|
+
addMatches(/\b(?:Vue|app|vueApp)\s*\.\s*component\s*\(\s*['"]([^'"]+)['"]/g, 'global');
|
|
1757
|
+
|
|
1758
|
+
// Vue options-API methods: `methods: { submit() {...}, validate: () => ... }`.
|
|
1759
|
+
// Names inside the balanced `methods` block only — body-level matches keep
|
|
1760
|
+
// their real file line through the absolute-index offset.
|
|
1761
|
+
for (const methodsMatch of content.matchAll(/\bmethods\s*:\s*\{/g)) {
|
|
1762
|
+
const methodsBody = sliceBalancedBlock(content, methodsMatch.index + methodsMatch[0].length);
|
|
1763
|
+
if (!methodsBody) {
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
const methodsStart = methodsMatch.index + methodsMatch[0].length;
|
|
1767
|
+
for (const methodMatch of methodsBody.matchAll(/(?:^|[,{\n]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*\((?:[^()]|\([^)]*\))*\)\s*\{/g)) {
|
|
1768
|
+
addSymbol(methodMatch[1], 'method', methodsStart + methodMatch.index + methodMatch[0].indexOf(methodMatch[1]));
|
|
1769
|
+
}
|
|
1770
|
+
for (const methodMatch of methodsBody.matchAll(/(?:^|[,{]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][A-Za-z0-9_$]*\s*=>)/g)) {
|
|
1771
|
+
addSymbol(methodMatch[1], 'method', methodsStart + methodMatch.index + methodMatch[0].indexOf(methodMatch[1]));
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1730
1775
|
if (/\bexport\s+default\b/.test(content)) {
|
|
1731
1776
|
addSymbol('default', 'default-export');
|
|
1732
1777
|
}
|
|
@@ -1743,7 +1788,120 @@ export function extractSymbols(filePath, content) {
|
|
|
1743
1788
|
}
|
|
1744
1789
|
}
|
|
1745
1790
|
|
|
1746
|
-
return dedupeBy(symbols, (item) => `${item.filePath}:${item.type}:${item.name}`);
|
|
1791
|
+
return dedupeBy(symbols, (item) => `${item.filePath}:${item.type}:${item.name}:${item.parent ?? ''}:${item.schema ?? ''}`);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// Locates every CREATE <object> statement with its schema qualifier (or null)
|
|
1795
|
+
// so same-named objects in different schemas stay distinct records. Used for
|
|
1796
|
+
// column extraction and SQL reference segmentation.
|
|
1797
|
+
function findSqlObjects(content) {
|
|
1798
|
+
const sqlQualifiedIdent = '(?:(["`]?[A-Za-z_][A-Za-z0-9_$]*["`]?)\\s*\\.\\s*)?["`]?([A-Za-z_][A-Za-z0-9_$]*)["`]?';
|
|
1799
|
+
const patterns = [
|
|
1800
|
+
['table', `CREATE\\s+(?:OR\\s+REPLACE\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlQualifiedIdent}`],
|
|
1801
|
+
['view', `CREATE\\s+(?:OR\\s+REPLACE\\s+)?VIEW\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlQualifiedIdent}`],
|
|
1802
|
+
['function', `CREATE\\s+(?:OR\\s+REPLACE\\s+)?FUNCTION\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlQualifiedIdent}`],
|
|
1803
|
+
['procedure', `CREATE\\s+(?:OR\\s+REPLACE\\s+)?PROCEDURE\\s+${sqlQualifiedIdent}`],
|
|
1804
|
+
['type', `CREATE\\s+(?:TYPE|DOMAIN|SEQUENCE)\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlQualifiedIdent}`],
|
|
1805
|
+
];
|
|
1806
|
+
const objects = [];
|
|
1807
|
+
for (const [type, pattern] of patterns) {
|
|
1808
|
+
for (const match of content.matchAll(new RegExp(pattern, 'gi'))) {
|
|
1809
|
+
if (!match[2]) {
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
objects.push({
|
|
1813
|
+
type,
|
|
1814
|
+
name: match[2],
|
|
1815
|
+
schema: match[1] ? match[1].replace(/["`]/g, '') : null,
|
|
1816
|
+
matchIndex: match.index,
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
return objects.sort((a, b) => a.matchIndex - b.matchIndex);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
const SQL_COLUMN_CONSTRAINT_KEYWORDS = new Set([
|
|
1824
|
+
'primary', 'foreign', 'unique', 'check', 'constraint', 'key', 'index', 'exclude', 'like', 'period',
|
|
1825
|
+
]);
|
|
1826
|
+
|
|
1827
|
+
// Slices a CREATE TABLE body's balanced paren list and emits one row per
|
|
1828
|
+
// top-level column definition (constraint rows are skipped).
|
|
1829
|
+
function extractSqlTableColumns(content, sqlObject) {
|
|
1830
|
+
const headerPattern = new RegExp(
|
|
1831
|
+
`CREATE\\s+(?:OR\\s+REPLACE\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?(?:(?:["\`]?[A-Za-z_][A-Za-z0-9_$]*["\`]?)\\s*\\.\\s*)?["\`]?${escapeRegExp(sqlObject.name)}["\`]?`,
|
|
1832
|
+
'gi',
|
|
1833
|
+
);
|
|
1834
|
+
headerPattern.lastIndex = sqlObject.matchIndex;
|
|
1835
|
+
const headerMatch = headerPattern.exec(content);
|
|
1836
|
+
if (!headerMatch) {
|
|
1837
|
+
return [];
|
|
1838
|
+
}
|
|
1839
|
+
const openParenSearchEnd = Math.min(content.length, headerMatch.index + headerMatch[0].length + 500);
|
|
1840
|
+
const openParen = content.slice(headerMatch.index + headerMatch[0].length - 1, openParenSearchEnd).indexOf('(');
|
|
1841
|
+
if (openParen === -1) {
|
|
1842
|
+
return [];
|
|
1843
|
+
}
|
|
1844
|
+
const openParenAbsolute = headerMatch.index + headerMatch[0].length - 1 + openParen;
|
|
1845
|
+
|
|
1846
|
+
let depth = 0;
|
|
1847
|
+
let closeParen = -1;
|
|
1848
|
+
for (let index = openParenAbsolute; index < content.length; index += 1) {
|
|
1849
|
+
if (content[index] === '(') {
|
|
1850
|
+
depth += 1;
|
|
1851
|
+
} else if (content[index] === ')') {
|
|
1852
|
+
depth -= 1;
|
|
1853
|
+
if (depth === 0) {
|
|
1854
|
+
closeParen = index;
|
|
1855
|
+
break;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
if (closeParen === -1) {
|
|
1860
|
+
return [];
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
const body = content.slice(openParenAbsolute + 1, closeParen);
|
|
1864
|
+
const columns = [];
|
|
1865
|
+
let topLevelDepth = 0;
|
|
1866
|
+
let segmentStart = 0;
|
|
1867
|
+
const segments = [];
|
|
1868
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
1869
|
+
const char = body[index];
|
|
1870
|
+
if (char === '(') {
|
|
1871
|
+
topLevelDepth += 1;
|
|
1872
|
+
} else if (char === ')') {
|
|
1873
|
+
topLevelDepth -= 1;
|
|
1874
|
+
} else if (char === ',' && topLevelDepth === 0) {
|
|
1875
|
+
segments.push(body.slice(segmentStart, index));
|
|
1876
|
+
segmentStart = index + 1;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
segments.push(body.slice(segmentStart));
|
|
1880
|
+
|
|
1881
|
+
let offset = openParenAbsolute + 1;
|
|
1882
|
+
for (const segment of segments) {
|
|
1883
|
+
const columnMatch = segment.match(/^\s*["`]?([A-Za-z_][A-Za-z0-9_$]*)["`]?/);
|
|
1884
|
+
const columnName = columnMatch?.[1];
|
|
1885
|
+
if (columnName && !SQL_COLUMN_CONSTRAINT_KEYWORDS.has(columnName.toLowerCase())) {
|
|
1886
|
+
columns.push({ name: columnName, matchIndex: offset + columnMatch[0].indexOf(columnMatch[1]) });
|
|
1887
|
+
}
|
|
1888
|
+
offset += segment.length + 1;
|
|
1889
|
+
}
|
|
1890
|
+
return columns;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// Output aliases inside a CREATE VIEW body segment: `expr AS alias` where the
|
|
1894
|
+
// alias is followed by a column-list boundary.
|
|
1895
|
+
function extractSqlViewAliases(viewSegment) {
|
|
1896
|
+
const aliases = [];
|
|
1897
|
+
for (const match of viewSegment.matchAll(/\bAS\s+["`]?([A-Za-z_][A-Za-z0-9_$]*)["`]?\s*(?=\s*(?:,|FROM|WHERE|;|\n|$))/gi)) {
|
|
1898
|
+
aliases.push({ name: match[1], matchIndex: match.index + match[0].indexOf(match[1]) });
|
|
1899
|
+
}
|
|
1900
|
+
return aliases;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
function escapeRegExp(value) {
|
|
1904
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1747
1905
|
}
|
|
1748
1906
|
|
|
1749
1907
|
function extractImports(filePath, content) {
|
|
@@ -1912,7 +2070,89 @@ function sliceBalancedBlock(content, startIndex) {
|
|
|
1912
2070
|
return '';
|
|
1913
2071
|
}
|
|
1914
2072
|
|
|
2073
|
+
// SQL keywords that appear before `(` in procedural/plain SQL but are not
|
|
2074
|
+
// object references (SELECT count(, IF(, CAST(...) — plus common column type
|
|
2075
|
+
// names, since `VARCHAR(20)` in a table body is a type, not a call.
|
|
2076
|
+
const SQL_CALL_KEYWORDS = new Set([
|
|
2077
|
+
'select', 'from', 'where', 'and', 'or', 'not', 'case', 'when', 'then', 'else',
|
|
2078
|
+
'end', 'join', 'inner', 'left', 'right', 'outer', 'full', 'cross', 'on',
|
|
2079
|
+
'group', 'order', 'by', 'having', 'limit', 'offset', 'union', 'all',
|
|
2080
|
+
'distinct', 'as', 'insert', 'into', 'values', 'update', 'set', 'delete',
|
|
2081
|
+
'create', 'table', 'view', 'function', 'procedure', 'index', 'alter', 'drop',
|
|
2082
|
+
'if', 'exists', 'begin', 'commit', 'rollback', 'return', 'declare', 'exec',
|
|
2083
|
+
'execute', 'call', 'with', 'cast', 'coalesce', 'nullif', 'extract', 'primary',
|
|
2084
|
+
'foreign', 'references', 'key', 'constraint', 'default', 'unique', 'check',
|
|
2085
|
+
'asc', 'desc', 'is', 'in', 'like', 'between', 'null', 'true', 'false',
|
|
2086
|
+
'interval', 'date', 'using', 'natural', 'returns', 'language', 'temporary',
|
|
2087
|
+
'varchar', 'char', 'nvarchar', 'nchar', 'int', 'integer', 'bigint', 'smallint',
|
|
2088
|
+
'tinyint', 'text', 'boolean', 'bool', 'datetime', 'timestamp', 'timestamptz',
|
|
2089
|
+
'decimal', 'numeric', 'float', 'double', 'real', 'json', 'jsonb', 'uuid',
|
|
2090
|
+
'serial', 'bigserial', 'blob', 'bytea', 'bit', 'money', 'xml', 'enum',
|
|
2091
|
+
'count', 'sum', 'avg', 'min', 'max', 'round', 'upper', 'lower', 'trim',
|
|
2092
|
+
'length', 'concat', 'substring', 'replace', 'abs', 'ceil', 'floor', 'now',
|
|
2093
|
+
'current_timestamp', 'current_date', 'year', 'month', 'day', 'hour', 'minute',
|
|
2094
|
+
'second', 'convert', 'try_cast', 'iif', 'stuff', 'format', 'getdate',
|
|
2095
|
+
]);
|
|
2096
|
+
|
|
2097
|
+
// SQL reference extraction: one calls-record per created object whose segment
|
|
2098
|
+
// (its statement through the next CREATE) lists the SQL objects it touches —
|
|
2099
|
+
// FROM/JOIN targets, EXEC targets, and `name(` function calls. This is the
|
|
2100
|
+
// dependency edge impact analysis uses to surface dependent views/functions.
|
|
2101
|
+
function extractSqlFunctionCalls(filePath, content) {
|
|
2102
|
+
const objects = findSqlObjects(content);
|
|
2103
|
+
if (objects.length === 0) {
|
|
2104
|
+
return [];
|
|
2105
|
+
}
|
|
2106
|
+
const stripped = content
|
|
2107
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
2108
|
+
.replace(/--[^\n]*/g, '');
|
|
2109
|
+
|
|
2110
|
+
const records = [];
|
|
2111
|
+
for (const [objectIndex, sqlObject] of objects.entries()) {
|
|
2112
|
+
const bodyEnd = objectIndex + 1 < objects.length ? objects[objectIndex + 1].matchIndex : content.length;
|
|
2113
|
+
const segment = stripped.slice(sqlObject.matchIndex, Math.min(bodyEnd, sqlObject.matchIndex + 20000));
|
|
2114
|
+
|
|
2115
|
+
const calls = [];
|
|
2116
|
+
const addCall = (rawName) => {
|
|
2117
|
+
if (!rawName) {
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
const bareName = rawName.split('.').pop().replace(/["`]/g, '');
|
|
2121
|
+
if (!bareName || bareName.toLowerCase() === sqlObject.name.toLowerCase()) {
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
if (SQL_CALL_KEYWORDS.has(bareName.toLowerCase())) {
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
if (!calls.includes(bareName)) {
|
|
2128
|
+
calls.push(bareName);
|
|
2129
|
+
}
|
|
2130
|
+
};
|
|
2131
|
+
|
|
2132
|
+
for (const match of segment.matchAll(/\b(?:FROM|JOIN)\s+["`]?([A-Za-z_][A-Za-z0-9_$]*(?:\s*\.\s*["`]?[A-Za-z_][A-Za-z0-9_$]*)?)/gi)) {
|
|
2133
|
+
addCall(match[1]);
|
|
2134
|
+
}
|
|
2135
|
+
for (const match of segment.matchAll(/\bEXEC(?:UTE)?\s+(?:@\w+\s*=\s*)?["`]?([A-Za-z_][A-Za-z0-9_$]*)/gi)) {
|
|
2136
|
+
addCall(match[1]);
|
|
2137
|
+
}
|
|
2138
|
+
for (const match of segment.matchAll(/\b([A-Za-z_][A-Za-z0-9_$]*)\s*\(/g)) {
|
|
2139
|
+
addCall(match[1]);
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
records.push({
|
|
2143
|
+
filePath,
|
|
2144
|
+
symbol: sqlObject.name,
|
|
2145
|
+
exported: true,
|
|
2146
|
+
calls,
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
return records;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
1915
2152
|
function extractFunctionCalls(filePath, content) {
|
|
2153
|
+
if (filePath.endsWith('.sql')) {
|
|
2154
|
+
return extractSqlFunctionCalls(filePath, content);
|
|
2155
|
+
}
|
|
1916
2156
|
return extractFunctionBlocks(content).map((block) => {
|
|
1917
2157
|
const calls = [];
|
|
1918
2158
|
for (const match of block.body.matchAll(/(?:\b|\.)([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { getArtifactPath, INDEX_ARTIFACTS } from './paths.js';
|
|
4
|
+
import { getArtifactPath, INDEX_ARTIFACTS, isLikelyTestFilePath } from './paths.js';
|
|
5
5
|
import { classifyImpactRisk, findMirrorCounterparts } from './impactCatalog.js';
|
|
6
6
|
import { inferRelatedTestsFromArtifacts, loadRelatedTestArtifacts } from './relatedTests.js';
|
|
7
7
|
import { importsNeedAliasContext, loadImportAliasContext, resolveImportSpecifier } from './importResolution.js';
|
|
@@ -98,10 +98,11 @@ export async function resolveImpactContext({
|
|
|
98
98
|
const normalizedChangedFiles = unique(changedFiles.map(normalizePath));
|
|
99
99
|
const normalizedChangedSymbols = unique(changedSymbols.map((symbol) => String(symbol ?? '').trim()));
|
|
100
100
|
|
|
101
|
-
const [callsArtifact, importsArtifact, filesArtifact, relatedArtifacts] = await Promise.all([
|
|
101
|
+
const [callsArtifact, importsArtifact, filesArtifact, symbolsArtifact, relatedArtifacts] = await Promise.all([
|
|
102
102
|
readArtifact(absoluteRoot, INDEX_ARTIFACTS.calls),
|
|
103
103
|
readArtifact(absoluteRoot, INDEX_ARTIFACTS.imports),
|
|
104
104
|
readArtifact(absoluteRoot, INDEX_ARTIFACTS.files),
|
|
105
|
+
readArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols),
|
|
105
106
|
loadRelatedTestArtifacts({ rootDir: absoluteRoot }),
|
|
106
107
|
]);
|
|
107
108
|
const callItems = safeItems(callsArtifact);
|
|
@@ -135,6 +136,15 @@ export async function resolveImpactContext({
|
|
|
135
136
|
...normalizedChangedSymbols,
|
|
136
137
|
...changedRecords.map((record) => record.symbol),
|
|
137
138
|
]);
|
|
139
|
+
// Global-registered symbols (window.X / app.config.globalProperties.$X /
|
|
140
|
+
// app.component(...)) are reachable without imports, so a bare-name call is
|
|
141
|
+
// already proof for them.
|
|
142
|
+
const changedGlobalSymbols = new Set(
|
|
143
|
+
safeItems(symbolsArtifact)
|
|
144
|
+
.filter((item) => item.type === 'global' && changedFileSet.has(normalizePath(item.filePath)))
|
|
145
|
+
.map((item) => item.name),
|
|
146
|
+
);
|
|
147
|
+
const candidateCallers = [];
|
|
138
148
|
if (callerTargets.size > 0) {
|
|
139
149
|
for (const record of callItems) {
|
|
140
150
|
const matchedSymbol = (record.calls ?? []).find((name) => callerTargets.has(name));
|
|
@@ -146,7 +156,12 @@ export async function resolveImpactContext({
|
|
|
146
156
|
continue;
|
|
147
157
|
}
|
|
148
158
|
|
|
149
|
-
|
|
159
|
+
candidateCallers.push({
|
|
160
|
+
filePath: record.filePath,
|
|
161
|
+
symbol: record.symbol,
|
|
162
|
+
reason: `calls ${matchedSymbol}`,
|
|
163
|
+
matchedSymbol,
|
|
164
|
+
});
|
|
150
165
|
}
|
|
151
166
|
}
|
|
152
167
|
|
|
@@ -179,48 +194,108 @@ export async function resolveImpactContext({
|
|
|
179
194
|
}
|
|
180
195
|
}
|
|
181
196
|
|
|
197
|
+
// Callers only count with proof: an import edge into a changed file
|
|
198
|
+
// (directly / re-export / alias), an explicit SQL reference record, or a
|
|
199
|
+
// global-registered changed symbol. Name-only matches are reported as
|
|
200
|
+
// unverifiedImpact instead of pretending exhaustive caller knowledge.
|
|
201
|
+
const importerSet = new Set(importers);
|
|
202
|
+
const isVerifiedCaller = (entry) => importerSet.has(entry.filePath)
|
|
203
|
+
|| String(entry.filePath ?? '').toLowerCase().endsWith('.sql')
|
|
204
|
+
|| changedGlobalSymbols.has(entry.matchedSymbol);
|
|
205
|
+
// Output rows keep the historical { filePath, symbol, reason } shape.
|
|
206
|
+
const toCallerRow = ({ filePath, symbol, reason }) => ({ filePath, symbol, reason });
|
|
207
|
+
const verifiedCallers = candidateCallers.filter(isVerifiedCaller).map(toCallerRow);
|
|
208
|
+
const unverifiedImpact = unique(
|
|
209
|
+
candidateCallers
|
|
210
|
+
.filter((entry) => !isVerifiedCaller(entry))
|
|
211
|
+
.map((entry) => entry.filePath),
|
|
212
|
+
);
|
|
213
|
+
|
|
182
214
|
const candidateFiles = unique([
|
|
183
215
|
...normalizedChangedFiles,
|
|
184
216
|
...importers,
|
|
185
|
-
...
|
|
217
|
+
...verifiedCallers.map((item) => item.filePath),
|
|
186
218
|
...mirrorCounterparts.map((item) => item.filePath),
|
|
187
219
|
]);
|
|
188
|
-
const
|
|
220
|
+
const relatedTestsFromArtifacts = inferRelatedTestsFromArtifacts({
|
|
189
221
|
candidateFiles,
|
|
190
222
|
analogsMap: relatedArtifacts.analogsMap,
|
|
191
223
|
relationsMap: relatedArtifacts.relationsMap,
|
|
192
224
|
limit: budget.maxTests,
|
|
193
225
|
}).map((entry) => entry.filePath);
|
|
226
|
+
// Importer test files are related tests by definition: they import the
|
|
227
|
+
// changed file and live under a test directory.
|
|
228
|
+
const importerTestFiles = importers.filter((filePath) => isLikelyTestFilePath(filePath));
|
|
229
|
+
const relatedTests = unique([...importerTestFiles, ...relatedTestsFromArtifacts]);
|
|
194
230
|
|
|
195
231
|
const recommendedTestFiles = unique([
|
|
196
232
|
...relatedTests,
|
|
197
233
|
...risk.riskLabels.flatMap((label) => RISK_TEST_RECOMMENDATIONS[label] ?? []),
|
|
198
234
|
]);
|
|
199
235
|
|
|
200
|
-
|
|
236
|
+
// Stale-entry guard: a renamed/deleted file must not surface as impact.
|
|
237
|
+
// Only the capped output lists are stat-checked, so the cost stays bounded.
|
|
238
|
+
const existingRelatedTests = await filterExistingPaths(absoluteRoot, relatedTests);
|
|
239
|
+
const dedupedCallers = await filterExistingCallers(absoluteRoot, dedupeCallers(verifiedCallers));
|
|
240
|
+
const existingImporters = await filterExistingPaths(absoluteRoot, unique(importers));
|
|
241
|
+
const existingUnverifiedImpact = await filterExistingPaths(absoluteRoot, unverifiedImpact);
|
|
242
|
+
|
|
243
|
+
const dedupedCallerList = dedupedCallers.slice(0, budget.maxCallers);
|
|
244
|
+
const importerList = existingImporters.slice(0, budget.maxImporters);
|
|
245
|
+
const calleeList = unique(callees.map((item) => JSON.stringify(item))).map((value) => JSON.parse(value));
|
|
246
|
+
const relatedTestList = existingRelatedTests.slice(0, budget.maxTests);
|
|
247
|
+
const unverifiedList = existingUnverifiedImpact.slice(0, budget.maxCallers);
|
|
201
248
|
|
|
202
249
|
return {
|
|
203
250
|
changedFiles: normalizedChangedFiles,
|
|
204
251
|
changedSymbols: normalizedChangedSymbols,
|
|
205
252
|
riskLabels: risk.riskLabels,
|
|
206
253
|
requiresImpactCheck: risk.requiresImpactCheck,
|
|
207
|
-
callees:
|
|
208
|
-
callers:
|
|
209
|
-
importers:
|
|
254
|
+
callees: calleeList.slice(0, budget.maxCallees),
|
|
255
|
+
callers: dedupedCallerList,
|
|
256
|
+
importers: importerList,
|
|
210
257
|
dependencies: unique(dependencies).slice(0, budget.maxImporters),
|
|
211
258
|
mirrorCounterparts,
|
|
212
|
-
relatedTests,
|
|
259
|
+
relatedTests: relatedTestList,
|
|
213
260
|
recommendedTestFiles,
|
|
261
|
+
unverifiedImpact: unverifiedList,
|
|
262
|
+
truncated: {
|
|
263
|
+
callers: Math.max(dedupedCallers.length - dedupedCallerList.length, 0),
|
|
264
|
+
importers: Math.max(existingImporters.length - importerList.length, 0),
|
|
265
|
+
callees: Math.max(calleeList.length - budget.maxCallees, 0),
|
|
266
|
+
relatedTests: Math.max(existingRelatedTests.length - relatedTestList.length, 0),
|
|
267
|
+
unverifiedImpact: Math.max(existingUnverifiedImpact.length - unverifiedList.length, 0),
|
|
268
|
+
},
|
|
214
269
|
explanations: {
|
|
215
270
|
callees: changedRecords.map((record) => `${record.filePath}:${record.symbol}`),
|
|
216
|
-
callers:
|
|
271
|
+
callers: dedupedCallerList.map((entry) => `${entry.filePath}:${entry.symbol}`),
|
|
217
272
|
importers,
|
|
218
273
|
relatedTests,
|
|
219
274
|
mirrorCounterparts: mirrorCounterparts.map((entry) => `${entry.source} -> ${entry.filePath}`),
|
|
275
|
+
unverifiedImpact: unverifiedList,
|
|
220
276
|
},
|
|
221
277
|
};
|
|
222
278
|
}
|
|
223
279
|
|
|
280
|
+
async function pathExists(rootDir, relativePath) {
|
|
281
|
+
try {
|
|
282
|
+
await fs.access(path.join(rootDir, relativePath));
|
|
283
|
+
return true;
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function filterExistingPaths(rootDir, values = []) {
|
|
290
|
+
const checks = await Promise.all(values.map(async (value) => ({ value, exists: await pathExists(rootDir, value) })));
|
|
291
|
+
return checks.filter((check) => check.exists).map((check) => check.value);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function filterExistingCallers(rootDir, callers = []) {
|
|
295
|
+
const checks = await Promise.all(callers.map(async (entry) => ({ entry, exists: await pathExists(rootDir, entry.filePath) })));
|
|
296
|
+
return checks.filter((check) => check.exists).map((check) => check.entry);
|
|
297
|
+
}
|
|
298
|
+
|
|
224
299
|
export function formatImpactConfidenceSummary(impact = {}) {
|
|
225
300
|
const lines = ['Impact checked:'];
|
|
226
301
|
|
|
@@ -254,6 +329,11 @@ export function formatImpactConfidenceSummary(impact = {}) {
|
|
|
254
329
|
lines.push(`- Mirrors: ${mirrors.join(', ')}`);
|
|
255
330
|
}
|
|
256
331
|
|
|
332
|
+
const unverified = (impact.unverifiedImpact ?? []).slice(0, 3);
|
|
333
|
+
if (unverified.length > 0) {
|
|
334
|
+
lines.push(`- Unverified impact (name-only): ${unverified.join(', ')}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
257
337
|
const relatedTests = (impact.relatedTests ?? []).slice(0, 4);
|
|
258
338
|
if (relatedTests.length > 0) {
|
|
259
339
|
lines.push(`- Related tests: ${relatedTests.join(', ')}`);
|