@ngockhoale/ukit 1.2.2 → 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.
- package/CHANGELOG.md +19 -0
- package/README.md +5 -3
- package/manifests/platform.full.yaml +126 -0
- package/package.json +1 -1
- package/src/core/runtimeConfig.js +26 -1
- package/src/index/impactCatalog.js +12 -0
- package/src/index/taskRouting.js +3 -0
- package/templates/.claude/hooks/post-edit-verify.sh +13 -0
- package/templates/.claude/hooks/pre-edit-backup.sh +13 -0
- package/templates/.claude/hooks/stale-spec-guard.sh +13 -0
- package/templates/.claude/settings.json +20 -0
- package/templates/.claude/ukit/index/anchor-search.mjs +99 -0
- package/templates/.claude/ukit/index/lib/index-core.mjs +3 -0
- package/templates/.claude/ukit/index/post-edit-verify.mjs +206 -0
- package/templates/.claude/ukit/index/pre-edit-backup.mjs +84 -0
- package/templates/.claude/ukit/index/route-task.mjs +6 -0
- package/templates/.claude/ukit/index/safe-patch.mjs +97 -0
- package/templates/.claude/ukit/index/stale-spec-check.mjs +192 -0
- package/templates/.claude/ukit/runtime/safe-patch-core.mjs +140 -0
- package/templates/.claude/ukit/runtime/text-profile.mjs +139 -0
- package/templates/.codex/README.md +3 -2
- package/templates/AGENTS.md +9 -2
- package/templates/CLAUDE.md +10 -2
- package/templates/ukit/README.md +1 -1
- package/templates/ukit/storage/config.json +23 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
classifySafePatchRisk,
|
|
6
|
+
countOccurrences,
|
|
7
|
+
lineNumberForIndex,
|
|
8
|
+
parseJsonInput,
|
|
9
|
+
pathExists,
|
|
10
|
+
readRuntimeSafePatchConfig,
|
|
11
|
+
resolveProjectFile,
|
|
12
|
+
summarizeSnippet,
|
|
13
|
+
} from '../runtime/safe-patch-core.mjs';
|
|
14
|
+
|
|
15
|
+
function getToolName(payload = {}) {
|
|
16
|
+
return String(payload.tool_name || payload.tool || payload.name || '').trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getFilePath(payload = {}) {
|
|
20
|
+
return payload.tool_input?.file_path || payload.file_path || '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getOldString(payload = {}) {
|
|
24
|
+
return payload.tool_input?.old_string ?? payload.old_string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatRisk(risk) {
|
|
28
|
+
return risk.labels.length > 0 ? risk.labels.join(',') : 'low';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function checkStaleSpec({ projectRoot = process.cwd(), payload = {} } = {}) {
|
|
32
|
+
const config = await readRuntimeSafePatchConfig(projectRoot);
|
|
33
|
+
if (config.enabled === false) {
|
|
34
|
+
return { status: 'disabled' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const toolName = getToolName(payload);
|
|
38
|
+
const filePath = getFilePath(payload);
|
|
39
|
+
if (!filePath) return { status: 'skipped', reason: 'no-file' };
|
|
40
|
+
const resolved = resolveProjectFile(projectRoot, filePath);
|
|
41
|
+
const exists = await pathExists(resolved.absolute);
|
|
42
|
+
|
|
43
|
+
let profile = null;
|
|
44
|
+
let risk = classifySafePatchRisk(resolved.relative, null, config);
|
|
45
|
+
if (exists) {
|
|
46
|
+
profile = await analyzeTextFile(resolved.absolute);
|
|
47
|
+
risk = classifySafePatchRisk(resolved.relative, profile, config);
|
|
48
|
+
}
|
|
49
|
+
const strict = Boolean(config.strictSharedRisk) && risk.strict;
|
|
50
|
+
|
|
51
|
+
if (profile?.shebangHasBom && strict) {
|
|
52
|
+
return {
|
|
53
|
+
status: 'blocked',
|
|
54
|
+
code: 'shebang-bom',
|
|
55
|
+
message: `BLOCKED: shebang file '${resolved.relative}' has a UTF-8 BOM before #!. Use safe-patch.mjs after removing/handling BOM intentionally.`,
|
|
56
|
+
file: resolved.relative,
|
|
57
|
+
risk,
|
|
58
|
+
profile: publicTextProfile(profile),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (profile && (profile.binaryLike || !profile.utf8Valid) && strict) {
|
|
63
|
+
return {
|
|
64
|
+
status: 'blocked',
|
|
65
|
+
code: 'non-text',
|
|
66
|
+
message: `BLOCKED: '${resolved.relative}' is not strict UTF-8 text. Refusing risky agent edit to avoid corruption.`,
|
|
67
|
+
file: resolved.relative,
|
|
68
|
+
risk,
|
|
69
|
+
profile: publicTextProfile(profile),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (/write/i.test(toolName) && exists && strict) {
|
|
74
|
+
return {
|
|
75
|
+
status: 'blocked',
|
|
76
|
+
code: 'shared-risk-write',
|
|
77
|
+
message: `BLOCKED: whole-file Write to existing shared-risk file '${resolved.relative}'. Use anchored Edit or node .claude/ukit/index/safe-patch.mjs with a unique anchor.`,
|
|
78
|
+
file: resolved.relative,
|
|
79
|
+
risk,
|
|
80
|
+
profile: profile ? publicTextProfile(profile) : null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!/edit/i.test(toolName)) {
|
|
85
|
+
return { status: 'skipped', reason: 'not-edit', file: resolved.relative, risk };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const oldString = getOldString(payload);
|
|
89
|
+
if (!exists) {
|
|
90
|
+
return { status: 'skipped', reason: 'new-file', file: resolved.relative, risk };
|
|
91
|
+
}
|
|
92
|
+
if (typeof oldString !== 'string' || oldString.length === 0) {
|
|
93
|
+
if (strict) {
|
|
94
|
+
return {
|
|
95
|
+
status: 'blocked',
|
|
96
|
+
code: 'missing-old-string',
|
|
97
|
+
message: `BLOCKED: missing old_string for shared-risk edit '${resolved.relative}'. Use a unique current-file anchor and exact old_string.`,
|
|
98
|
+
file: resolved.relative,
|
|
99
|
+
risk,
|
|
100
|
+
profile: publicTextProfile(profile),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { status: 'warn', code: 'missing-old-string', file: resolved.relative, risk };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const occurrence = countOccurrences(profile.text, oldString);
|
|
107
|
+
if (occurrence.count === 1) {
|
|
108
|
+
return {
|
|
109
|
+
status: 'ok',
|
|
110
|
+
specStatus: 'exact',
|
|
111
|
+
file: resolved.relative,
|
|
112
|
+
risk,
|
|
113
|
+
profile: publicTextProfile(profile),
|
|
114
|
+
line: lineNumberForIndex(profile.text, occurrence.indexes[0]),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const code = occurrence.count === 0 ? 'stale-spec' : 'ambiguous-spec';
|
|
119
|
+
const message = occurrence.count === 0
|
|
120
|
+
? `BLOCKED: stale spec for '${resolved.relative}' — old_string was not found in current file. Re-read current source, then choose Apply as-is / Adapt to current code / Skip.`
|
|
121
|
+
: `BLOCKED: ambiguous spec for '${resolved.relative}' — old_string matched ${occurrence.count} times. Use a unique anchor or narrower old_string.`;
|
|
122
|
+
|
|
123
|
+
if (strict) {
|
|
124
|
+
return {
|
|
125
|
+
status: 'blocked',
|
|
126
|
+
code,
|
|
127
|
+
message,
|
|
128
|
+
file: resolved.relative,
|
|
129
|
+
risk,
|
|
130
|
+
profile: publicTextProfile(profile),
|
|
131
|
+
matches: occurrence.indexes.slice(0, 5).map((index) => ({
|
|
132
|
+
line: lineNumberForIndex(profile.text, index),
|
|
133
|
+
snippet: summarizeSnippet(profile.text.slice(index, index + Math.min(oldString.length, 180))),
|
|
134
|
+
})),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
status: 'warn',
|
|
140
|
+
code,
|
|
141
|
+
message: message.replace(/^BLOCKED:/, 'WARNING:'),
|
|
142
|
+
file: resolved.relative,
|
|
143
|
+
risk,
|
|
144
|
+
profile: publicTextProfile(profile),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function readStdin() {
|
|
149
|
+
return await new Promise((resolve) => {
|
|
150
|
+
let raw = '';
|
|
151
|
+
process.stdin.setEncoding('utf8');
|
|
152
|
+
process.stdin.on('data', (chunk) => { raw += chunk; });
|
|
153
|
+
process.stdin.on('end', () => resolve(raw));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
158
|
+
return {
|
|
159
|
+
json: argv.includes('--json'),
|
|
160
|
+
help: argv.includes('--help') || argv.includes('-h'),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function main() {
|
|
165
|
+
const args = parseArgs();
|
|
166
|
+
if (args.help) {
|
|
167
|
+
const help = 'Usage: stale-spec-check.mjs < Claude hook JSON on stdin';
|
|
168
|
+
process.stdout.write(args.json ? `${JSON.stringify({ help })}\n` : `${help}\n`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const payload = parseJsonInput(await readStdin(), {});
|
|
172
|
+
const result = await checkStaleSpec({ projectRoot: process.env.CLAUDE_PROJECT_DIR || process.cwd(), payload });
|
|
173
|
+
if (args.json) {
|
|
174
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
175
|
+
} else if (result.status === 'ok') {
|
|
176
|
+
process.stdout.write(`[ukit-safe-patch] spec=exact file=${result.file} line=${result.line} risk=${formatRisk(result.risk)}\n`);
|
|
177
|
+
} else if (result.status === 'warn') {
|
|
178
|
+
process.stderr.write(`[ukit-safe-patch] ${result.message}\n`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (result.status === 'blocked') {
|
|
182
|
+
process.stderr.write(`[ukit-safe-patch] ${result.message}\n`);
|
|
183
|
+
process.exit(2);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.basename(process.argv[1])) {
|
|
188
|
+
main().catch((error) => {
|
|
189
|
+
process.stderr.write(`[ukit-safe-patch] ERROR: ${error?.message || error}\n`);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
});
|
|
192
|
+
}
|