@ngockhoale/ukit 3.4.1 → 3.4.2
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/package.json +1 -1
- package/src/cli/commands/code.js +29 -5
- package/src/cli/commands/decision.js +18 -4
- package/src/cli/commands/doctor.js +7 -3
- package/src/cli/commands/install.js +29 -4
- package/src/cli/commands/memory.js +25 -5
- package/src/cli/commands/telemetry.js +18 -1
- package/src/cli/commands/vm.js +7 -1
- package/src/context/detectProjectContext.js +7 -2
- package/src/core/agentRuntime/contract.js +5 -1
- package/src/core/agentRuntime/eventStore.js +54 -7
- package/src/core/agentRuntime/recovery.js +22 -15
- package/src/core/agentRuntime/supervisor.js +71 -13
- package/src/core/applyPlan.js +11 -1
- package/src/core/codeintel/compiler.js +51 -8
- package/src/core/codeintel/diagnostics.js +124 -33
- package/src/core/codeintel/freshness.js +25 -12
- package/src/core/codeintel/invalidation.js +11 -3
- package/src/core/codeintel/retriever.js +53 -20
- package/src/core/codeintel/router.js +19 -9
- package/src/core/codeintel/summaries.js +4 -3
- package/src/core/codeintel/vectorProvider.js +30 -4
- package/src/core/compact/index.js +24 -7
- package/src/core/compact/threshold.js +49 -14
- package/src/core/diffPlan.js +51 -23
- package/src/core/ensureGitignore.js +19 -2
- package/src/core/fileOps.js +61 -0
- package/src/core/memory/hygiene.js +51 -1
- package/src/core/memory/migrate.js +41 -21
- package/src/core/memory/store.js +96 -61
- package/src/core/metadata.js +37 -2
- package/src/core/observability/adapters/ingest.js +30 -2
- package/src/core/observability/emit/config.js +19 -4
- package/src/core/observability/emit/crash.js +3 -1
- package/src/core/observability/emit/recorder.js +15 -7
- package/src/core/observability/privacy/sanitizeObserved.js +3 -1
- package/src/core/observability/segments/internal.js +36 -8
- package/src/core/observability/segments/retention.js +11 -0
- package/src/core/observability/support/import.js +27 -1
- package/src/core/output/index.js +16 -1
- package/src/core/permissionDoctor.js +72 -9
- package/src/core/repairBrokenHooks.js +15 -2
- package/src/core/reviewPanelAggregate.js +26 -10
- package/src/core/runInstallPipeline.js +71 -22
- package/src/core/runtimeConfig.js +2 -0
- package/src/core/status.js +2 -0
- package/src/core/taskBudgetValidator.js +7 -1
- package/src/core/taskProgressGuard.js +11 -1
- package/src/core/unattendedDoctor.js +36 -5
- package/src/core/uninstall.js +52 -12
- package/src/core/update.js +5 -1
- package/src/decision/client.js +158 -27
- package/src/decision/reviewVerdict.js +23 -7
- package/src/diagnostics/failurePatterns.js +1 -1
- package/src/diagnostics/feedbackEvents.js +1 -1
- package/src/diagnostics/routeOutcomes.js +42 -4
- package/src/diagnostics/skillAccuracy.js +35 -4
- package/src/index/buildIndex.js +123 -26
- package/src/index/fixLoopEscalation.js +3 -0
- package/src/index/gitHooks.js +99 -29
- package/src/index/importResolution.js +7 -1
- package/src/index/playbookRegistry.js +15 -11
- package/src/index/queryIndex.js +28 -10
- package/src/index/routeResolver.js +8 -3
- package/src/index/taskRouting.js +37 -2
- package/src/learning/codeProposals.js +24 -5
- package/src/learning/selfImprove.js +29 -5
- package/src/learning/tunedOverlay.js +18 -7
- package/src/learning/tuning.js +10 -4
- package/src/skill/auditSkill.js +46 -7
- package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
- package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
- package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
- package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
- package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
- package/template_project/.claude/hooks/session-episode.sh +60 -28
- package/template_project/.claude/hooks/verification-guard.sh +26 -15
- package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
- package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
- package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
- package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
- package/template_project/.claude/ukit/index/query-index.mjs +13 -6
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
- package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
- package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
- package/template_project/.claude/ukit/index/route-task.mjs +15 -0
- package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
- package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
- package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
- package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
- package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
- package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
- package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
- package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
- package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
- package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
- package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
- package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
- package/template_project/.claude/ukit/runtime/token-utils.mjs +57 -14
|
@@ -5,7 +5,7 @@ import { createHash } from 'node:crypto';
|
|
|
5
5
|
import { withFileLock } from '../fileOps.js';
|
|
6
6
|
import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, normalizeRelative } from '../../index/paths.js';
|
|
7
7
|
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
8
|
-
import { createEdge, getSemanticProvider
|
|
8
|
+
import { createEdge, getSemanticProvider } from './providers.js';
|
|
9
9
|
import { createSemanticProvider } from './semanticProvider.js';
|
|
10
10
|
import { createEmbeddingProvider } from './vectorProvider.js';
|
|
11
11
|
|
|
@@ -14,6 +14,13 @@ import { createEmbeddingProvider } from './vectorProvider.js';
|
|
|
14
14
|
// codeIntel.retriever.weights). merge:'concat' preserves the exact C26
|
|
15
15
|
// sequential ordering as an escape hatch. A missing/failing lane is skipped
|
|
16
16
|
// and the reason lands in `omitted` — never fatal, never fabricated anchors.
|
|
17
|
+
// C92-F-04: `fileSet` is the path allow-list built from files.json, and every
|
|
18
|
+
// lane applies the same rule — a path is accepted iff fileSet.has(path). An
|
|
19
|
+
// UNREADABLE files.json (missing or schema-mismatched) makes the gate
|
|
20
|
+
// unavailable: retrieve() refuses and names the artifact in `omitted` instead
|
|
21
|
+
// of treating an empty allow-list as "allow everything" (fail closed, not
|
|
22
|
+
// fail open). A readable-but-empty files.json is a genuinely empty universe —
|
|
23
|
+
// also closed — which is distinguishable via the `omitted` entries.
|
|
17
24
|
|
|
18
25
|
const DEFAULT_LIMIT = 20;
|
|
19
26
|
const DEFAULT_BM25 = { k1: 1.2, b: 0.75 };
|
|
@@ -123,7 +130,7 @@ function symbolLane(symbolItems, normalizedQuery, fileSet) {
|
|
|
123
130
|
for (const sym of symbolItems) {
|
|
124
131
|
if (!sym?.name || !sym?.filePath || seen.has(sym.filePath)) continue;
|
|
125
132
|
if (sym.name === normalizedQuery || sym.name.toLowerCase() === normalizedQuery.toLowerCase()) {
|
|
126
|
-
if (fileSet.
|
|
133
|
+
if (fileSet.has(sym.filePath)) {
|
|
127
134
|
seen.add(sym.filePath);
|
|
128
135
|
lane.push(laneEntry(
|
|
129
136
|
sym.filePath,
|
|
@@ -136,7 +143,7 @@ function symbolLane(symbolItems, normalizedQuery, fileSet) {
|
|
|
136
143
|
return lane;
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
function bm25Lane(fileItems, symbolItems, queryTokens, bm25) {
|
|
146
|
+
function bm25Lane(fileItems, symbolItems, queryTokens, bm25, fileSet) {
|
|
140
147
|
if (queryTokens.length === 0) return null;
|
|
141
148
|
const docs = new Map();
|
|
142
149
|
for (const item of fileItems) {
|
|
@@ -144,7 +151,9 @@ function bm25Lane(fileItems, symbolItems, queryTokens, bm25) {
|
|
|
144
151
|
docs.set(item.filePath, tokenize(item.filePath));
|
|
145
152
|
}
|
|
146
153
|
for (const sym of symbolItems) {
|
|
147
|
-
|
|
154
|
+
// C92-F-04: symbol rows for paths outside the files.json universe must
|
|
155
|
+
// not create emit-table docs — the gate is fileSet.has(path) for all lanes.
|
|
156
|
+
if (!sym?.filePath || !fileSet.has(sym.filePath)) continue;
|
|
148
157
|
const doc = docs.get(sym.filePath) ?? [];
|
|
149
158
|
doc.push(...tokenize(sym.name));
|
|
150
159
|
docs.set(sym.filePath, doc);
|
|
@@ -189,7 +198,7 @@ async function semanticLane(rootDir, normalizedQuery, config, fileSet, snapshotI
|
|
|
189
198
|
if (!target) continue;
|
|
190
199
|
const rel = path.isAbsolute(target) ? normalizeRelative(rootDir, target) : target;
|
|
191
200
|
if (seen.has(rel)) continue;
|
|
192
|
-
if (
|
|
201
|
+
if (!fileSet.has(rel)) continue;
|
|
193
202
|
seen.add(rel);
|
|
194
203
|
lane.push(laneEntry(rel, `semantic ${edge.kind ?? 'match'} for "${normalizedQuery}"`));
|
|
195
204
|
}
|
|
@@ -199,7 +208,7 @@ async function semanticLane(rootDir, normalizedQuery, config, fileSet, snapshotI
|
|
|
199
208
|
// Vector lane (SPEC §2/§3): dep-free hashed embedding provider; mirrors the
|
|
200
209
|
// semantic lane. Edges' `to` carries "file:0" — reduce to a ranked file list.
|
|
201
210
|
async function vectorLane(rootDir, normalizedQuery, config, fileSet, snapshotId) {
|
|
202
|
-
const provider =
|
|
211
|
+
const provider = embeddingProviderFor(rootDir, config);
|
|
203
212
|
if (!provider || provider.name === 'null' || typeof provider.resolve !== 'function') {
|
|
204
213
|
return { lane: null, why: 'unavailable' };
|
|
205
214
|
}
|
|
@@ -222,13 +231,37 @@ async function vectorLane(rootDir, normalizedQuery, config, fileSet, snapshotId)
|
|
|
222
231
|
if (!target) continue;
|
|
223
232
|
const rel = path.isAbsolute(target) ? normalizeRelative(rootDir, target) : target;
|
|
224
233
|
if (seen.has(rel)) continue;
|
|
225
|
-
if (
|
|
234
|
+
if (!fileSet.has(rel)) continue;
|
|
226
235
|
seen.add(rel);
|
|
227
236
|
lane.push(laneEntry(rel, `vector ${edge.kind ?? 'match'} for "${normalizedQuery}"`));
|
|
228
237
|
}
|
|
229
238
|
return { lane, why: lane.length === 0 ? 'no-match' : null };
|
|
230
239
|
}
|
|
231
240
|
|
|
241
|
+
// C92-F-10: reuse the embedding provider across retrieve() calls for the same
|
|
242
|
+
// (projectRoot, embedding config) so its doc-vector cache survives between
|
|
243
|
+
// queries — a fresh provider per call re-embeds the whole corpus every time.
|
|
244
|
+
// Bounded: insertion-ordered LRU over at most PROVIDER_CACHE_MAX instances.
|
|
245
|
+
const PROVIDER_CACHE_MAX = 16;
|
|
246
|
+
const embeddingProviders = new Map();
|
|
247
|
+
|
|
248
|
+
function embeddingProviderFor(rootDir, config) {
|
|
249
|
+
const embedding = config?.codeIntel?.embedding;
|
|
250
|
+
const key = `${rootDir}\n${embedding?.provider ?? 'hashed'}\n${embedding?.dimensions ?? 'default'}`;
|
|
251
|
+
let provider = embeddingProviders.get(key);
|
|
252
|
+
if (provider) {
|
|
253
|
+
embeddingProviders.delete(key);
|
|
254
|
+
embeddingProviders.set(key, provider);
|
|
255
|
+
return provider;
|
|
256
|
+
}
|
|
257
|
+
provider = createEmbeddingProvider({ projectRoot: rootDir, config });
|
|
258
|
+
embeddingProviders.set(key, provider);
|
|
259
|
+
if (embeddingProviders.size > PROVIDER_CACHE_MAX) {
|
|
260
|
+
embeddingProviders.delete(embeddingProviders.keys().next().value);
|
|
261
|
+
}
|
|
262
|
+
return provider;
|
|
263
|
+
}
|
|
264
|
+
|
|
232
265
|
function rrfMerge(lanes, weights, rrfK) {
|
|
233
266
|
const scores = new Map();
|
|
234
267
|
const meta = new Map();
|
|
@@ -361,6 +394,17 @@ export async function retrieve(projectRoot, query, { mode = 'search', limit, sna
|
|
|
361
394
|
return { anchors: [], evidence: [], relations: [], omitted };
|
|
362
395
|
}
|
|
363
396
|
|
|
397
|
+
// C92-F-04 (fail closed): files.json is the path allow-list. When it is
|
|
398
|
+
// missing or schema-mismatched the gate is UNAVAILABLE, and an empty
|
|
399
|
+
// allow-list must not be read as "allow everything" — refuse the retrieval
|
|
400
|
+
// and name the unusable artifact so callers can distinguish a complete
|
|
401
|
+
// result from a degraded one.
|
|
402
|
+
if (!filesArtifact) {
|
|
403
|
+
omitted.push({ what: INDEX_ARTIFACTS.files, why: 'index-unavailable' });
|
|
404
|
+
omitted.push({ what: 'retrieval', why: 'path-gate-unavailable' });
|
|
405
|
+
return { anchors: [], evidence: [], relations: [], omitted };
|
|
406
|
+
}
|
|
407
|
+
|
|
364
408
|
const fileItems = filesArtifact?.items ?? [];
|
|
365
409
|
const symbolItems = symbolsArtifact?.items ?? [];
|
|
366
410
|
const fileSet = new Set(fileItems.map((f) => f.filePath));
|
|
@@ -374,26 +418,15 @@ export async function retrieve(projectRoot, query, { mode = 'search', limit, sna
|
|
|
374
418
|
const exact = exactLane(fileItems, normalizedQuery, queryBase);
|
|
375
419
|
if (exact.length > 0) lanes.set('exact', exact);
|
|
376
420
|
|
|
377
|
-
// Lane 2 — symbol-name match
|
|
378
|
-
const provider = new IndexFileSyntaxProvider({ rootDir });
|
|
421
|
+
// Lane 2 — symbol-name match against the symbols.json universe.
|
|
379
422
|
if (!symbolsArtifact) {
|
|
380
423
|
omitted.push({ what: 'symbol-lane', why: 'index-missing' });
|
|
381
424
|
}
|
|
382
425
|
const symbol = symbolLane(symbolItems, normalizedQuery, fileSet);
|
|
383
426
|
if (symbol.length > 0) lanes.set('symbol', symbol);
|
|
384
|
-
// Enrich anchors with provider symbols when the lane matched (best-effort).
|
|
385
|
-
for (const entry of symbol) {
|
|
386
|
-
try {
|
|
387
|
-
if (provider.supports(entry.path)) {
|
|
388
|
-
await provider.symbols(entry.path);
|
|
389
|
-
}
|
|
390
|
-
} catch {
|
|
391
|
-
// Provider enrichment is best-effort; anchors already stand.
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
427
|
|
|
395
428
|
// Lane 3 — BM25-lite over file/symbol tokens.
|
|
396
|
-
const bm25 = bm25Lane(fileItems, symbolItems, queryTokens, defaults.bm25);
|
|
429
|
+
const bm25 = bm25Lane(fileItems, symbolItems, queryTokens, defaults.bm25, fileSet);
|
|
397
430
|
if (bm25 === null) {
|
|
398
431
|
if (exact.length === 0 && symbol.length === 0) {
|
|
399
432
|
omitted.push({ what: 'bm25-lane', why: 'empty-query-tokens' });
|
|
@@ -35,7 +35,13 @@ const TRIVIAL_RE = /\b(typo|label|rename|spacing|toggle|whitespace|comment fix|s
|
|
|
35
35
|
const ERROR_RE = /\b(error|exception|stack ?trace|bug|crash|failing|fails|failure|regression|TypeError|ReferenceError|SyntaxError|ENOENT|segfault)\b/i;
|
|
36
36
|
const EXPLORE_RE = /\b(where|how does|how do|find|explore|understand|what does|which file|locate|handled)\b/i;
|
|
37
37
|
const DEEP_FLOW_RE = /\b(refactor|migrate|migration|flow|end[- ]?to[- ]?end|trace|across|pipeline|lifecycle)\b/i;
|
|
38
|
-
|
|
38
|
+
// C92-F-09: bare `like\b` matched the most common English filler verb ("I
|
|
39
|
+
// would like to…"), misrouting everyday prompts to analogy. Keep only
|
|
40
|
+
// comparative uses: quantifier + like ("something/anything like X") and
|
|
41
|
+
// behaviour-verb + like ("behaves/looks/works like X"). "like <determiner>"
|
|
42
|
+
// is deliberately excluded — "I'd like the docs updated" is a polite request,
|
|
43
|
+
// not a similarity probe.
|
|
44
|
+
const ANALOGY_RE = /\b(similar to|analogy|same as|pattern|equivalent)\b|\b(?:something|anything|nothing) like\b|\b(?:looks?|behav(?:e|es)|acts?|works?|performs?|functions?|feels?) like\b/i;
|
|
39
45
|
const PATH_RE = /(?:[\w.-]+\/)+[\w.-]+|[\w.-]+\.(?:js|ts|mjs|cjs|jsx|tsx|py|rb|go|rs|java|md|json|yml|yaml|css|html|sh)\b/gi;
|
|
40
46
|
|
|
41
47
|
// English slash-words that look like paths to PATH_RE but never are —
|
|
@@ -90,24 +96,28 @@ function decide(prompt, { hasError = false, filesHinted } = {}) {
|
|
|
90
96
|
return { mode: 'impact', reasons };
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
// Rule 5 —
|
|
99
|
+
// Rule 5 — analogy / pattern-search wording (C92-F-09): similarity intent is
|
|
100
|
+
// the more specific signal, so it wins over generic explore/find wording and
|
|
101
|
+
// over cross-cutting deep_flow wording. The regex no longer matches a bare
|
|
102
|
+
// verb "like" — only comparative idioms ("similar to", "same as", "something
|
|
103
|
+
// like X", "behaves like X").
|
|
104
|
+
if (ANALOGY_RE.test(prompt)) {
|
|
105
|
+
reasons.push('similar-to / analogy pattern-search wording');
|
|
106
|
+
return { mode: 'analogy', reasons };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Rule 6 — open-ended exploration wording.
|
|
94
110
|
if (EXPLORE_RE.test(prompt)) {
|
|
95
111
|
reasons.push('open-ended where/how/find/explore wording');
|
|
96
112
|
return { mode: 'explore', reasons };
|
|
97
113
|
}
|
|
98
114
|
|
|
99
|
-
// Rule
|
|
115
|
+
// Rule 7 — cross-cutting refactor/migrate/flow wording.
|
|
100
116
|
if (DEEP_FLOW_RE.test(prompt)) {
|
|
101
117
|
reasons.push('cross-cutting refactor/migrate/flow wording');
|
|
102
118
|
return { mode: 'deep_flow', reasons };
|
|
103
119
|
}
|
|
104
120
|
|
|
105
|
-
// Rule 7 — analogy / pattern search wording.
|
|
106
|
-
if (ANALOGY_RE.test(prompt)) {
|
|
107
|
-
reasons.push('similar-to / analogy pattern-search wording');
|
|
108
|
-
return { mode: 'analogy', reasons };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
121
|
// Rule 8 — fallback: single-file verb (single hinted file or lone path mention).
|
|
112
122
|
if (hintedFiles.length === 1 || hasPathMention(prompt)) {
|
|
113
123
|
reasons.push('fallback: single-file hint');
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { getSyntaxProvider, IndexFileSyntaxProvider } from './providers.js';
|
|
5
|
-
import {
|
|
5
|
+
import { resolveProjectPathReal } from '../fileOps.js';
|
|
6
6
|
|
|
7
7
|
// Deterministic extractive summaries (SPEC §7/§8, CI-304) — the L3 detail tier.
|
|
8
8
|
// No model calls, no clock/random, no new deps. File reads are bounded to the
|
|
@@ -18,7 +18,9 @@ const DECL_RE = /^\s*(?:export\s+default\s+|export\s+)?(?:async\s+)?(?:function\
|
|
|
18
18
|
|
|
19
19
|
async function readHead(rootDir, relPath) {
|
|
20
20
|
try {
|
|
21
|
-
|
|
21
|
+
// Post-realpath containment: a symlinked path that passes the lexical
|
|
22
|
+
// check but resolves outside the root must never reach fs.readFile.
|
|
23
|
+
const abs = await resolveProjectPathReal(rootDir, relPath);
|
|
22
24
|
if (!abs) return null;
|
|
23
25
|
const content = await fs.readFile(abs, 'utf8');
|
|
24
26
|
return { content, lines: content.split('\n').slice(0, MAX_READ_LINES), bytes: Buffer.byteLength(content, 'utf8') };
|
|
@@ -54,7 +56,6 @@ function docblockFirstSentence(lines, endIndex = lines.length) {
|
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
const text = block.join(' ').replace(/\s+/g, ' ').trim();
|
|
57
|
-
if (!text) return null;
|
|
58
59
|
const m = text.match(/^.+?[.!?](?:\s|$)/);
|
|
59
60
|
return (m ? m[0] : text).trim();
|
|
60
61
|
}
|
|
@@ -9,9 +9,19 @@ import { createEdge } from './providers.js';
|
|
|
9
9
|
// no deps — real embeddings plug in later through the injectable `loader`
|
|
10
10
|
// seam (config.codeIntel.embedding.provider === 'auto'), degrading to the
|
|
11
11
|
// hashed provider on ANY failure. Never throws.
|
|
12
|
+
// C92-F-10: corpus doc vectors are cached on the provider instance (bounded —
|
|
13
|
+
// EMBED_CACHE_MAX docs) and scoring streams docs one at a time, so a query no
|
|
14
|
+
// longer re-embeds the corpus or holds every vector live at once. Retrievals
|
|
15
|
+
// share one provider per (rootDir, embedding config) via retriever.js
|
|
16
|
+
// embeddingProviderFor, which is what lets this cache pay off across calls.
|
|
12
17
|
|
|
13
18
|
const DEFAULT_DIMENSIONS = 256;
|
|
14
19
|
const DEFAULT_LIMIT = 20;
|
|
20
|
+
// Doc-vector cache bound (C92-F-10): ~2 KiB per cached 256-dim vector, so the
|
|
21
|
+
// cap keeps the cache near ~8 MiB per provider instance. On overflow the cache
|
|
22
|
+
// resets (doc texts are content-addressed, so eviction can only cost a
|
|
23
|
+
// re-embed — never a wrong score).
|
|
24
|
+
const EMBED_CACHE_MAX = 4096;
|
|
15
25
|
|
|
16
26
|
function fnv1a(str) {
|
|
17
27
|
let h = 0x811c9dc5;
|
|
@@ -97,6 +107,19 @@ export class HashedEmbeddingProvider {
|
|
|
97
107
|
this._embedFn = typeof embedFn === 'function' ? embedFn : null;
|
|
98
108
|
}
|
|
99
109
|
|
|
110
|
+
// Doc-vector cache: docText → vector. Doc texts are content-addressed, so a
|
|
111
|
+
// stale key can never alias onto different content; index churn only costs a
|
|
112
|
+
// re-embed of the changed doc text.
|
|
113
|
+
async #embedDoc(text) {
|
|
114
|
+
if (!this._docVec) this._docVec = new Map();
|
|
115
|
+
const cached = this._docVec.get(text);
|
|
116
|
+
if (cached) return cached;
|
|
117
|
+
const vec = await this.embed(text);
|
|
118
|
+
if (this._docVec.size >= EMBED_CACHE_MAX) this._docVec.clear();
|
|
119
|
+
this._docVec.set(text, vec);
|
|
120
|
+
return vec;
|
|
121
|
+
}
|
|
122
|
+
|
|
100
123
|
async available() {
|
|
101
124
|
return true;
|
|
102
125
|
}
|
|
@@ -139,10 +162,13 @@ export class HashedEmbeddingProvider {
|
|
|
139
162
|
docs.set(sym.filePath, `${docs.get(sym.filePath) ?? sym.filePath} ${sym.name}`);
|
|
140
163
|
}
|
|
141
164
|
const snapshot = ctx?.snapshot ?? null;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
165
|
+
// C92-F-10: score docs one at a time (cached vectors reused; at most one
|
|
166
|
+
// embed is ever in flight) rather than Promise.all over the corpus, which
|
|
167
|
+
// kept every 256-float vector live simultaneously.
|
|
168
|
+
const scored = [];
|
|
169
|
+
for (const [filePath, docText] of docs) {
|
|
170
|
+
scored.push({ filePath, score: cosineSimilarity(queryVec, await this.#embedDoc(docText)) });
|
|
171
|
+
}
|
|
146
172
|
return scored
|
|
147
173
|
.filter((entry) => entry.score > 0)
|
|
148
174
|
.sort((a, b) => b.score - a.score || a.filePath.localeCompare(b.filePath))
|
|
@@ -101,12 +101,19 @@ export function compactContextBlock(
|
|
|
101
101
|
forceFirstCount: (header ? 1 : 0) + normalizedAnchorLines.length,
|
|
102
102
|
},
|
|
103
103
|
).join('\n').trim();
|
|
104
|
-
|
|
104
|
+
// 'forced-anchors' means every anchor is present — verify it. When the block
|
|
105
|
+
// budget cannot hold them all, the mode must report the shortfall instead of
|
|
106
|
+
// claiming an anchor-complete block that silently dropped the tail anchors
|
|
107
|
+
// (C92-H-03: the dropped tail used to be memory-bridge + ask-before-drop).
|
|
108
|
+
validationMode = findMissingAnchors(text, normalizedAnchorLines).length === 0
|
|
109
|
+
? 'forced-anchors'
|
|
110
|
+
: 'anchor-shortfall';
|
|
105
111
|
}
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
const tokensBefore = estimateTokenCount(rawText);
|
|
109
115
|
const tokensAfter = estimateTokenCount(text);
|
|
116
|
+
const missingAnchors = findMissingAnchors(text, normalizedAnchorLines);
|
|
110
117
|
|
|
111
118
|
return {
|
|
112
119
|
text,
|
|
@@ -115,6 +122,7 @@ export function compactContextBlock(
|
|
|
115
122
|
savedTokens: Math.max(0, tokensBefore - tokensAfter),
|
|
116
123
|
validationMode,
|
|
117
124
|
anchorCount: normalizedAnchorLines.length,
|
|
125
|
+
missingAnchors,
|
|
118
126
|
};
|
|
119
127
|
}
|
|
120
128
|
|
|
@@ -167,6 +175,7 @@ function buildCompactedContextLines(
|
|
|
167
175
|
const seen = new Set();
|
|
168
176
|
let usedTokens = 0;
|
|
169
177
|
let nonEmptyCount = 0;
|
|
178
|
+
let forcedRemaining = forceFirstCount;
|
|
170
179
|
|
|
171
180
|
for (const line of sourceLines) {
|
|
172
181
|
const compressed = compressLine(line);
|
|
@@ -180,12 +189,18 @@ function buildCompactedContextLines(
|
|
|
180
189
|
}
|
|
181
190
|
|
|
182
191
|
const tokens = estimateTokenCount(compressed);
|
|
183
|
-
|
|
192
|
+
// Forced lines (header + anchors) are the first `forceFirstCount` distinct
|
|
193
|
+
// non-empty source lines: they bypass the line cap and the soft token budget —
|
|
194
|
+
// dropping a forced anchor would silently break the anchor contract this
|
|
195
|
+
// function exists to enforce. The quota is consumed on the candidate itself,
|
|
196
|
+
// whether or not it fits, so a dropped forced anchor never slides its slot
|
|
197
|
+
// onto a following content line.
|
|
198
|
+
const forceLine = forcedRemaining > 0;
|
|
199
|
+
if (forceLine) {
|
|
200
|
+
forcedRemaining -= 1;
|
|
201
|
+
}
|
|
184
202
|
const wouldExceedLines = nonEmptyCount >= maxLines;
|
|
185
203
|
|
|
186
|
-
// Forced lines (header + anchors) are guaranteed placement: they bypass the
|
|
187
|
-
// line cap — dropping a forced anchor would silently break the anchor
|
|
188
|
-
// contract this function exists to enforce.
|
|
189
204
|
if ((!forceLine && wouldExceedLines) || (!forceLine && selected.length > 0 && (usedTokens + tokens) > maxTokens)) {
|
|
190
205
|
break;
|
|
191
206
|
}
|
|
@@ -195,8 +210,11 @@ function buildCompactedContextLines(
|
|
|
195
210
|
: compressed;
|
|
196
211
|
const selectedTokens = estimateTokenCount(selectedLine);
|
|
197
212
|
|
|
213
|
+
// A forced line whose remaining slice of hardTokenCap is empty used to break
|
|
214
|
+
// the loop, dropping every anchor after it. Keep going so later anchors still
|
|
215
|
+
// get their chance — the caller reports whichever anchors never landed.
|
|
198
216
|
if (!selectedLine) {
|
|
199
|
-
|
|
217
|
+
continue;
|
|
200
218
|
}
|
|
201
219
|
|
|
202
220
|
selected.push(selectedLine);
|
|
@@ -204,7 +222,6 @@ function buildCompactedContextLines(
|
|
|
204
222
|
usedTokens += selectedTokens;
|
|
205
223
|
nonEmptyCount += 1;
|
|
206
224
|
}
|
|
207
|
-
|
|
208
225
|
if (selected.length === 0) {
|
|
209
226
|
return compressMarkdownLines(sourceLines, {
|
|
210
227
|
maxTokens,
|
|
@@ -469,12 +469,18 @@ export async function buildCompactThresholds(config = {}) {
|
|
|
469
469
|
const hardCapTokens = negotiated
|
|
470
470
|
? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
|
|
471
471
|
: shippedHardCap;
|
|
472
|
-
// An explicit operator tokenThreshold is
|
|
473
|
-
//
|
|
472
|
+
// An explicit operator tokenThreshold is honoured but still clamped under the
|
|
473
|
+
// negotiated cap: an advisory soft/hard pair sitting at or above hardCapTokens can
|
|
474
|
+
// never fire, because context-hardcap-gate.sh already refuses tools at the cap
|
|
475
|
+
// (C92-H-01 — on a default install tokenThreshold is the shipped 150k default, not a
|
|
476
|
+
// real operator choice, so this is correcting a number, not overriding intent).
|
|
474
477
|
const softThreshold = explicitSoftThreshold > 0
|
|
475
|
-
? Math.max(1, explicitSoftThreshold)
|
|
478
|
+
? Math.max(1, Math.min(explicitSoftThreshold, hardCapTokens - 1))
|
|
476
479
|
: Math.max(1, Math.round(hardCapTokens * SOFT_TO_CAP_RATIO));
|
|
477
|
-
const hardThreshold = Math.
|
|
480
|
+
const hardThreshold = Math.min(
|
|
481
|
+
hardCapTokens,
|
|
482
|
+
Math.max(softThreshold + 1, Math.round(softThreshold * 1.6)),
|
|
483
|
+
);
|
|
478
484
|
const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
|
|
479
485
|
|
|
480
486
|
return {
|
|
@@ -607,6 +613,9 @@ export function resolveThresholdCompactBudget({
|
|
|
607
613
|
// this shape (raw JSON readers) still see a coherent state.
|
|
608
614
|
// Mirrors template_project/.claude/ukit/runtime/compact-threshold.mjs — keep in lockstep.
|
|
609
615
|
const PRESSURE_SESSIONS_MAX = 8;
|
|
616
|
+
// C90-16: sessionless writers (omp tool events without session_id/transcript)
|
|
617
|
+
// accumulate here — never on the newest sibling's record.
|
|
618
|
+
const SESSIONLESS_PRESSURE_BUCKET = '_sessionless';
|
|
610
619
|
|
|
611
620
|
function normalizeSessionId(value) {
|
|
612
621
|
const id = typeof value === 'string' ? value.trim() : '';
|
|
@@ -627,12 +636,20 @@ function readPressureDocument(raw) {
|
|
|
627
636
|
return raw && typeof raw === 'object' ? { default: raw } : {};
|
|
628
637
|
}
|
|
629
638
|
|
|
630
|
-
function pickPressureSession(sessions, sessionId) {
|
|
639
|
+
function pickPressureSession(sessions, sessionId, { forWrite = false } = {}) {
|
|
631
640
|
if (sessionId) {
|
|
632
641
|
// A session id that has no record yet starts clean — it must not inherit another
|
|
633
642
|
// session's totals, and the other way round nothing here touches that session.
|
|
634
643
|
return { id: sessionId, record: sessions[sessionId] ?? null };
|
|
635
644
|
}
|
|
645
|
+
if (forWrite) {
|
|
646
|
+
// C90-16: a writer without a session id must never accumulate onto the
|
|
647
|
+
// newest sibling's record — cross-session pressure corruption survives a
|
|
648
|
+
// SessionStart reset and wedges that session's hard-cap gate. Sessionless
|
|
649
|
+
// mutations accumulate on a dedicated bucket instead, even when the
|
|
650
|
+
// document holds no records yet.
|
|
651
|
+
return { id: SESSIONLESS_PRESSURE_BUCKET, record: sessions[SESSIONLESS_PRESSURE_BUCKET] ?? null };
|
|
652
|
+
}
|
|
636
653
|
const entries = Object.entries(sessions);
|
|
637
654
|
if (!entries.length) {
|
|
638
655
|
return { id: 'default', record: null };
|
|
@@ -669,11 +686,22 @@ export async function buildCompactPressureState(rawState = null, config = {}) {
|
|
|
669
686
|
const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
|
|
670
687
|
const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
|
|
671
688
|
if (!finiteNumber(config?.compact?.tokenThreshold, 0) && rawSoftThreshold > 0) {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
689
|
+
// A persisted pair is only honoured while it still sits inside the negotiated cap.
|
|
690
|
+
// The record outlives capacity negotiation: an install that ran before the record
|
|
691
|
+
// existed, or before the model was verified, carries thresholds tuned for the
|
|
692
|
+
// shipped 500k ceiling — restoring them re-pins the advisory ABOVE the live
|
|
693
|
+
// hardCapTokens forever (C92-H-02). Falling through keeps the freshly derived
|
|
694
|
+
// pair, which is also what the rewritten record persists, so the file self-heals.
|
|
695
|
+
if (rawSoftThreshold < thresholds.hardCapTokens) {
|
|
696
|
+
thresholds.softThreshold = rawSoftThreshold;
|
|
697
|
+
thresholds.hardThreshold = Math.min(
|
|
698
|
+
thresholds.hardCapTokens,
|
|
699
|
+
rawHardThreshold > rawSoftThreshold
|
|
700
|
+
? rawHardThreshold
|
|
701
|
+
: Math.max(rawSoftThreshold + 1, Math.round(rawSoftThreshold * 1.6)),
|
|
702
|
+
);
|
|
703
|
+
thresholds.baselineTokens = Math.max(120, Math.min(18_000, Math.round(thresholds.softThreshold * 0.18)));
|
|
704
|
+
}
|
|
677
705
|
}
|
|
678
706
|
const recentPrompts = (Array.isArray(rawState?.recentPrompts) ? rawState.recentPrompts : [])
|
|
679
707
|
.map((entry) => normalizePromptEntry(entry))
|
|
@@ -1017,17 +1045,23 @@ export async function buildThresholdCompactPlan({
|
|
|
1017
1045
|
const compacted = compactContextBlock(rawLines, {
|
|
1018
1046
|
maxTokens: budget.maxTokens,
|
|
1019
1047
|
maxLines: budget.maxLines,
|
|
1048
|
+
// Guardrail anchors are listed FIRST: under hard pressure the block budget may
|
|
1049
|
+
// not fit every anchor, and the lines that protect the user (ask-before-drop)
|
|
1050
|
+
// and the session (memory bridge) are the ones that must never be the tail
|
|
1051
|
+
// casualty (C92-H-03).
|
|
1020
1052
|
anchorLines: [
|
|
1021
1053
|
thresholdLine,
|
|
1054
|
+
askBeforeDropLine,
|
|
1055
|
+
memoryBridgeLine,
|
|
1022
1056
|
routeLine,
|
|
1023
1057
|
previousLine,
|
|
1024
1058
|
outputLine,
|
|
1025
|
-
memoryBridgeLine,
|
|
1026
|
-
askBeforeDropLine,
|
|
1027
1059
|
].filter(Boolean),
|
|
1028
1060
|
maxAnchors: 6,
|
|
1029
1061
|
});
|
|
1030
1062
|
|
|
1063
|
+
const missingAnchors = compacted.missingAnchors ?? [];
|
|
1064
|
+
|
|
1031
1065
|
return {
|
|
1032
1066
|
active: true,
|
|
1033
1067
|
phase,
|
|
@@ -1040,6 +1074,7 @@ export async function buildThresholdCompactPlan({
|
|
|
1040
1074
|
tokensBefore: compacted.tokensBefore,
|
|
1041
1075
|
tokensAfter: compacted.tokensAfter,
|
|
1042
1076
|
savedTokens: compacted.savedTokens,
|
|
1077
|
+
missingAnchors,
|
|
1043
1078
|
};
|
|
1044
1079
|
}
|
|
1045
1080
|
|
|
@@ -1057,7 +1092,7 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
|
1057
1092
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1058
1093
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1059
1094
|
const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
|
|
1060
|
-
const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1095
|
+
const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId), { forWrite: true });
|
|
1061
1096
|
const current = await buildCompactPressureState(record, config);
|
|
1062
1097
|
const next = await mutator(current);
|
|
1063
1098
|
const normalized = await buildCompactPressureState(next, config);
|
|
@@ -1071,7 +1106,7 @@ export async function writeCompactPressureState(projectRoot, state, config = {})
|
|
|
1071
1106
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1072
1107
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1073
1108
|
const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
|
|
1074
|
-
const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1109
|
+
const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId), { forWrite: true });
|
|
1075
1110
|
const normalized = await buildCompactPressureState(state, config);
|
|
1076
1111
|
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1077
1112
|
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
package/src/core/diffPlan.js
CHANGED
|
@@ -5,17 +5,34 @@ import { isOmpConfigTarget, mergeOmpConfig } from './ompConfigMerge.js';
|
|
|
5
5
|
|
|
6
6
|
const GATEWAY_RESILIENCE_KEYS = new Set(Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS));
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// Read errors split in two: ENOENT is the ONLY state that means "absent" (→ a
|
|
9
|
+
// 'create' plan). Every other error — EACCES, EISDIR, EIO, ENOTDIR — means the
|
|
10
|
+
// target may exist but cannot be compared. Collapsing those into `null` would
|
|
11
|
+
// look identical to "missing" and the apply step would either overwrite the
|
|
12
|
+
// live file atomically with no .bak (W2-C4) or crash mid-install on a 'wx'
|
|
13
|
+
// create (W2-C6). Unreadable targets get a non-write action instead.
|
|
14
|
+
async function readFileOrStatus(filePath, encoding = 'utf8') {
|
|
9
15
|
try {
|
|
10
16
|
if (encoding === null) {
|
|
11
|
-
return await fs.readFile(filePath);
|
|
17
|
+
return { status: 'ok', content: await fs.readFile(filePath) };
|
|
12
18
|
}
|
|
13
|
-
return await fs.readFile(filePath, encoding);
|
|
14
|
-
} catch {
|
|
15
|
-
|
|
19
|
+
return { status: 'ok', content: await fs.readFile(filePath, encoding) };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error?.code === 'ENOENT') {
|
|
22
|
+
return { status: 'missing' };
|
|
23
|
+
}
|
|
24
|
+
return { status: 'unreadable', error };
|
|
16
25
|
}
|
|
17
26
|
}
|
|
18
27
|
|
|
28
|
+
function conflictResult(entry, error) {
|
|
29
|
+
const code = error?.code ?? 'unknown';
|
|
30
|
+
console.warn(
|
|
31
|
+
`[UKit] Warning: cannot read ${entry.targetPath} (${code}) — leaving it untouched instead of treating it as absent. Fix permissions or remove it manually.`,
|
|
32
|
+
);
|
|
33
|
+
return { ...entry, exists: true, action: 'conflict', existingContent: null };
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
async function checkLinkStatus(targetPath, linkTarget) {
|
|
20
37
|
try {
|
|
21
38
|
const stat = await fs.lstat(targetPath);
|
|
@@ -201,33 +218,41 @@ function resolveFileAction(entry, existingContent) {
|
|
|
201
218
|
|
|
202
219
|
// A `mergeStrategy: skip` target must never be classified as "missing" just because it
|
|
203
220
|
// cannot be READ — a directory, broken symlink, FIFO/device, or unreadable (EACCES) file
|
|
204
|
-
// at the seed path all mean "exists, do not touch".
|
|
205
|
-
//
|
|
206
|
-
//
|
|
221
|
+
// at the seed path all mean "exists, do not touch". An lstat that fails for a reason
|
|
222
|
+
// OTHER than ENOENT (W2-C6: EACCES on a parent, ELOOP, ENOTDIR) proves nothing at all —
|
|
223
|
+
// treating it as "absent" would send a 'create' action to apply, whose exclusive 'wx'
|
|
224
|
+
// write then crashes mid-install on a target that may have been there all along.
|
|
225
|
+
// Existence for skip entries is decided by lstat (no symlink follow, no device open —
|
|
226
|
+
// reading a FIFO would hang).
|
|
207
227
|
async function resolveSkipEntryAction(entry) {
|
|
208
228
|
let stat;
|
|
209
229
|
try {
|
|
210
230
|
stat = await fs.lstat(entry.targetPath);
|
|
211
|
-
} catch {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
231
|
+
} catch (statError) {
|
|
232
|
+
if (statError?.code === 'ENOENT') {
|
|
233
|
+
// Truly absent — seed it. The apply step still uses an exclusive 'wx'
|
|
234
|
+
// create so a target appearing between diff and apply is not clobbered.
|
|
235
|
+
return { ...entry, exists: false, action: 'create', existingContent: null };
|
|
236
|
+
}
|
|
237
|
+
return conflictResult(entry, statError);
|
|
215
238
|
}
|
|
216
239
|
|
|
217
240
|
// Only a regular, readable file can prove byte-equality for an 'unchanged' verdict.
|
|
218
241
|
// Anything else — directory, symlink (valid or broken), FIFO, socket, device, or a
|
|
219
242
|
// file we cannot read — is simply 'skip': present, owner-owned, hands off.
|
|
220
|
-
|
|
221
|
-
if (stat.isFile() && !stat.isSymbolicLink()) {
|
|
222
|
-
existingContent = await readFileOrNull(
|
|
223
|
-
entry.targetPath,
|
|
224
|
-
Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
if (existingContent === null) {
|
|
243
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
228
244
|
return { ...entry, exists: true, action: 'skip', existingContent: null };
|
|
229
245
|
}
|
|
230
|
-
|
|
246
|
+
const read = await readFileOrStatus(
|
|
247
|
+
entry.targetPath,
|
|
248
|
+
Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
|
|
249
|
+
);
|
|
250
|
+
if (read.status !== 'ok') {
|
|
251
|
+
// 'missing' here means it vanished between lstat and read; 'unreadable'
|
|
252
|
+
// means present-but-inaccessible. Either way: owner territory, skip it.
|
|
253
|
+
return { ...entry, exists: read.status === 'unreadable', action: 'skip', existingContent: null };
|
|
254
|
+
}
|
|
255
|
+
return resolveFileAction(entry, read.content);
|
|
231
256
|
}
|
|
232
257
|
|
|
233
258
|
export async function diffInstallPlan(plan) {
|
|
@@ -243,11 +268,14 @@ export async function diffInstallPlan(plan) {
|
|
|
243
268
|
return resolveSkipEntryAction(entry);
|
|
244
269
|
}
|
|
245
270
|
|
|
246
|
-
const
|
|
271
|
+
const read = await readFileOrStatus(
|
|
247
272
|
entry.targetPath,
|
|
248
273
|
Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
|
|
249
274
|
);
|
|
250
|
-
|
|
275
|
+
if (read.status === 'unreadable') {
|
|
276
|
+
return conflictResult(entry, read.error);
|
|
277
|
+
}
|
|
278
|
+
return resolveFileAction(entry, read.status === 'ok' ? read.content : null);
|
|
251
279
|
}),
|
|
252
280
|
);
|
|
253
281
|
|
|
@@ -73,7 +73,12 @@ function parseGitignore(content) {
|
|
|
73
73
|
* The block spans `[startIdx, endIdx]` inclusive. The search for the closing
|
|
74
74
|
* marker is bounded by the NEXT start marker, so an excision can never run past
|
|
75
75
|
* a second block. If no end marker exists inside that bound the block is
|
|
76
|
-
* "unterminated" and
|
|
76
|
+
* "unterminated" (C92-C-01) and its excision boundary stops at the contiguous
|
|
77
|
+
* run of canonical entries following the start marker — a `.gitignore` has no
|
|
78
|
+
* marker where a deleted `# /UKit` should end, so only exact canonical-entry
|
|
79
|
+
* lines provably written by UKit are managed. Everything else — blank lines,
|
|
80
|
+
* user comments, stale or custom rules — is user content and is never spliced
|
|
81
|
+
* out.
|
|
77
82
|
*
|
|
78
83
|
* @param {string[]} lines `\r`-free lines from {@link parseGitignore}
|
|
79
84
|
* @param {number} [from]
|
|
@@ -94,7 +99,19 @@ function locateBlock(lines, from = 0) {
|
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
const terminated = endIdx !== -1;
|
|
97
|
-
|
|
102
|
+
if (terminated) {
|
|
103
|
+
return { startIdx, endIdx, terminated };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Unterminated: own only the contiguous canonical-entry run right after the
|
|
107
|
+
// start marker. The canonical block is regenerated in full anyway, so this
|
|
108
|
+
// run is information-free; stopping at the first non-entry line is what keeps
|
|
109
|
+
// arbitrary user rules below the header safe (C92-C-01).
|
|
110
|
+
let lastManaged = startIdx;
|
|
111
|
+
while (lastManaged + 1 < bound && UKIT_ENTRIES.includes(lines[lastManaged + 1])) {
|
|
112
|
+
lastManaged += 1;
|
|
113
|
+
}
|
|
114
|
+
return { startIdx, endIdx: lastManaged, terminated };
|
|
98
115
|
}
|
|
99
116
|
|
|
100
117
|
export async function ensureGitignore(projectRoot) {
|