@ngockhoale/ukit 1.2.1 → 1.3.0

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 (30) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +7 -5
  3. package/manifests/platform.full.yaml +127 -13
  4. package/package.json +1 -1
  5. package/src/core/runInstallPipeline.js +6 -1
  6. package/src/core/runtimeConfig.js +29 -112
  7. package/src/index/impactCatalog.js +12 -0
  8. package/src/index/taskRouting.js +3 -0
  9. package/templates/.claude/agents/ukit-small-task-maintainer.md +5 -5
  10. package/templates/.claude/hooks/post-edit-verify.sh +13 -0
  11. package/templates/.claude/hooks/pre-edit-backup.sh +13 -0
  12. package/templates/.claude/hooks/stale-spec-guard.sh +13 -0
  13. package/templates/.claude/settings.json +20 -0
  14. package/templates/.claude/ukit/index/anchor-search.mjs +99 -0
  15. package/templates/.claude/ukit/index/lib/index-core.mjs +3 -0
  16. package/templates/.claude/ukit/index/post-edit-verify.mjs +206 -0
  17. package/templates/.claude/ukit/index/pre-edit-backup.mjs +84 -0
  18. package/templates/.claude/ukit/index/route-task.mjs +6 -0
  19. package/templates/.claude/ukit/index/safe-patch.mjs +97 -0
  20. package/templates/.claude/ukit/index/stale-spec-check.mjs +192 -0
  21. package/templates/.claude/ukit/runtime/safe-patch-core.mjs +140 -0
  22. package/templates/.claude/ukit/runtime/text-profile.mjs +139 -0
  23. package/templates/.codex/README.md +4 -3
  24. package/templates/.codex/settings.json +10 -11
  25. package/templates/.gitignore +0 -1
  26. package/templates/AGENTS.md +11 -4
  27. package/templates/CLAUDE.md +12 -4
  28. package/templates/ukit/README.md +1 -1
  29. package/templates/ukit/storage/config.json +153 -4
  30. package/templates/.claude/ukit/.env.example +0 -17
@@ -0,0 +1,99 @@
1
+ import { analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
2
+ import { buildLineWindow, countOccurrences, lineNumberForIndex, resolveProjectFile, summarizeSnippet } from '../runtime/safe-patch-core.mjs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import path from 'node:path';
5
+
6
+ function parseArgs(argv = process.argv.slice(2)) {
7
+ const args = { json: false, contextLines: 3 };
8
+ for (let i = 0; i < argv.length; i += 1) {
9
+ const arg = argv[i];
10
+ if (arg === '--json') args.json = true;
11
+ else if (arg === '--file') args.file = argv[++i];
12
+ else if (arg === '--query' || arg === '--anchor') args.query = argv[++i];
13
+ else if (arg === '--context-lines') args.contextLines = Number(argv[++i]);
14
+ else if (arg === '--start-line') args.startLine = Number(argv[++i]);
15
+ else if (arg === '--end-line') args.endLine = Number(argv[++i]);
16
+ else if (arg === '--help' || arg === '-h') args.help = true;
17
+ }
18
+ return args;
19
+ }
20
+
21
+ function usage() {
22
+ return 'Usage: node .claude/ukit/index/anchor-search.mjs --file <path> --query <anchor> [--json] [--context-lines N]';
23
+ }
24
+
25
+ export async function searchAnchor({ projectRoot = process.cwd(), filePath, query, contextLines = 3, startLine = null, endLine = null } = {}) {
26
+ const resolved = resolveProjectFile(projectRoot, filePath);
27
+ if (!resolved) throw new Error('Missing --file.');
28
+ if (!query) throw new Error('Missing --query.');
29
+
30
+ const profile = await analyzeTextFile(resolved.absolute);
31
+ if (profile.binaryLike || !profile.utf8Valid) {
32
+ return {
33
+ status: 'non-text',
34
+ file: resolved.relative,
35
+ query,
36
+ count: 0,
37
+ matches: [],
38
+ profile: publicTextProfile(profile),
39
+ };
40
+ }
41
+
42
+ const occurrence = countOccurrences(profile.text, query);
43
+ let matches = occurrence.indexes.map((index) => {
44
+ const line = lineNumberForIndex(profile.text, index);
45
+ const window = buildLineWindow(profile.text, line, 0);
46
+ return { index, line, snippet: summarizeSnippet(window.text, 160) };
47
+ });
48
+
49
+ if (Number.isFinite(startLine) || Number.isFinite(endLine)) {
50
+ const minLine = Number.isFinite(startLine) ? startLine : 1;
51
+ const maxLine = Number.isFinite(endLine) ? endLine : Number.MAX_SAFE_INTEGER;
52
+ matches = matches.filter((match) => match.line >= minLine && match.line <= maxLine);
53
+ }
54
+
55
+ const count = matches.length;
56
+ const status = count === 1 ? 'unique' : count === 0 ? 'not-found' : 'ambiguous';
57
+ const firstLine = matches[0]?.line ?? 1;
58
+ return {
59
+ status,
60
+ file: resolved.relative,
61
+ query,
62
+ count,
63
+ matches,
64
+ ...(count === 1 ? { window: buildLineWindow(profile.text, firstLine, Number.isFinite(contextLines) ? contextLines : 3) } : {}),
65
+ profile: publicTextProfile(profile),
66
+ };
67
+ }
68
+
69
+ async function main() {
70
+ const args = parseArgs();
71
+ if (args.help || !args.file || !args.query) {
72
+ const help = usage();
73
+ process.stdout.write(args.json ? `${JSON.stringify({ help })}\n` : `${help}\n`);
74
+ return;
75
+ }
76
+ const result = await searchAnchor({
77
+ projectRoot: process.env.CLAUDE_PROJECT_DIR || process.cwd(),
78
+ filePath: args.file,
79
+ query: args.query,
80
+ contextLines: args.contextLines,
81
+ startLine: args.startLine,
82
+ endLine: args.endLine,
83
+ });
84
+ if (args.json) {
85
+ process.stdout.write(`${JSON.stringify(result)}\n`);
86
+ } else {
87
+ process.stdout.write(`[ukit:anchor] status=${result.status} count=${result.count} file=${result.file} query=${JSON.stringify(result.query)}\n`);
88
+ for (const match of result.matches.slice(0, 5)) {
89
+ process.stdout.write(`- line ${match.line}: ${match.snippet}\n`);
90
+ }
91
+ }
92
+ }
93
+
94
+ if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.basename(process.argv[1])) {
95
+ main().catch((error) => {
96
+ process.stderr.write(`[ukit:anchor] ERROR: ${error?.message || error}\n`);
97
+ process.exit(1);
98
+ });
99
+ }
@@ -2828,6 +2828,9 @@ async function detectPackageManager(rootDir) {
2828
2828
  }
2829
2829
 
2830
2830
  const IMPACT_SHARED_PATTERNS = [
2831
+ { regex: /^\.claude\/hooks\//, label: 'installed-hook-runtime' },
2832
+ { regex: /^\.claude\/ukit\//, label: 'installed-helper-runtime' },
2833
+ { regex: /^\.codex\//, label: 'installed-codex-runtime' },
2831
2834
  { regex: /^src\/index\//, label: 'shared-index-runtime' },
2832
2835
  {
2833
2836
  regex: /^src\/core\/(runInstallPipeline|applyPlan|buildPlan|diffPlan|metadata|migrateLegacy|uninstall|runtimeConfig|runtimePaths)\.js$/,
@@ -0,0 +1,206 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { analyzeTextBuffer, analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
5
+ import { classifySafePatchRisk, parseJsonInput, pathExists, readRuntimeSafePatchConfig, resolveProjectFile } from '../runtime/safe-patch-core.mjs';
6
+
7
+ async function readStdin() {
8
+ return await new Promise((resolve) => {
9
+ let raw = '';
10
+ process.stdin.setEncoding('utf8');
11
+ process.stdin.on('data', (chunk) => { raw += chunk; });
12
+ process.stdin.on('end', () => resolve(raw));
13
+ });
14
+ }
15
+
16
+ function getFilePath(payload = {}) {
17
+ return payload.tool_input?.file_path || payload.file_path || '';
18
+ }
19
+
20
+ async function listManifestPaths(backupsRoot) {
21
+ const dates = await fs.readdir(backupsRoot, { withFileTypes: true }).catch(() => []);
22
+ return dates
23
+ .filter((entry) => entry.isDirectory())
24
+ .map((entry) => path.join(backupsRoot, entry.name, 'manifest.jsonl'))
25
+ .sort()
26
+ .reverse();
27
+ }
28
+
29
+ async function readJsonl(filePath) {
30
+ const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
31
+ return raw
32
+ .split(/\n/)
33
+ .map((line) => line.trim())
34
+ .filter(Boolean)
35
+ .map((line) => {
36
+ try {
37
+ return JSON.parse(line);
38
+ } catch {
39
+ return null;
40
+ }
41
+ })
42
+ .filter(Boolean);
43
+ }
44
+
45
+ async function findLatestBackup(projectRoot, relativePath) {
46
+ const backupsRoot = path.join(projectRoot, '.ukit', 'storage', 'backups');
47
+ for (const manifestPath of await listManifestPaths(backupsRoot)) {
48
+ const entries = await readJsonl(manifestPath);
49
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
50
+ const entry = entries[index];
51
+ if (entry.file === relativePath && entry.rollbackPath && !entry.afterHash) {
52
+ return { entry, manifestPath };
53
+ }
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function diffLineStats(beforeText, afterText, { maxCells = 2_000_000 } = {}) {
60
+ const before = String(beforeText ?? '').split(/\n/);
61
+ const after = String(afterText ?? '').split(/\n/);
62
+ const cellCount = (before.length + 1) * (after.length + 1);
63
+ if (cellCount > Number(maxCells || 2_000_000)) {
64
+ return boundedDiffLineStats(before, after, cellCount);
65
+ }
66
+
67
+ const rows = before.length + 1;
68
+ const cols = after.length + 1;
69
+ const dp = Array.from({ length: rows }, () => Array(cols).fill(0));
70
+
71
+ for (let i = before.length - 1; i >= 0; i -= 1) {
72
+ for (let j = after.length - 1; j >= 0; j -= 1) {
73
+ dp[i][j] = before[i] === after[j]
74
+ ? dp[i + 1][j + 1] + 1
75
+ : Math.max(dp[i + 1][j], dp[i][j + 1]);
76
+ }
77
+ }
78
+
79
+ let i = 0;
80
+ let j = 0;
81
+ let changedLines = 0;
82
+ let hunkCount = 0;
83
+ let inHunk = false;
84
+ while (i < before.length || j < after.length) {
85
+ if (i < before.length && j < after.length && before[i] === after[j]) {
86
+ inHunk = false;
87
+ i += 1;
88
+ j += 1;
89
+ } else if (j < after.length && (i === before.length || dp[i][j + 1] >= dp[i + 1]?.[j])) {
90
+ changedLines += 1;
91
+ if (!inHunk) hunkCount += 1;
92
+ inHunk = true;
93
+ j += 1;
94
+ } else if (i < before.length) {
95
+ changedLines += 1;
96
+ if (!inHunk) hunkCount += 1;
97
+ inHunk = true;
98
+ i += 1;
99
+ }
100
+ }
101
+
102
+ return { changedLines, hunkCount, algorithm: 'lcs', cellCount };
103
+ }
104
+
105
+ function boundedDiffLineStats(before, after, cellCount) {
106
+ let start = 0;
107
+ while (start < before.length && start < after.length && before[start] === after[start]) {
108
+ start += 1;
109
+ }
110
+
111
+ let beforeEnd = before.length - 1;
112
+ let afterEnd = after.length - 1;
113
+ while (beforeEnd >= start && afterEnd >= start && before[beforeEnd] === after[afterEnd]) {
114
+ beforeEnd -= 1;
115
+ afterEnd -= 1;
116
+ }
117
+
118
+ const removed = Math.max(0, beforeEnd - start + 1);
119
+ const added = Math.max(0, afterEnd - start + 1);
120
+ const changedLines = removed + added;
121
+ return {
122
+ changedLines,
123
+ hunkCount: changedLines > 0 ? 1 : 0,
124
+ algorithm: 'bounded-fallback',
125
+ cellCount,
126
+ };
127
+ }
128
+
129
+ async function appendPostEditEntry(manifestPath, entry) {
130
+ await fs.appendFile(manifestPath, `${JSON.stringify(entry)}\n`, 'utf8');
131
+ }
132
+
133
+ export async function verifyPostEdit({ projectRoot = process.cwd(), payload = {} } = {}) {
134
+ const config = await readRuntimeSafePatchConfig(projectRoot);
135
+ if (config.enabled === false || config.backupEnabled === false) return { status: 'disabled' };
136
+ const filePath = getFilePath(payload);
137
+ if (!filePath) return { status: 'skipped', reason: 'no-file' };
138
+ const resolved = resolveProjectFile(projectRoot, filePath);
139
+ if (!(await pathExists(resolved.absolute))) return { status: 'skipped', reason: 'missing-after', file: resolved.relative };
140
+
141
+ const latest = await findLatestBackup(projectRoot, resolved.relative);
142
+ if (!latest) return { status: 'skipped', reason: 'no-backup', file: resolved.relative };
143
+
144
+ const rollbackPath = path.resolve(projectRoot, latest.entry.rollbackPath);
145
+ const beforeBuffer = await fs.readFile(rollbackPath);
146
+ const beforeProfile = analyzeTextBuffer(beforeBuffer);
147
+ const afterProfile = await analyzeTextFile(resolved.absolute);
148
+ const risk = classifySafePatchRisk(resolved.relative, afterProfile, config);
149
+ const delta = beforeProfile.utf8Valid && afterProfile.utf8Valid
150
+ ? diffLineStats(beforeProfile.text, afterProfile.text, { maxCells: Number(config.deltaMaxDiffCells || 2_000_000) })
151
+ : { changedLines: Number.POSITIVE_INFINITY, hunkCount: Number.POSITIVE_INFINITY };
152
+
153
+ const overChangedLines = delta.changedLines > Number(config.deltaMaxChangedLines || 120);
154
+ const overHunks = delta.hunkCount > Number(config.deltaMaxHunks || 3);
155
+ const status = risk.strict && (overChangedLines || overHunks) ? 'blocked' : 'ok';
156
+ const postEntry = {
157
+ event: 'post-edit',
158
+ file: resolved.relative,
159
+ beforeHash: beforeProfile.sha256,
160
+ afterHash: afterProfile.sha256,
161
+ timestamp: new Date().toISOString(),
162
+ delta,
163
+ riskLabels: risk.labels,
164
+ profile: publicTextProfile(afterProfile),
165
+ rollbackPath: latest.entry.rollbackPath,
166
+ status,
167
+ };
168
+ await appendPostEditEntry(latest.manifestPath, postEntry);
169
+
170
+ return {
171
+ ...postEntry,
172
+ message: status === 'blocked'
173
+ ? `BLOCKED: '${resolved.relative}' exceeded Safe Patch delta budget (changedLines=${delta.changedLines}/${config.deltaMaxChangedLines}, hunks=${delta.hunkCount}/${config.deltaMaxHunks}). Review diff or restore from ${latest.entry.rollbackPath}.`
174
+ : `OK: '${resolved.relative}' delta changedLines=${delta.changedLines}, hunks=${delta.hunkCount}.`,
175
+ };
176
+ }
177
+
178
+ async function main() {
179
+ const json = process.argv.includes('--json');
180
+ const help = process.argv.includes('--help') || process.argv.includes('-h');
181
+ if (help) {
182
+ const text = 'Usage: post-edit-verify.mjs < Claude hook JSON on stdin';
183
+ process.stdout.write(json ? `${JSON.stringify({ help: text })}\n` : `${text}\n`);
184
+ return;
185
+ }
186
+ const result = await verifyPostEdit({
187
+ projectRoot: process.env.CLAUDE_PROJECT_DIR || process.cwd(),
188
+ payload: parseJsonInput(await readStdin(), {}),
189
+ });
190
+ if (json) {
191
+ process.stdout.write(`${JSON.stringify(result)}\n`);
192
+ } else if (result.status === 'ok') {
193
+ process.stdout.write(`[ukit-safe-patch] ${result.message}\n`);
194
+ }
195
+ if (result.status === 'blocked') {
196
+ process.stderr.write(`[ukit-safe-patch] ${result.message}\n`);
197
+ process.exit(2);
198
+ }
199
+ }
200
+
201
+ if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.basename(process.argv[1])) {
202
+ main().catch((error) => {
203
+ process.stderr.write(`[ukit-safe-patch] post-edit ERROR: ${error?.message || error}\n`);
204
+ process.exit(1);
205
+ });
206
+ }
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
4
+ import { classifySafePatchRisk, parseJsonInput, pathExists, readRuntimeSafePatchConfig, resolveProjectFile } from '../runtime/safe-patch-core.mjs';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ async function readStdin() {
8
+ return await new Promise((resolve) => {
9
+ let raw = '';
10
+ process.stdin.setEncoding('utf8');
11
+ process.stdin.on('data', (chunk) => { raw += chunk; });
12
+ process.stdin.on('end', () => resolve(raw));
13
+ });
14
+ }
15
+
16
+ function getFilePath(payload = {}) {
17
+ return payload.tool_input?.file_path || payload.file_path || '';
18
+ }
19
+
20
+ function getToolName(payload = {}) {
21
+ return String(payload.tool_name || payload.tool || payload.name || '').trim() || 'Edit/Write';
22
+ }
23
+
24
+ async function pruneOldBackups(projectRoot, config) {
25
+ const retentionDays = Number(config.backupRetentionDays || 30);
26
+ if (!Number.isFinite(retentionDays) || retentionDays <= 0) return;
27
+ const backupsRoot = path.join(projectRoot, '.ukit', 'storage', 'backups');
28
+ const entries = await fs.readdir(backupsRoot, { withFileTypes: true }).catch(() => []);
29
+ const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
30
+ await Promise.all(entries.map(async (entry) => {
31
+ if (!entry.isDirectory() || !/^\d{4}-\d{2}-\d{2}$/.test(entry.name)) return;
32
+ const backupTime = Date.parse(`${entry.name}T00:00:00.000Z`);
33
+ if (!Number.isFinite(backupTime) || backupTime >= cutoffMs) return;
34
+ await fs.rm(path.join(backupsRoot, entry.name), { recursive: true, force: true });
35
+ }));
36
+ }
37
+
38
+ async function backupIfNeeded({ projectRoot, payload }) {
39
+ const config = await readRuntimeSafePatchConfig(projectRoot);
40
+ if (config.enabled === false || config.backupEnabled === false) return { status: 'disabled' };
41
+ await pruneOldBackups(projectRoot, config);
42
+ const filePath = getFilePath(payload);
43
+ if (!filePath) return { status: 'skipped', reason: 'no-file' };
44
+ const resolved = resolveProjectFile(projectRoot, filePath);
45
+ if (!(await pathExists(resolved.absolute))) return { status: 'skipped', reason: 'new-file', file: resolved.relative };
46
+ const profile = await analyzeTextFile(resolved.absolute);
47
+ const risk = classifySafePatchRisk(resolved.relative, profile, config);
48
+ if (!risk.strict) return { status: 'skipped', reason: 'low-risk', file: resolved.relative };
49
+
50
+ const session = new Date().toISOString().slice(0, 10);
51
+ const backupRoot = path.join(projectRoot, '.ukit', 'storage', 'backups', session);
52
+ const filesDir = path.join(backupRoot, 'files');
53
+ await fs.mkdir(filesDir, { recursive: true });
54
+ const backupName = `${profile.sha256}-before`;
55
+ const rollbackPath = path.join(filesDir, backupName);
56
+ await fs.copyFile(resolved.absolute, rollbackPath);
57
+ const manifestPath = path.join(backupRoot, 'manifest.jsonl');
58
+ const entry = {
59
+ file: resolved.relative,
60
+ beforeHash: profile.sha256,
61
+ profile: publicTextProfile(profile),
62
+ tool: getToolName(payload),
63
+ timestamp: new Date().toISOString(),
64
+ riskLabels: risk.labels,
65
+ rollbackPath: path.relative(projectRoot, rollbackPath).replace(/\\/g, '/'),
66
+ };
67
+ await fs.appendFile(manifestPath, `${JSON.stringify(entry)}\n`, 'utf8');
68
+ return { status: 'backed-up', ...entry };
69
+ }
70
+
71
+ async function main() {
72
+ const payload = parseJsonInput(await readStdin(), {});
73
+ const result = await backupIfNeeded({ projectRoot: process.env.CLAUDE_PROJECT_DIR || process.cwd(), payload });
74
+ if (result.status === 'backed-up') {
75
+ process.stdout.write(`[ukit-safe-patch] backup=${result.rollbackPath} file=${result.file}\n`);
76
+ }
77
+ }
78
+
79
+ if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.basename(process.argv[1])) {
80
+ main().catch((error) => {
81
+ process.stderr.write(`[ukit-safe-patch] backup ERROR: ${error?.message || error}\n`);
82
+ process.exit(1);
83
+ });
84
+ }
@@ -1196,6 +1196,7 @@ function buildRouteSummary({
1196
1196
  ?? [...primaryCommands, ...fallbackCommands],
1197
1197
  );
1198
1198
  const policyMode = verificationRecommendation?.executionPolicy?.policyMode ?? null;
1199
+ const editGuardHint = isSharedImpactFile(routingContext.targetFile) ? 'anchor-required' : null;
1199
1200
  const compactHelperLane = nextAction?.type === 'pull-indexed-context'
1200
1201
  && typeof contextRecommendation?.command === 'string'
1201
1202
  && contextRecommendation.command.trim();
@@ -1214,6 +1215,7 @@ function buildRouteSummary({
1214
1215
  formatCompactSegment('targets', primaryTargets),
1215
1216
  formatCompactSegment('tests', relatedTests),
1216
1217
  formatCompactSegment('styles', styleFiles),
1218
+ editGuardHint ? `editGuard=${editGuardHint}` : null,
1217
1219
  delegationRecommendation?.hint ? `delegate=${delegationRecommendation.hint}` : null,
1218
1220
  policyMode ? `policy=${policyMode}` : null,
1219
1221
  ].filter(Boolean).join(' | ');
@@ -1223,6 +1225,7 @@ function buildRouteSummary({
1223
1225
  fallbackCommands,
1224
1226
  preferredOrder,
1225
1227
  policyMode,
1228
+ editGuardHint,
1226
1229
  intentMode: routingContext.intentMode ?? null,
1227
1230
  delegateHint: delegationRecommendation?.hint ?? null,
1228
1231
  nextActionType: nextAction?.type ?? null,
@@ -1336,6 +1339,9 @@ function buildHelperCommand({ commandNamespace = '.claude', scriptName, intent =
1336
1339
  }
1337
1340
 
1338
1341
  const SHARED_IMPACT_PATTERNS = [
1342
+ /^\.claude\/hooks\//,
1343
+ /^\.claude\/ukit\//,
1344
+ /^\.codex\//,
1339
1345
  /^src\/index\//,
1340
1346
  /^src\/core\/(runInstallPipeline|applyPlan|buildPlan|diffPlan|metadata|migrateLegacy|uninstall|runtimeConfig|runtimePaths)\.js$/,
1341
1347
  /^src\/core\/(output|token|compact)\//,
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs/promises';
2
+ import { analyzeTextFile, normalizeNewlinesForProfile, publicTextProfile, writeTextWithProfile } from '../runtime/text-profile.mjs';
3
+ import { buildLineWindow, countOccurrences, lineNumberForIndex, resolveProjectFile } from '../runtime/safe-patch-core.mjs';
4
+ import { searchAnchor } from './anchor-search.mjs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import path from 'node:path';
7
+
8
+ function parseArgs(argv = process.argv.slice(2)) {
9
+ const args = { json: false, contextLines: 8 };
10
+ for (let i = 0; i < argv.length; i += 1) {
11
+ const arg = argv[i];
12
+ if (arg === '--json') args.json = true;
13
+ else if (arg === '--file') args.file = argv[++i];
14
+ else if (arg === '--anchor') args.anchor = argv[++i];
15
+ else if (arg === '--old') args.oldString = argv[++i];
16
+ else if (arg === '--new') args.newString = argv[++i];
17
+ else if (arg === '--context-lines') args.contextLines = Number(argv[++i]);
18
+ else if (arg === '--help' || arg === '-h') args.help = true;
19
+ }
20
+ return args;
21
+ }
22
+
23
+ function usage() {
24
+ return 'Usage: node .claude/ukit/index/safe-patch.mjs --file <path> --anchor <unique-anchor> --old <text> --new <text> [--json]';
25
+ }
26
+
27
+ export async function applySafePatch({ projectRoot = process.cwd(), filePath, anchor, oldString, newString, contextLines = 8 } = {}) {
28
+ const resolved = resolveProjectFile(projectRoot, filePath);
29
+ if (!resolved) throw new Error('Missing --file.');
30
+ if (!anchor) throw new Error('Missing --anchor.');
31
+ if (!oldString) throw new Error('Missing --old.');
32
+
33
+ const anchorResult = await searchAnchor({ projectRoot, filePath, query: anchor, contextLines });
34
+ if (anchorResult.status !== 'unique') {
35
+ throw new Error(`Anchor is ${anchorResult.status}; expected unique anchor before patching.`);
36
+ }
37
+
38
+ const beforeProfile = await analyzeTextFile(resolved.absolute);
39
+ if (beforeProfile.binaryLike || !beforeProfile.utf8Valid) {
40
+ throw new Error('Target is not strict UTF-8 text; refusing safe patch.');
41
+ }
42
+
43
+ const oldForProfile = normalizeNewlinesForProfile(oldString, beforeProfile);
44
+ const newForProfile = normalizeNewlinesForProfile(newString ?? '', beforeProfile);
45
+ const occurrence = countOccurrences(beforeProfile.text, oldForProfile);
46
+ if (occurrence.count !== 1) {
47
+ throw new Error(`Patch old text occurrence is ${occurrence.count}; expected exactly 1.`);
48
+ }
49
+
50
+ const anchorLine = anchorResult.matches[0].line;
51
+ const oldLine = lineNumberForIndex(beforeProfile.text, occurrence.indexes[0]);
52
+ const window = buildLineWindow(beforeProfile.text, anchorLine, Number.isFinite(contextLines) ? contextLines : 8);
53
+ if (oldLine < window.startLine || oldLine > window.endLine) {
54
+ throw new Error('Patch old text is outside the bounded anchor window.');
55
+ }
56
+
57
+ const nextText = beforeProfile.text.replace(oldForProfile, newForProfile);
58
+ await writeTextWithProfile(resolved.absolute, nextText, beforeProfile);
59
+ const afterProfile = await analyzeTextFile(resolved.absolute);
60
+
61
+ return {
62
+ status: 'patched',
63
+ file: resolved.relative,
64
+ anchor,
65
+ before: { profile: publicTextProfile(beforeProfile) },
66
+ after: { profile: publicTextProfile(afterProfile) },
67
+ };
68
+ }
69
+
70
+ async function main() {
71
+ const args = parseArgs();
72
+ if (args.help || !args.file || !args.anchor || !args.oldString) {
73
+ const help = usage();
74
+ process.stdout.write(args.json ? `${JSON.stringify({ help })}\n` : `${help}\n`);
75
+ return;
76
+ }
77
+ const result = await applySafePatch({
78
+ projectRoot: process.env.CLAUDE_PROJECT_DIR || process.cwd(),
79
+ filePath: args.file,
80
+ anchor: args.anchor,
81
+ oldString: args.oldString,
82
+ newString: args.newString ?? '',
83
+ contextLines: args.contextLines,
84
+ });
85
+ if (args.json) {
86
+ process.stdout.write(`${JSON.stringify(result)}\n`);
87
+ } else {
88
+ process.stdout.write(`[ukit:safe-patch] patched ${result.file} anchor=${JSON.stringify(result.anchor)} newline=${result.after.profile.newline} bom=${result.after.profile.hasBom ? 'yes' : 'no'}\n`);
89
+ }
90
+ }
91
+
92
+ if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.basename(process.argv[1])) {
93
+ main().catch((error) => {
94
+ process.stderr.write(`[ukit:safe-patch] ERROR: ${error?.message || error}\n`);
95
+ process.exit(1);
96
+ });
97
+ }