@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.
- package/CHANGELOG.md +27 -1
- package/README.md +7 -5
- package/manifests/platform.full.yaml +127 -13
- package/package.json +1 -1
- package/src/core/runInstallPipeline.js +6 -1
- package/src/core/runtimeConfig.js +29 -112
- package/src/index/impactCatalog.js +12 -0
- package/src/index/taskRouting.js +3 -0
- package/templates/.claude/agents/ukit-small-task-maintainer.md +5 -5
- 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 +4 -3
- package/templates/.codex/settings.json +10 -11
- package/templates/.gitignore +0 -1
- package/templates/AGENTS.md +11 -4
- package/templates/CLAUDE.md +12 -4
- package/templates/ukit/README.md +1 -1
- package/templates/ukit/storage/config.json +153 -4
- 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
|
+
}
|