@ngockhoale/ukit 3.4.28 → 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.
Files changed (36) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/manifests/documentation.yaml +88 -0
  3. package/package.json +1 -1
  4. package/src/core/buildPlan.js +11 -1
  5. package/src/core/permissionPolicy.js +1 -1
  6. package/src/core/status.js +3 -1
  7. package/src/index/buildIndex.js +244 -4
  8. package/src/index/impactContext.js +91 -11
  9. package/src/index/languageTools.js +126 -0
  10. package/src/index/paths.js +5 -1
  11. package/src/index/playbookRegistry.js +108 -9
  12. package/src/index/queryIndex.js +415 -1
  13. package/src/index/routeCatalog.js +1 -1
  14. package/src/index/taskRouting.js +25 -3
  15. package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
  16. package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
  17. package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
  18. package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
  19. package/template_project/.claude/hooks/record-execution.mjs +23 -0
  20. package/template_project/.claude/hooks/skill-router.sh +105 -7
  21. package/template_project/.claude/settings.json +21 -1
  22. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  23. package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
  24. package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
  25. package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
  26. package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
  27. package/template_project/.claude/ukit/index/query-index.mjs +100 -7
  28. package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
  29. package/template_project/.claude/ukit/index/route-task.mjs +208 -31
  30. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +455 -24
  31. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
  32. package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
  33. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
  34. package/template_project/.omp/hooks/pre/ukit-bridge.js +32 -4
  35. package/template_user/playbooks/feature-implementation.md +9 -0
  36. package/template_user/playbooks/migration.md +4 -0
package/CHANGELOG.md CHANGED
@@ -9,6 +9,29 @@ 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
+
28
+ ## 3.4.29 - 2026-10-09
29
+
30
+ **Fixes (no new features).**
31
+ - `ukit install` inside the UKit source repo no longer overwrites the hand-curated root `CLAUDE.md` / `AGENTS.md` (repo-only Release Policy was lost). Self-install now seeds but never overwrites them, like `docs/`; downstream projects still get them refreshed. Regression: `tests/core/buildPlan.test.js`.
32
+ - `ukit status` no longer reads the ambient project's `context-capacity.json` (CLAUDE_PROJECT_DIR/cwd); the capacity record is scoped to the reported project root, so `statusCommand.test.js` passes with or without a live runtime cache.
33
+ - Completion gate: `yarn release:verify; echo "exit=$?"` (and `&&`/`|` variants without a space) is now recognised as a verification command, so the green run records `verification-evidence`. Failed runs still record as failed. Regression: `tests/hooks/executionLedgerReleaseVerify.test.js`.
34
+
12
35
  ## 3.4.28 - 2026-10-09
13
36
 
14
37
  **Release-safety close-out: config preservation, safe `ukit update`, OMP eval/Bash hardening (local RC; not yet published).**
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.28",
3
+ "version": "3.4.30",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,6 +45,16 @@ function safeResolve(basePath, relativePath) {
45
45
  return resolvedPath;
46
46
  }
47
47
 
48
+ // Root instruction files are hand-curated canonical sources in the UKit repo itself (they carry
49
+ // the repo-only Release Policy, rendered from instructions/ + the repo overlay), so a
50
+ // self-install must seed them but never overwrite them.
51
+ const SELF_INSTALL_PROTECTED_ROOT_FILES = new Set(['CLAUDE.md', 'AGENTS.md']);
52
+
53
+ function isSelfInstallProtectedTarget(targetPath, projectRoot) {
54
+ return isDocsTarget(targetPath, projectRoot)
55
+ || SELF_INSTALL_PROTECTED_ROOT_FILES.has(path.relative(projectRoot, targetPath));
56
+ }
57
+
48
58
  function isDocsTarget(targetPath, projectRoot) {
49
59
  const relative = path.relative(projectRoot, targetPath);
50
60
  return relative === 'docs' || relative.startsWith(`docs${path.sep}`);
@@ -88,7 +98,7 @@ async function downgradeSelfInstallDocOverwrites(planEntries, { templatesRoot, p
88
98
  if (entry.type === 'link' || entry.mergeStrategy !== 'overwrite_with_backup') {
89
99
  continue;
90
100
  }
91
- if (isDocsTarget(entry.targetPath, projectRoot)) {
101
+ if (isSelfInstallProtectedTarget(entry.targetPath, projectRoot)) {
92
102
  entry.mergeStrategy = 'skip';
93
103
  }
94
104
  }
@@ -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:*)', omp: '*> /dev/sda*', codex: '> /dev/sda', hardDeny: true },
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
  ]);
@@ -146,7 +146,9 @@ export async function buildStatusReport(projectRoot) {
146
146
  const compactSummary = buildCompactSummary(compactHistory);
147
147
  const compactPressure = await buildCompactPressureState(
148
148
  await readStatusJson(runtimePaths.compactPressurePath),
149
- config,
149
+ // Scope the negotiated-capacity record to the reported project; without projectRoot the
150
+ // reader falls back to CLAUDE_PROJECT_DIR/cwd and leaks another project's runtime cache.
151
+ { ...config, projectRoot },
150
152
  );
151
153
  const promptCacheStats = buildPromptCacheStats(await readStatusJson(runtimePaths.promptCachePath));
152
154
  const outputCompressionStats = summarizeOutputHistory(await readStatusJson(runtimePaths.outputHistoryPath));
@@ -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
- return dedupeBy(symbols, (item) => `${item.filePath}:${item.type}:${item.name}`);
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
- callers.push({ filePath: record.filePath, symbol: record.symbol, reason: `calls ${matchedSymbol}` });
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
- ...callers.map((item) => item.filePath),
217
+ ...verifiedCallers.map((item) => item.filePath),
186
218
  ...mirrorCounterparts.map((item) => item.filePath),
187
219
  ]);
188
- const relatedTests = inferRelatedTestsFromArtifacts({
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
- const dedupedCallers = dedupeCallers(callers).slice(0, budget.maxCallers);
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: unique(callees.map((item) => JSON.stringify(item))).map((value) => JSON.parse(value)).slice(0, budget.maxCallees),
208
- callers: dedupedCallers,
209
- importers: unique(importers).slice(0, budget.maxImporters),
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: dedupedCallers.map((entry) => `${entry.filePath}:${entry.symbol}`),
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(', ')}`);