@ngockhoale/ukit 3.4.29 → 3.4.31

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 (49) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/manifests/documentation.yaml +100 -0
  3. package/package.json +1 -1
  4. package/src/core/executionContracts.js +32 -0
  5. package/src/core/permissionPolicy.js +1 -1
  6. package/src/decision/client.js +15 -0
  7. package/src/decision/protocol.js +12 -14
  8. package/src/index/buildIndex.js +244 -4
  9. package/src/index/impactContext.js +91 -11
  10. package/src/index/languageTools.js +126 -0
  11. package/src/index/paths.js +5 -1
  12. package/src/index/playbookRegistry.js +108 -9
  13. package/src/index/queryIndex.js +415 -1
  14. package/src/index/routeCatalog.js +1 -1
  15. package/src/index/routeResolver.js +2 -2
  16. package/src/index/taskRouting.js +28 -4
  17. package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
  18. package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
  19. package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
  20. package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
  21. package/template_project/.claude/hooks/record-execution.mjs +21 -0
  22. package/template_project/.claude/hooks/skill-router.sh +187 -16
  23. package/template_project/.claude/settings.json +43 -1
  24. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  25. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
  26. package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
  27. package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
  28. package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
  29. package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
  30. package/template_project/.claude/ukit/index/query-index.mjs +100 -7
  31. package/template_project/.claude/ukit/index/review-verdict.mjs +14 -4
  32. package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
  33. package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
  34. package/template_project/.claude/ukit/index/route-task.mjs +243 -38
  35. package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -59
  36. package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
  37. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
  38. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +612 -56
  39. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
  40. package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
  41. package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
  42. package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
  43. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
  44. package/template_project/.omp/hooks/pre/ukit-bridge.js +40 -5
  45. package/template_project/AGENTS.md +1 -1
  46. package/template_project/CLAUDE.md +1 -1
  47. package/template_project/instructions/core.md +1 -1
  48. package/template_user/playbooks/feature-implementation.md +9 -0
  49. package/template_user/playbooks/migration.md +4 -0
package/CHANGELOG.md CHANGED
@@ -9,6 +9,35 @@ 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.31 - 2026-10-11
13
+
14
+ **Completion Gate: task-aware operational completion (fixes the CronDelete/CronList false positive).** Verified with the full suite (`yarn test`, 566 files) plus focused six-scenario routing → recorder → Stop regressions.
15
+ - **Task kinds.** CODE_IMPLEMENTATION / OPERATIONAL / READ_ONLY / CONFIGURATION / MIXED are classified independently of write activity. Implementation still requires a source write plus risk-based verification; mixed tasks keep both obligations.
16
+ - **Operational evidence.** New bounded, tool-agnostic proof bank (`operation-evidence.mjs`) and strict native-result normalization (`cron-operation-adapter.mjs`): completion needs an accepted execution, matching target/scope, a fresh verified postcondition and active-task evidence. Unsupported result shapes fail closed.
17
+ - **Task-instance isolation.** Each request gets its own task identity; identical repeated requests are distinct tasks, while continue/resume keeps identity. Evidence and journals from an earlier task cannot satisfy a new one.
18
+ - **Receipt retention / replay protection.** Failed operations survive receipt eviction; native tool-use IDs are tracked (bounded, fail-closed on saturation) so an overwritten observation cannot replay as fresh proof.
19
+ - **Claude Code / OMP.** The Claude recorder and settings matchers cover `CronDelete`/`CronList` success and failure events; the OMP bridge normalizes `cron_delete`/`cron_list` results. Codex uses the shared policy helper (no native Stop enforcement claimed).
20
+ - **Negation-aware classification.** "Xóa cron …, không thay đổi code, Git hoặc Docker" / "Delete cron …. Do not modify source code, Git or Docker." classify as OPERATIONAL; negated source instructions no longer become source requirements.
21
+ - **No-route mutation.** A prompt that ordered a change but matched no skill (no routed contract) now ends with an explicit "nothing was verified" notice instead of a silent stop. This is a notice, not a block — a known limitation.
22
+ - Loop-breaker release note no longer claims no verification ran; docs retry-guidance wording aligned in the template instructions.
23
+ - Limits: classifier is lexical; the Cron adapter does not support an "ensure presence" goal (fails closed); no live native end-to-end run was performed.
24
+
25
+ ## 3.4.30 - 2026-10-10
26
+
27
+ **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.
28
+ - **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`.
29
+ - **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`.
30
+ - **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).
31
+ - **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>`.
32
+ - **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.
33
+ - **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.
34
+ - **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).
35
+ - **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.
36
+ - **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".
37
+ - **`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).
38
+ - **Playbook observability.** `ledger.playbookTrace`, `--playbook-status`.
39
+ - **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.
40
+
12
41
  ## 3.4.29 - 2026-10-09
13
42
 
14
43
  **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
@@ -845,6 +933,18 @@ entries:
845
933
  archive_policy: never
846
934
  notes: C69 G7 operator doc — timeline render, support bundle contents/exclusions, replay limits
847
935
 
936
+ - id: docs-unic-decision-integration-p1-fix
937
+ path: docs/UNIC-DECISION-INTEGRATION-P1-FIX.md
938
+ class: canonical
939
+ audience: [maintainer]
940
+ owner: runtime
941
+ source_of_truth: docs/UNIC-DECISION-INTEGRATION-P1-FIX.md
942
+ merge_strategy: none
943
+ load_policy: on-demand
944
+ validation: [manual]
945
+ archive_policy: never
946
+ notes: P1 safety-floor / ordinal choice score / request-cap fix record for the unic-decision integration (9971eac0)
947
+
848
948
  - id: docs-design-ci-cross-platform-matrix
849
949
  path: docs/design/CI-CROSS-PLATFORM-MATRIX.md
850
950
  class: canonical
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.29",
3
+ "version": "3.4.31",
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",
@@ -166,3 +166,35 @@ export function buildConfigContracts() {
166
166
  Object.entries(EXECUTION_CONTRACTS).map(([mode, contract]) => [mode, stripContractForConfig(contract)])
167
167
  );
168
168
  }
169
+
170
+ // Task semantics are independent of source execution strategy and observed progress.
171
+ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
172
+ const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
173
+ const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
174
+ const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
175
+ && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
176
+ const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
177
+ && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
178
+ const operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
179
+ && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
180
+ const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
181
+ const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
182
+ const readOnly = /\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text)
183
+ && !sourceOrder && !operation && !config;
184
+ const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
185
+ : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
186
+ const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
187
+ return { taskKind, ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
188
+ ...(operation ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
189
+ completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
190
+ }
191
+
192
+ export function applyTaskSemantics(contract, semantic) {
193
+ const evidence = contract?.completionEvidence || [];
194
+ const operationOnly = semantic.taskKind === 'OPERATIONAL'
195
+ || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external');
196
+ return { ...(contract || {}), ...semantic,
197
+ completionEvidence: operationOnly ? ['operation-verification']
198
+ : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
199
+ : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
200
+ }
@@ -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
  ]);
@@ -368,6 +368,21 @@ export function createDecisionClient({
368
368
  return outcome('invalid', { fallbackCode: 'empty-batch' });
369
369
  }
370
370
 
371
+ const maxBatch = dp.maxBatchQuestions ?? 8;
372
+ const maxEnum = dp.maxEnumLabels ?? 11;
373
+ if (![maxBatch, maxEnum].every(n => Number.isInteger(n) && n > 0)) {
374
+ return outcome('unsupported', { fallbackCode: 'invalid-decision-limits' });
375
+ }
376
+ if (questions.length > maxBatch) {
377
+ return outcome('unsupported', { fallbackCode: 'max-batch-questions' });
378
+ }
379
+ if (questions.some(q => Array.isArray(q.candidates) && q.candidates.length > maxEnum)) {
380
+ return outcome('unsupported', { fallbackCode: 'max-enum-labels' });
381
+ }
382
+ if (new Set(questions.map(q => q.decisionKey)).size !== questions.length) {
383
+ return outcome('invalid', { fallbackCode: 'duplicate-question' });
384
+ }
385
+
371
386
  // Language classification covers state + question text; the English
372
387
  // checkpoint is reachable only when the whole request proves English.
373
388
  // Malformed batch input (cyclic state, non-serializable values, internal
@@ -51,23 +51,20 @@ export function encodeQuestion(question) {
51
51
  description: 'true iff the proposition holds; interpreted as P(true).',
52
52
  };
53
53
  } else {
54
- // score — ordinal integer; the legend MUST be defined in the description.
55
- const legend = Array.isArray(candidates) ? candidates : [];
56
- value = { type: 'integer' };
57
- if (legend.length > 0) {
58
- value.minimum = 1;
59
- value.maximum = legend.length;
60
- value.description = `Ordered scale: ${legend
61
- .map((label, i) => `${i + 1}=${label}`)
62
- .join(', ')}.`;
54
+ if (!Array.isArray(candidates) || candidates.length === 0
55
+ || candidates.some(label => typeof label !== 'string' || !label.trim())
56
+ || new Set(candidates).size !== candidates.length) {
57
+ throw new TypeError('score requires unique non-empty ordinal labels.');
63
58
  }
59
+ value = { type: 'string', enum: [...candidates] };
64
60
  }
65
61
 
66
62
  return {
67
63
  type: 'function',
68
64
  function: {
69
65
  name: decisionKey,
70
- description: String(instruction ?? ''),
66
+ description: String(instruction ?? '') + (kind === 'score'
67
+ ? `\nOrdered ordinal rubric: ${candidates.map((label, i) => `${i + 1}=${JSON.stringify(label)}`).join(', ')}. Choose exactly one label; do not return an expected numeric score.` : ''),
71
68
  parameters: {
72
69
  type: 'object',
73
70
  properties: { value },
@@ -184,11 +181,12 @@ function validateValue(question, args) {
184
181
  if (isFiniteNumber(value) && value >= 0 && value <= 1) return { value };
185
182
  return 'missing-value';
186
183
  }
187
- // score — finite integer within the declared legend range.
188
- if (!isFiniteNumber(value) || !Number.isInteger(value)) return 'missing-value';
184
+ // Decode an explicit choice losslessly; never reinterpret native expectations.
189
185
  const legend = Array.isArray(question.candidates) ? question.candidates : [];
190
- if (legend.length > 0 && (value < 1 || value > legend.length)) return 'out-of-enum';
191
- return { value };
186
+ if (!legend.length || new Set(legend).size !== legend.length) return 'out-of-enum';
187
+ if (typeof value !== 'string') return 'missing-value';
188
+ const ordinal = legend.indexOf(value);
189
+ return ordinal < 0 ? 'out-of-enum' : { value: ordinal + 1 };
192
190
  }
193
191
 
194
192
  /**
@@ -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)) {