@ngockhoale/ukit 1.1.6 → 1.1.7

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.
@@ -23,6 +23,7 @@ export async function deriveVerificationPlan({
23
23
  targetFile = null,
24
24
  taskType = null,
25
25
  contextResult = null,
26
+ impactContext = null,
26
27
  skillIds = [],
27
28
  } = {}) {
28
29
  const absoluteRoot = path.resolve(rootDir);
@@ -72,7 +73,15 @@ export async function deriveVerificationPlan({
72
73
  const notes = [];
73
74
  const docsOnly = primaryTargets.length > 0
74
75
  && primaryTargets.every((filePath) => filePath.startsWith('docs/'));
76
+ const impactTests = unique(impactContext?.recommendedTestFiles ?? []);
77
+ const impactRisk = Boolean(
78
+ impactContext?.requiresImpactCheck
79
+ || (impactContext?.riskLabels ?? []).length > 0,
80
+ );
81
+ const sharedSimple = effectiveTaskType === 'shared-simple' || taskType === 'shared-simple';
75
82
  const risky = effectiveTaskType === 'non-trivial'
83
+ || sharedSimple
84
+ || impactRisk
76
85
  || skillIds.some((skillId) => RISKY_SKILL_IDS.has(skillId));
77
86
 
78
87
  let mode = 'minimal';
@@ -98,6 +107,14 @@ export async function deriveVerificationPlan({
98
107
  }
99
108
  }
100
109
 
110
+ if (impactTests.length > 0 && scripts.test) {
111
+ mode = relatedTests.length > 0 ? mode : 'impact-tests-first';
112
+ reasons.push('impactTests');
113
+ for (const testFile of impactTests.slice(0, 2)) {
114
+ commands.push(buildScriptCommand(packageManager, 'test', [testFile]));
115
+ }
116
+ }
117
+
101
118
  if (risky) {
102
119
  reasons.push('riskySkillOrTask');
103
120
  if (scripts.test) {
@@ -345,7 +362,7 @@ function deriveExecutionPolicy({
345
362
  || reasons.includes('riskySkillOrTask')
346
363
  || reasons.includes('highRiskFallback');
347
364
  const hasPrimaryCommands = commands.length > 0;
348
- const hasTargetedPrimaryCommands = mode === 'targeted-tests-first' && hasPrimaryCommands;
365
+ const hasTargetedPrimaryCommands = (mode === 'targeted-tests-first' || mode === 'impact-tests-first') && hasPrimaryCommands;
349
366
  const sharedScope = sharedAbstractions.length > 0;
350
367
  const noRelatedTests = relatedTests.length === 0;
351
368
  const broadOnlyPrimary = hasPrimaryCommands && !hasTargetedPrimaryCommands && noRelatedTests;
@@ -386,7 +386,7 @@ const { pathToFileURL } = require('url');
386
386
  return '';
387
387
  }
388
388
 
389
- function inferTaskType({ promptText, commandText, selectedIds, buildRouteSignalText }) {
389
+ function inferTaskType({ promptText, commandText, selectedIds, buildRouteSignalText, targetFile = null }) {
390
390
  const lower = buildRouteSignalText(promptText, commandText);
391
391
 
392
392
  if (
@@ -5,9 +5,10 @@
5
5
  INPUT=$(cat)
6
6
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
7
7
  STATE_FILE="$PROJECT_ROOT/.claude/ukit/skill-router-state.json"
8
+ ROUTE_CACHE_FILE="$PROJECT_ROOT/.claude/ukit/route-cache.json"
8
9
  PROGRESS_FILE="$PROJECT_ROOT/.claude/ukit/verification-progress.json"
9
10
 
10
- INPUT="$INPUT" STATE_FILE="$STATE_FILE" PROGRESS_FILE="$PROGRESS_FILE" node <<'NODE'
11
+ INPUT="$INPUT" STATE_FILE="$STATE_FILE" ROUTE_CACHE_FILE="$ROUTE_CACHE_FILE" PROGRESS_FILE="$PROGRESS_FILE" node <<'NODE'
11
12
  const fs = require('fs');
12
13
  const path = require('path');
13
14
 
@@ -125,12 +126,122 @@ function isExplicitBroadVerificationRequest(promptText) {
125
126
  /chạy full test suite/,
126
127
  /verify toàn bộ/,
127
128
  /kiểm tra toàn bộ/,
129
+ // Plan-driven execution prompts often authorize the plan's final broad
130
+ // verification without spelling it as "full test suite".
131
+ /(?:run|execute|implement|do)\b.*\bfull\b.*\b(?:prd\s+)?plan\b/,
132
+ /\bfull\b.*\b(?:prd\s+)?plan\b.*\b(?:verify|verification|test|tests)\b/,
133
+ /(?:chạy|lam|làm|thực hiện|triển khai)\b.*\bfull\b.*\b(?:prd|plan|ke hoach|kế hoạch)\b/,
134
+ /\bfull\b.*\b(?:prd|plan|ke hoach|kế hoạch)\b.*\b(?:verify|verification|test|tests|kiểm tra)\b/,
128
135
  ].some((pattern) => pattern.test(text));
129
136
  }
130
137
 
138
+ function collectRecentPromptTexts(state, routeCache, maxAgeMs) {
139
+ const prompts = [
140
+ state?.routingContext?.lastExplicitUserPromptText,
141
+ state?.routingContext?.promptText,
142
+ ];
143
+ const now = Date.now();
144
+
145
+ for (const entry of routeCache?.entries ?? []) {
146
+ const updatedAt = Number(entry?.updatedAt ?? entry?.ts ?? 0);
147
+ if (Number.isFinite(updatedAt) && updatedAt > 0 && now - updatedAt > maxAgeMs) {
148
+ continue;
149
+ }
150
+ prompts.push(
151
+ entry?.routingContext?.lastExplicitUserPromptText,
152
+ entry?.routingContext?.promptText,
153
+ );
154
+ }
155
+
156
+ return unique(prompts.map((prompt) => String(prompt || '').trim()).filter(Boolean));
157
+ }
158
+
159
+ function hasRecentExplicitBroadVerificationRequest(state, routeCache, maxAgeMs) {
160
+ return collectRecentPromptTexts(state, routeCache, maxAgeMs)
161
+ .some((promptText) => isExplicitBroadVerificationRequest(promptText));
162
+ }
163
+
164
+ function collectRecentRouteSummaries(routeCache, maxAgeMs) {
165
+ const now = Date.now();
166
+ const summaries = [];
167
+
168
+ for (const entry of routeCache?.entries ?? []) {
169
+ const updatedAt = Number(entry?.updatedAt ?? entry?.ts ?? 0);
170
+ if (Number.isFinite(updatedAt) && updatedAt > 0 && now - updatedAt > maxAgeMs) {
171
+ continue;
172
+ }
173
+ if (entry?.routeSummary && typeof entry.routeSummary === 'object') {
174
+ summaries.push(entry.routeSummary);
175
+ }
176
+ }
177
+
178
+ return summaries;
179
+ }
180
+
181
+ function collectAttemptedCommands(progress, {
182
+ currentFingerprint = null,
183
+ maxAgeMs,
184
+ } = {}) {
185
+ const now = Date.now();
186
+ const attempted = [];
187
+ const progressUpdatedAt = Number(progress?.updatedAt ?? 0);
188
+ const legacyProgressIsFresh = Number.isFinite(progressUpdatedAt)
189
+ && progressUpdatedAt > 0
190
+ && now - progressUpdatedAt <= maxAgeMs;
191
+
192
+ if (progress?.fingerprint === currentFingerprint || legacyProgressIsFresh) {
193
+ attempted.push(...(progress?.attemptedCommands ?? []));
194
+ }
195
+
196
+ for (const entry of progress?.recentAttempts ?? []) {
197
+ const ts = Number(entry?.ts ?? 0);
198
+ if (Number.isFinite(ts) && ts > 0 && now - ts <= maxAgeMs) {
199
+ attempted.push(entry.command);
200
+ }
201
+ }
202
+
203
+ return new Set(attempted.map(normalizeCommand).filter(Boolean));
204
+ }
205
+
206
+ function hasCompletedRecentTargetedLane({
207
+ routeCache,
208
+ attemptedCommands,
209
+ command,
210
+ maxAgeMs,
211
+ } = {}) {
212
+ const normalizedCommand = normalizeCommand(command);
213
+ if (!isBroadTestCommand(normalizedCommand)) {
214
+ return false;
215
+ }
216
+
217
+ return collectRecentRouteSummaries(routeCache, maxAgeMs).some((summary) => {
218
+ const mode = summary?.policyMode;
219
+ if (mode !== 'auto-run-targeted' && mode !== 'auto-run-targeted-then-fallback') {
220
+ return false;
221
+ }
222
+
223
+ const recentPrimary = unique((summary?.primaryCommands ?? []).map(normalizeCommand));
224
+ if (recentPrimary.length === 0 || !recentPrimary.every((item) => attemptedCommands.has(item))) {
225
+ return false;
226
+ }
227
+
228
+ const recentFallbacks = unique((summary?.fallbackCommands ?? []).map(normalizeCommand));
229
+ const recentPreferred = unique((summary?.preferredOrder ?? []).map(normalizeCommand));
230
+ return recentFallbacks.includes(normalizedCommand)
231
+ || recentPreferred.includes(normalizedCommand)
232
+ || recentPrimary.includes(normalizedCommand);
233
+ });
234
+ }
235
+
131
236
  function deny(message) {
132
- process.stderr.write(`${message}\n`);
133
- process.exit(2);
237
+ const advisory = message.replace(/^BLOCKED:/, 'ADVISORY:');
238
+ if (process.env.UKIT_VERIFICATION_GUARD_ENFORCE === '1') {
239
+ fs.writeSync(2, `${message}\n`);
240
+ process.exit(2);
241
+ }
242
+
243
+ fs.writeSync(1, `${advisory}\n`);
244
+ process.exit(0);
134
245
  }
135
246
 
136
247
  const payload = (() => {
@@ -148,6 +259,7 @@ if (!command) {
148
259
  const statePath = process.env.STATE_FILE;
149
260
  const progressPath = process.env.PROGRESS_FILE;
150
261
  const state = readJson(statePath, null);
262
+ const routeCache = readJson(process.env.ROUTE_CACHE_FILE, null);
151
263
  const recommendation = state?.verificationRecommendation ?? null;
152
264
  const routeSummary = state?.routeSummary ?? null;
153
265
  const helpers = state?.helpers ?? null;
@@ -195,24 +307,39 @@ if (!policyMode && primaryCommands.length === 0 && fallbackCommands.length === 0
195
307
  }
196
308
 
197
309
  const progress = readJson(progressPath, {});
198
- const normalizedProgress = progress?.fingerprint === state?.fingerprint
199
- ? progress
200
- : { fingerprint: state?.fingerprint || null, attemptedCommands: [] };
201
- const attemptedCommands = new Set((normalizedProgress.attemptedCommands ?? []).map(normalizeCommand));
310
+ const attemptedCommands = collectAttemptedCommands(progress, {
311
+ currentFingerprint: state?.fingerprint || null,
312
+ maxAgeMs: STATE_FRESH_MS,
313
+ });
202
314
 
203
315
  function persistAttempt(commandText) {
204
- attemptedCommands.add(commandText);
316
+ const normalizedCommandText = normalizeCommand(commandText);
317
+ attemptedCommands.add(normalizedCommandText);
318
+ const now = Date.now();
319
+ const recentAttempts = [
320
+ ...(progress?.recentAttempts ?? []),
321
+ { command: normalizedCommandText, ts: now },
322
+ ]
323
+ .map((entry) => ({
324
+ command: normalizeCommand(entry?.command),
325
+ ts: Number(entry?.ts ?? now),
326
+ }))
327
+ .filter((entry) => entry.command && Number.isFinite(entry.ts) && now - entry.ts <= STATE_FRESH_MS)
328
+ .filter((entry, index, list) => (
329
+ list.findLastIndex((candidate) => candidate.command === entry.command) === index
330
+ ));
205
331
  writeJson(progressPath, {
206
332
  fingerprint: state?.fingerprint || null,
333
+ updatedAt: now,
207
334
  attemptedCommands: [...attemptedCommands],
335
+ recentAttempts,
208
336
  });
209
337
  }
210
338
 
211
339
  const isPrimaryCommand = primaryCommands.includes(command);
212
- const explicitBroadRequested = isExplicitBroadVerificationRequest(
213
- state?.routingContext?.lastExplicitUserPromptText || state?.routingContext?.promptText || '',
214
- );
340
+ const explicitBroadRequested = hasRecentExplicitBroadVerificationRequest(state, routeCache, STATE_FRESH_MS);
215
341
  const isFallbackCommand = fallbackCommands.includes(command);
342
+ const isTargetedVerification = looksTargetedTestCommand(command);
216
343
  if (!isPrimaryCommand && !isFallbackCommand && !isVerificationCommand(command)) {
217
344
  process.exit(0);
218
345
  }
@@ -223,10 +350,21 @@ const helperSource = routeSummary?.helperHint
223
350
  || '';
224
351
  const helperCommand = helperSource ? ` Helper: ${helperSource}` : '';
225
352
  const missingPrimary = primaryCommands.filter((item) => !attemptedCommands.has(item));
353
+ const recentTargetedLaneCompleted = hasCompletedRecentTargetedLane({
354
+ routeCache,
355
+ attemptedCommands,
356
+ command,
357
+ maxAgeMs: STATE_FRESH_MS,
358
+ });
226
359
  const preferredText = formatCompactCommandList(preferredOrder, { separator: ' -> ' });
227
360
  const missingPrimaryText = formatCompactCommandList(missingPrimary);
228
361
 
229
- if (policyMode === 'confirm-then-broad' && !explicitBroadRequested) {
362
+ if (isTargetedVerification) {
363
+ persistAttempt(command);
364
+ process.exit(0);
365
+ }
366
+
367
+ if (policyMode === 'confirm-then-broad' && isBroadTestCommand(command) && !explicitBroadRequested && !recentTargetedLaneCompleted) {
230
368
  deny(
231
369
  `BLOCKED: Ask the user before broad verification. Routed policy is confirm-then-broad because the index could not localize related tests confidently.${helperCommand}`,
232
370
  );
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import * as indexCore from './lib/index-core.mjs';
4
+
5
+ const {
6
+ buildCodeIndex,
7
+ DEFAULT_INDEX_CACHE_MAX_AGE_MS,
8
+ getIndexArtifactGeneratedAt,
9
+ isIndexStale,
10
+ } = indexCore;
11
+
12
+ const resolveImpactContext = typeof indexCore.resolveImpactContext === 'function'
13
+ ? indexCore.resolveImpactContext
14
+ : null;
15
+ const formatImpactConfidenceSummary = typeof indexCore.formatImpactConfidenceSummary === 'function'
16
+ ? indexCore.formatImpactConfidenceSummary
17
+ : null;
18
+
19
+ const CALLEE_CAP = 5;
20
+ const CALLER_CAP = 5;
21
+ const MIRROR_CAP = 3;
22
+ const TEST_CAP = 4;
23
+ const VERIFY_CAP = 3;
24
+
25
+ async function main() {
26
+ const args = process.argv.slice(2);
27
+ const rootDir = getRootDir(args);
28
+ const changedFiles = parseCsv(readFlagValue(args, '--changed'));
29
+ const changedSymbols = parseCsv(readFlagValue(args, '--symbol'));
30
+
31
+ if (changedFiles.length === 0) {
32
+ console.error('Usage: node .claude/ukit/index/impact-context.mjs --changed <file[,file]> [--symbol <name[,name]>] [--root <dir>]');
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+
37
+ await ensureFreshIndex(rootDir);
38
+
39
+ const impact = resolveImpactContext
40
+ ? await resolveImpactContext({ rootDir, changedFiles, changedSymbols })
41
+ : {
42
+ changedFiles,
43
+ changedSymbols,
44
+ riskLabels: [],
45
+ callees: [],
46
+ callers: [],
47
+ importers: [],
48
+ mirrorCounterparts: [],
49
+ relatedTests: [],
50
+ recommendedTestFiles: [],
51
+ };
52
+
53
+ printCompact(impact);
54
+
55
+ if (formatImpactConfidenceSummary) {
56
+ console.log('');
57
+ console.log(formatImpactConfidenceSummary(impact));
58
+ }
59
+ }
60
+
61
+ async function ensureFreshIndex(rootDir) {
62
+ const generatedAtMs = await getIndexArtifactGeneratedAt({ rootDir });
63
+ const stale = generatedAtMs === null
64
+ ? true
65
+ : await isIndexStale({
66
+ rootDir,
67
+ maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS,
68
+ now: Date.now(),
69
+ generatedAtMs,
70
+ });
71
+
72
+ if (stale) {
73
+ await buildCodeIndex({ rootDir });
74
+ }
75
+ }
76
+
77
+ function printCompact(impact) {
78
+ const changed = (impact.changedFiles ?? []).slice(0, 5).join(', ');
79
+ const symbols = (impact.changedSymbols ?? []).slice(0, 5).join(', ');
80
+ const risk = (impact.riskLabels ?? []).slice(0, 5).join(', ');
81
+ const callees = unique((impact.callees ?? []).map((entry) => entry?.symbol).filter(Boolean)).slice(0, CALLEE_CAP).join(', ');
82
+ const callers = unique((impact.callers ?? []).map((entry) => entry?.filePath).filter(Boolean)).slice(0, CALLER_CAP).join(', ');
83
+ const mirrors = unique((impact.mirrorCounterparts ?? []).map((entry) => entry?.filePath).filter(Boolean)).slice(0, MIRROR_CAP).join(', ');
84
+ const tests = (impact.recommendedTestFiles ?? impact.relatedTests ?? []).slice(0, TEST_CAP);
85
+ const verify = tests.slice(0, VERIFY_CAP).map((testFile) => `yarn test ${testFile}`).join(' | ');
86
+
87
+ console.log('[ukit:impact]');
88
+ console.log(`changed: ${changed || '(none)'}`);
89
+ if (symbols) console.log(`symbols: ${symbols}`);
90
+ if (risk) console.log(`risk: ${risk}`);
91
+ if (callees) console.log(`callees: ${callees}`);
92
+ if (callers) console.log(`callers: ${callers}`);
93
+ if (mirrors) console.log(`mirrors: ${mirrors}`);
94
+ if (tests.length > 0) console.log(`related-tests: ${tests.join(', ')}`);
95
+ if (verify) console.log(`verify: ${verify}`);
96
+ }
97
+
98
+ function parseCsv(value) {
99
+ return unique(
100
+ String(value ?? '')
101
+ .split(',')
102
+ .map((entry) => entry.trim().replace(/^\.\//, ''))
103
+ .filter(Boolean),
104
+ );
105
+ }
106
+
107
+ function unique(values = []) {
108
+ return [...new Set(values)];
109
+ }
110
+
111
+ function readFlagValue(args, flag) {
112
+ const index = args.indexOf(flag);
113
+ if (index === -1) return null;
114
+ return args[index + 1] ?? null;
115
+ }
116
+
117
+ function getRootDir(args) {
118
+ const explicitRoot = readFlagValue(args, '--root');
119
+ return path.resolve(explicitRoot || process.cwd());
120
+ }
121
+
122
+ await main();