@ngockhoale/ukit 3.0.10 → 3.0.11
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 +10 -0
- package/bin/ukit +5 -9
- package/package.json +1 -1
- package/scripts/bench/memory-baseline.mjs +1 -1
- package/scripts/bench/memory-bench.mjs +1 -1
- package/scripts/bench/memory-canary.mjs +12 -2
- package/scripts/bench/recorder-overhead.mjs +1 -1
- package/scripts/bench/sqlite-spike.mjs +1 -1
- package/scripts/measure-decision-gateway.mjs +1 -1
- package/src/cli/commands/memory.js +3 -0
- package/src/cli/deadline.js +19 -0
- package/src/core/agentRuntime/eventStore.js +38 -10
- package/src/core/agentRuntime/supervisor.js +41 -9
- package/src/core/agentRuntime/vmEngine.js +33 -2
- package/src/core/codeintel/freshness.js +13 -9
- package/src/core/codeintel/retriever.js +25 -20
- package/src/core/fileOps.js +19 -4
- package/src/core/memory/memoryFlags.js +14 -6
- package/src/core/metadata.js +111 -81
- package/src/core/token/index.js +69 -28
- package/src/index/buildIndex.js +7 -0
- package/src/index/impactContext.js +14 -5
- package/src/render/instructionRenderer.js +71 -19
- package/template_project/.claude/hooks/auto-prune-bash.sh +60 -83
- package/template_project/.claude/hooks/block-dangerous.mjs +42 -11
- package/template_project/.claude/skills/docs-manager/init-project-docs.sh +59 -16
- package/template_project/.claude/skills/docx/scripts/document.py +31 -8
- package/template_project/.claude/skills/frontend-vue/composables/indexDBStore.js +26 -5
- package/template_project/.claude/skills/frontend-vue/composables/useRequest.js +24 -12
- package/template_project/.claude/skills/frontend-vue/composables/useSession.js +16 -11
- package/template_project/.claude/skills/frontend-vue/composables/useWebSocket.js +25 -2
- package/template_project/.claude/skills/pptx/scripts/rearrange.py +11 -2
- package/template_project/.claude/skills/root-cause-tracing/find-polluter.sh +20 -5
- package/template_project/.claude/skills/webapp-testing/scripts/with_server.py +37 -6
- package/template_project/.claude/ukit/index/cache-utils.mjs +25 -19
- package/template_project/.claude/ukit/index/impact-context.mjs +21 -13
- package/template_project/.claude/ukit/index/lib/index-core.mjs +96 -14
- package/template_project/.claude/ukit/index/query-index.mjs +13 -11
- package/template_project/.claude/ukit/index/resolve-context.mjs +12 -11
- package/template_project/.claude/ukit/index/route-task.mjs +44 -25
- package/template_project/.claude/ukit/index/triage.mjs +12 -11
- package/template_project/.claude/ukit/index/verify-context.mjs +12 -11
- package/template_project/.claude/ukit/runtime/async-lock.mjs +33 -0
- package/template_project/.claude/ukit/runtime/output-compression.mjs +9 -2
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +18 -2
- package/template_project/.claude/ukit/runtime/token-utils.mjs +60 -18
package/src/core/metadata.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { ensureDir, writeJson, readJsonIfExists } from './fileOps.js';
|
|
2
|
+
import { ensureDir, writeJson, readJsonIfExists, withFileLock } from './fileOps.js';
|
|
3
3
|
|
|
4
4
|
function normalizeTrackedRelativePath(relativePath) {
|
|
5
5
|
if (typeof relativePath !== 'string') {
|
|
@@ -33,100 +33,130 @@ export async function writeInstallMetadata({
|
|
|
33
33
|
projectRoot,
|
|
34
34
|
managedRelativePaths = null,
|
|
35
35
|
retainedManagedRelativePaths = [],
|
|
36
|
+
lockStaleMs,
|
|
37
|
+
lockMaxWaitMs,
|
|
36
38
|
}) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
version:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
39
|
+
const writeResult = await withFileLock(installMetaPath, async () => {
|
|
40
|
+
// Accumulate file tracking across re-installs so files written in earlier
|
|
41
|
+
// runs remain tracked (they are 'skip'/'unchanged' on re-install and don't
|
|
42
|
+
// appear in the current writes, but we still need to remove them on
|
|
43
|
+
// uninstall). The read→merge→write runs under withFileLock (C79-12):
|
|
44
|
+
// concurrent installs used to read the same pre-state and last-writer-wins
|
|
45
|
+
// the merge, silently losing tracked-file entries.
|
|
46
|
+
const previousData = await readJsonIfExists(installMetaPath);
|
|
47
|
+
const previousFiles = previousData?.tool === 'ukit' && Array.isArray(previousData?.files)
|
|
48
|
+
? previousData.files
|
|
49
|
+
: [];
|
|
50
|
+
const managedPathSet = Array.isArray(managedRelativePaths)
|
|
51
|
+
? new Set(managedRelativePaths.map((value) => normalizeTrackedRelativePath(value)).filter(Boolean))
|
|
52
|
+
: null;
|
|
53
|
+
const retainedManagedPathSet = new Set(
|
|
54
|
+
retainedManagedRelativePaths.map((value) => normalizeTrackedRelativePath(value)).filter(Boolean),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Convert writes to tracked entries stored as relative paths for portability.
|
|
58
|
+
const newEntries = projectRoot
|
|
59
|
+
? writes.map((w) => ({
|
|
60
|
+
p: normalizeTrackedRelativePath(path.relative(projectRoot, w.targetPath)),
|
|
61
|
+
t: w.type, // 'link', 'command', 'agent', 'hook', 'config', 'skill', etc.
|
|
62
|
+
})).filter((entry) => {
|
|
63
|
+
if (!entry.p) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (!managedPathSet) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return managedPathSet.has(entry.p) || retainedManagedPathSet.has(entry.p);
|
|
70
|
+
})
|
|
71
|
+
: [];
|
|
72
|
+
|
|
73
|
+
// Merge: keep entries from previous runs for paths not re-written this run,
|
|
74
|
+
// then append new entries (updating type info for any that were re-written).
|
|
75
|
+
const newPaths = new Set(newEntries.map((e) => e.p));
|
|
76
|
+
const retained = previousFiles.filter((e) => {
|
|
77
|
+
const p = normalizeTrackedRelativePath(typeof e === 'string' ? e : e?.p);
|
|
78
|
+
if (!p) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (managedPathSet && !managedPathSet.has(p) && !retainedManagedPathSet.has(p)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return !newPaths.has(p);
|
|
85
|
+
});
|
|
86
|
+
const allFiles = [...retained, ...newEntries];
|
|
87
|
+
|
|
88
|
+
const metadata = {
|
|
89
|
+
tool: 'ukit',
|
|
90
|
+
version: packageVersion,
|
|
91
|
+
installedAt: new Date().toISOString(),
|
|
92
|
+
manifest: {
|
|
93
|
+
name: manifest.name,
|
|
94
|
+
version: manifest.version,
|
|
95
|
+
},
|
|
96
|
+
stack: stackContext,
|
|
97
|
+
providers: providerContext,
|
|
98
|
+
summary,
|
|
99
|
+
files: allFiles,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
await ensureDir(path.dirname(installMetaPath));
|
|
103
|
+
await writeJson(installMetaPath, metadata);
|
|
104
|
+
return 'written';
|
|
105
|
+
}, { staleMs: lockStaleMs, maxWaitMs: lockMaxWaitMs });
|
|
106
|
+
|
|
107
|
+
if (writeResult === undefined) {
|
|
108
|
+
// FR-012 / SPEC §14: lock-wait expiry degrades explicitly — warn and skip
|
|
109
|
+
// the bookkeeping write rather than aborting the install or writing
|
|
110
|
+
// unlocked (the drop is also journaled by withFileLock).
|
|
111
|
+
console.warn(
|
|
112
|
+
`[UKit] Skipping install metadata write — install.json lock stayed held past the wait budget (${installMetaPath}). Tracked files for this run may be incomplete.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
98
115
|
}
|
|
99
116
|
|
|
100
117
|
export async function removeTrackedPathsFromMetadata({
|
|
101
118
|
installMetaPath,
|
|
102
119
|
projectRoot,
|
|
103
120
|
removeRelativePaths = [],
|
|
121
|
+
lockStaleMs,
|
|
122
|
+
lockMaxWaitMs,
|
|
104
123
|
}) {
|
|
105
124
|
if (!removeRelativePaths.length) {
|
|
106
125
|
return;
|
|
107
126
|
}
|
|
108
127
|
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const targetPaths = [...new Set(removeRelativePaths
|
|
115
|
-
.map((relPath) => (typeof relPath === 'string' ? relPath.trim().replace(/\\/g, '/') : ''))
|
|
116
|
-
.filter(Boolean))];
|
|
128
|
+
const removeResult = await withFileLock(installMetaPath, async () => {
|
|
129
|
+
const metadata = await readJsonIfExists(installMetaPath);
|
|
130
|
+
if (!metadata || metadata.tool !== 'ukit' || !Array.isArray(metadata.files)) {
|
|
131
|
+
return 'noop';
|
|
132
|
+
}
|
|
117
133
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
134
|
+
const targetPaths = [...new Set(removeRelativePaths
|
|
135
|
+
.map((relPath) => (typeof relPath === 'string' ? relPath.trim().replace(/\\/g, '/') : ''))
|
|
136
|
+
.filter(Boolean))];
|
|
137
|
+
|
|
138
|
+
const filteredFiles = metadata.files.filter((entry) => {
|
|
139
|
+
const p = typeof entry === 'string' ? entry : entry?.p;
|
|
140
|
+
if (!p) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
const normalized = p.trim().replace(/\\/g, '/');
|
|
144
|
+
return !targetPaths.some((targetPath) => isSameOrDescendantPath(normalized, targetPath));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (filteredFiles.length === metadata.files.length) {
|
|
148
|
+
return 'noop';
|
|
122
149
|
}
|
|
123
|
-
const normalized = p.trim().replace(/\\/g, '/');
|
|
124
|
-
return !targetPaths.some((targetPath) => isSameOrDescendantPath(normalized, targetPath));
|
|
125
|
-
});
|
|
126
150
|
|
|
127
|
-
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
151
|
+
await writeJson(installMetaPath, { ...metadata, files: filteredFiles });
|
|
152
|
+
return 'written';
|
|
153
|
+
}, { staleMs: lockStaleMs, maxWaitMs: lockMaxWaitMs });
|
|
130
154
|
|
|
131
|
-
|
|
155
|
+
if (removeResult === undefined) {
|
|
156
|
+
// Same explicit-degrade contract as writeInstallMetadata: warn, skip the
|
|
157
|
+
// write, never mutate install.json unlocked.
|
|
158
|
+
console.warn(
|
|
159
|
+
`[UKit] Skipping install metadata update — install.json lock stayed held past the wait budget (${installMetaPath}). Removed paths may still be listed as tracked.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
132
162
|
}
|
package/src/core/token/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
-
import { readJsonIfExists, writeJson } from '../fileOps.js';
|
|
2
|
+
import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
|
|
3
3
|
import { buildRuntimePaths } from '../runtimePaths.js';
|
|
4
4
|
|
|
5
5
|
export const DEFAULT_PROMPT_CACHE_MAX_ENTRIES = 20;
|
|
@@ -265,15 +265,39 @@ export async function readPromptCacheEntry(
|
|
|
265
265
|
lastHitAt: Date.now(),
|
|
266
266
|
hitCount: (entry.hitCount ?? 0) + 1,
|
|
267
267
|
};
|
|
268
|
-
const entries = [
|
|
269
|
-
touchedEntry,
|
|
270
|
-
...cache.entries.slice(0, entryIndex),
|
|
271
|
-
...cache.entries.slice(entryIndex + 1),
|
|
272
|
-
].slice(0, maxEntries);
|
|
273
268
|
try {
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
269
|
+
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
270
|
+
// SPEC §5 FR-019: the touch's read-merge-write runs under the cache-file
|
|
271
|
+
// lock — concurrent hook processes used to merge over the same snapshot
|
|
272
|
+
// and drop hitCounts/entries. The lock RE-READS the document so the merge
|
|
273
|
+
// applies to the freshest state, and an entry a concurrent delete already
|
|
274
|
+
// dropped is never resurrected.
|
|
275
|
+
const locked = await withFileLock(runtimePaths.promptCachePath, async () => {
|
|
276
|
+
const lockedCache = await readPromptCacheDocument(projectRoot, { maxEntries });
|
|
277
|
+
const lockedIndex = lockedCache.entries.findIndex(
|
|
278
|
+
(lockedEntry) => lockedEntry.requestKey === requestKey,
|
|
279
|
+
);
|
|
280
|
+
if (lockedIndex < 0) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const mergedEntry = {
|
|
284
|
+
...lockedCache.entries[lockedIndex],
|
|
285
|
+
updatedAt: Date.now(),
|
|
286
|
+
lastHitAt: Date.now(),
|
|
287
|
+
hitCount: (lockedCache.entries[lockedIndex].hitCount ?? 0) + 1,
|
|
288
|
+
};
|
|
289
|
+
const entries = [
|
|
290
|
+
mergedEntry,
|
|
291
|
+
...lockedCache.entries.slice(0, lockedIndex),
|
|
292
|
+
...lockedCache.entries.slice(lockedIndex + 1),
|
|
293
|
+
].slice(0, maxEntries);
|
|
294
|
+
const nextDocument = normalizePromptCacheDocument({ entries }, { maxEntries });
|
|
295
|
+
await writePromptCacheDocument(projectRoot, nextDocument);
|
|
296
|
+
return nextDocument.entries[0] ?? mergedEntry;
|
|
297
|
+
});
|
|
298
|
+
// Lock-busy (fail-closed skip, journaled) or entry-gone-under-lock →
|
|
299
|
+
// degrade to the in-memory touched entry, same as the failure path below.
|
|
300
|
+
return locked ?? touchedEntry;
|
|
277
301
|
} catch {
|
|
278
302
|
// TASK-006 (SPEC §5 FR-009): the touch-write is a side effect of a READ —
|
|
279
303
|
// a full/unwritable cache (EACCES, ENOSPC, ...) must never reject the
|
|
@@ -295,14 +319,23 @@ export async function writePromptCacheEntry(
|
|
|
295
319
|
return readPromptCacheDocument(projectRoot, { maxEntries });
|
|
296
320
|
}
|
|
297
321
|
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
322
|
+
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
323
|
+
// SPEC §5 FR-019: the merge must re-read under the lock — merging over a
|
|
324
|
+
// pre-lock snapshot is the lost-update race this lock exists to close, and
|
|
325
|
+
// no code path may write the cache unlocked.
|
|
326
|
+
const lockedDocument = await withFileLock(runtimePaths.promptCachePath, async () => {
|
|
327
|
+
const cache = await readPromptCacheDocument(projectRoot, { maxEntries });
|
|
328
|
+
const entries = [
|
|
329
|
+
normalizedEntry,
|
|
330
|
+
...cache.entries.filter((existingEntry) => existingEntry.requestKey !== normalizedEntry.requestKey),
|
|
331
|
+
].slice(0, maxEntries);
|
|
332
|
+
const nextDocument = normalizePromptCacheDocument({ entries }, { maxEntries });
|
|
333
|
+
await writePromptCacheDocument(projectRoot, nextDocument);
|
|
334
|
+
return nextDocument;
|
|
335
|
+
});
|
|
336
|
+
// Lock-busy → the write was skipped (journaled); return the freshest doc so
|
|
337
|
+
// callers keep a consistent shape without an unlocked write.
|
|
338
|
+
return lockedDocument ?? readPromptCacheDocument(projectRoot, { maxEntries });
|
|
306
339
|
}
|
|
307
340
|
|
|
308
341
|
/**
|
|
@@ -317,18 +350,26 @@ export async function deletePromptCacheEntries(projectRoot, { selectedIds } = {}
|
|
|
317
350
|
try {
|
|
318
351
|
const ids = new Set(Array.isArray(selectedIds) ? selectedIds : []);
|
|
319
352
|
if (ids.size === 0) return { removed: 0 };
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
353
|
+
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
354
|
+
// SPEC §5 FR-019: the filter+rewrite is a read-merge-write and runs under
|
|
355
|
+
// the cache-file lock — a purge racing a cache writer must not resurrect
|
|
356
|
+
// (or lose) entries. Lock-busy → the purge is skipped (journaled) and the
|
|
357
|
+
// function reports its usual no-op shape.
|
|
358
|
+
const locked = await withFileLock(runtimePaths.promptCachePath, async () => {
|
|
359
|
+
const cache = await readPromptCacheDocument(projectRoot);
|
|
360
|
+
if (cache.entries.length === 0) return { removed: 0 };
|
|
361
|
+
const kept = cache.entries.filter((entry) => {
|
|
362
|
+
const entryIds = Array.isArray(entry?.metadata?.selectedIds)
|
|
363
|
+
? entry.metadata.selectedIds
|
|
364
|
+
: [];
|
|
365
|
+
return !entryIds.some((id) => ids.has(id));
|
|
366
|
+
});
|
|
367
|
+
const removed = cache.entries.length - kept.length;
|
|
368
|
+
if (removed === 0) return { removed: 0 };
|
|
369
|
+
await writePromptCacheDocument(projectRoot, { entries: kept });
|
|
370
|
+
return { removed };
|
|
327
371
|
});
|
|
328
|
-
|
|
329
|
-
if (removed === 0) return { removed: 0 };
|
|
330
|
-
await writePromptCacheDocument(projectRoot, { entries: kept });
|
|
331
|
-
return { removed };
|
|
372
|
+
return locked ?? { removed: 0 };
|
|
332
373
|
} catch {
|
|
333
374
|
return { removed: 0 };
|
|
334
375
|
}
|
package/src/index/buildIndex.js
CHANGED
|
@@ -697,6 +697,13 @@ async function resolveChangedHintUpdates(rootDir, changedPaths, previousRecordsB
|
|
|
697
697
|
if (!isSafeContainedRelative(rootDir, relativePath)) {
|
|
698
698
|
return null;
|
|
699
699
|
}
|
|
700
|
+
// Hints under directories discovery never indexes are no-ops, not trust
|
|
701
|
+
// failures: `node_modules/`, `.cache/` and friends bypassed this lane and
|
|
702
|
+
// entered the index as phantom rows that kept the index perpetually stale.
|
|
703
|
+
// Same predicate as collectGitTrackedFiles.
|
|
704
|
+
if (relativePath.split('/').some((segment) => EXCLUDED_DIR_NAMES.has(segment))) {
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
700
707
|
|
|
701
708
|
const absolutePath = path.join(rootDir, relativePath);
|
|
702
709
|
let stat = null;
|
|
@@ -31,6 +31,17 @@ async function readArtifact(rootDir, artifactName) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Returns the artifact's item rows as plain objects, dropping poisoned rows
|
|
35
|
+
// (null, undefined, primitives). Hand-corrupted or truncated `.cache/index/*.json`
|
|
36
|
+
// files can carry `items: [null]` or `items: "corrupted"` — every consumer that
|
|
37
|
+
// dereferences `item.filePath` must see only object rows.
|
|
38
|
+
function safeItems(artifact) {
|
|
39
|
+
if (!artifact || typeof artifact !== 'object' || !Array.isArray(artifact.items)) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
return artifact.items.filter((entry) => entry && typeof entry === 'object');
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
function normalizePath(filePath) {
|
|
35
46
|
return String(filePath ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
|
|
36
47
|
}
|
|
@@ -93,9 +104,8 @@ export async function resolveImpactContext({
|
|
|
93
104
|
readArtifact(absoluteRoot, INDEX_ARTIFACTS.files),
|
|
94
105
|
loadRelatedTestArtifacts({ rootDir: absoluteRoot }),
|
|
95
106
|
]);
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const importItems = importsArtifact.items ?? [];
|
|
107
|
+
const callItems = safeItems(callsArtifact);
|
|
108
|
+
const importItems = safeItems(importsArtifact);
|
|
99
109
|
const risk = classifyImpactRisk(normalizedChangedFiles);
|
|
100
110
|
const mirrorCounterparts = findMirrorCounterparts(normalizedChangedFiles).slice(0, budget.maxMirrors);
|
|
101
111
|
|
|
@@ -145,8 +155,7 @@ export async function resolveImpactContext({
|
|
|
145
155
|
// Non-relative specifiers (aliases like `@/x`, `~/x`, tsconfig paths) resolve
|
|
146
156
|
// through the same machinery the index builder uses; relative specifiers keep
|
|
147
157
|
// the candidate-list match so a changed file absent from files.json (e.g. a
|
|
148
|
-
|
|
149
|
-
const indexedFileSet = new Set((filesArtifact.items ?? []).map((item) => item.filePath));
|
|
158
|
+
const indexedFileSet = new Set(safeItems(filesArtifact).map((item) => item.filePath));
|
|
150
159
|
const importAliasContext = importsNeedAliasContext(importItems)
|
|
151
160
|
? await loadImportAliasContext({ rootDir: absoluteRoot }).catch(() => null)
|
|
152
161
|
: null;
|
|
@@ -19,19 +19,48 @@ import { renderTemplateString } from './renderTemplate.js';
|
|
|
19
19
|
|
|
20
20
|
export const PREAMBLE_KEY = '__preamble__';
|
|
21
21
|
|
|
22
|
-
const
|
|
22
|
+
const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/;
|
|
23
|
+
const FENCE_CLOSE_RES = {
|
|
24
|
+
'`': /^ {0,3}(`{3,})\s*$/,
|
|
25
|
+
'~': /^ {0,3}(~{3,})\s*$/,
|
|
26
|
+
};
|
|
23
27
|
const LEFTOVER_TOKEN_RE = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/;
|
|
24
28
|
|
|
25
29
|
/**
|
|
26
30
|
* Split markdown into a Map<heading, body> at `## ` boundaries.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
31
|
+
* The reserved key PREAMBLE_KEY holds the leading non-## block verbatim.
|
|
32
|
+
* Bodies are raw slices (leading+trailing whitespace byte-meaningful).
|
|
33
|
+
*
|
|
34
|
+
* FR-017 (SPEC §5): heading detection is fence-aware — a `## ` line inside a
|
|
35
|
+
* fenced code block is content, not a boundary. Fences follow CommonMark: the
|
|
36
|
+
* opener is ``` or ~~~ indented ≤3 spaces; the closer uses the same character
|
|
37
|
+
* at the same or greater length with nothing after it. Duplicate `## `
|
|
38
|
+
* headings warn and keep the FIRST boundary (the duplicate line and its text
|
|
39
|
+
* stay inside the first section's verbatim body — no content is dropped).
|
|
30
40
|
*/
|
|
31
41
|
export function parseSections(markdown) {
|
|
42
|
+
// Collect `## ` heading boundaries by line scan, tracking fence state.
|
|
43
|
+
// Byte offsets are maintained manually so bodies stay raw slices.
|
|
44
|
+
const boundaries = []; // {index, matchLength, heading}
|
|
45
|
+
let fence = null; // {char, length} of the open fence
|
|
46
|
+
let offset = 0;
|
|
47
|
+
for (const line of markdown.split('\n')) {
|
|
48
|
+
if (fence) {
|
|
49
|
+
const close = line.match(FENCE_CLOSE_RES[fence.char]);
|
|
50
|
+
if (close && close[1].length >= fence.length) fence = null;
|
|
51
|
+
} else {
|
|
52
|
+
const open = line.match(FENCE_OPEN_RE);
|
|
53
|
+
if (open) {
|
|
54
|
+
fence = { char: open[1][0], length: open[1].length };
|
|
55
|
+
} else if (line.startsWith('## ') && line.length > 3) {
|
|
56
|
+
boundaries.push({ index: offset, matchLength: line.length, heading: line.slice(3).trimEnd() });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
offset += line.length + 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
const sections = new Map();
|
|
33
|
-
const
|
|
34
|
-
const first = matches[0];
|
|
63
|
+
const first = boundaries[0];
|
|
35
64
|
|
|
36
65
|
const preamble = first ? markdown.slice(0, first.index) : markdown;
|
|
37
66
|
if (preamble.trim().length > 0) {
|
|
@@ -40,18 +69,30 @@ export function parseSections(markdown) {
|
|
|
40
69
|
sections.set(PREAMBLE_KEY, preamble);
|
|
41
70
|
}
|
|
42
71
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
72
|
+
// Seed with the reserved key so a literal `## __preamble__` heading after a
|
|
73
|
+
// preamble degrades to keep-first instead of overwriting it.
|
|
74
|
+
const seen = new Set(sections.has(PREAMBLE_KEY) ? [PREAMBLE_KEY] : []);
|
|
75
|
+
const heads = [];
|
|
76
|
+
for (const b of boundaries) {
|
|
77
|
+
if (seen.has(b.heading)) {
|
|
78
|
+
console.warn(
|
|
79
|
+
`instructionRenderer: duplicate ## heading '${b.heading}' — keeping first occurrence`,
|
|
80
|
+
);
|
|
81
|
+
continue;
|
|
48
82
|
}
|
|
49
|
-
|
|
50
|
-
|
|
83
|
+
seen.add(b.heading);
|
|
84
|
+
heads.push(b);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < heads.length; i++) {
|
|
88
|
+
const h = heads[i];
|
|
89
|
+
const bodyStart = h.index + h.matchLength;
|
|
90
|
+
const bodyEnd = i + 1 < heads.length ? heads[i + 1].index : markdown.length;
|
|
51
91
|
// Raw slice up to (not including) the next `## ` heading — leading AND
|
|
52
92
|
// trailing whitespace are byte-meaningful (marker line vs blank line
|
|
53
|
-
// under the heading; multi-blank separators between sections).
|
|
54
|
-
|
|
93
|
+
// under the heading; multi-blank separators between sections). A skipped
|
|
94
|
+
// duplicate boundary leaves its line inside this slice.
|
|
95
|
+
sections.set(h.heading, markdown.slice(bodyStart, bodyEnd));
|
|
55
96
|
}
|
|
56
97
|
|
|
57
98
|
return sections;
|
|
@@ -111,8 +152,10 @@ export function renderOutput(outputSpec, sources, vars = {}) {
|
|
|
111
152
|
out = renderTemplateString(out, vars);
|
|
112
153
|
const leftover = out.match(LEFTOVER_TOKEN_RE);
|
|
113
154
|
if (leftover) {
|
|
114
|
-
|
|
115
|
-
|
|
155
|
+
// FR-017(d): warn + emit the token verbatim instead of bricking
|
|
156
|
+
// install/render on an imperfect user overlay.
|
|
157
|
+
console.warn(
|
|
158
|
+
`instructionRenderer: output '${outputSpec.target}' has unresolved template variable '{{${leftover[1]}}}' — emitted verbatim`,
|
|
116
159
|
);
|
|
117
160
|
}
|
|
118
161
|
}
|
|
@@ -124,8 +167,10 @@ export function renderOutput(outputSpec, sources, vars = {}) {
|
|
|
124
167
|
* Source names are looked up as `sources.get(ref.from)`; overlay sources are
|
|
125
168
|
* named `overlay:<file-stem>` by convention in renderInstructionsFromDisk.
|
|
126
169
|
* `varsByResolve` supplies variables to outputs with resolve_vars: true.
|
|
127
|
-
* FR-006 strictness: a `## ` section
|
|
128
|
-
*
|
|
170
|
+
* FR-006 strictness: a `## ` section referenced by zero outputs throws for
|
|
171
|
+
* shipped sources, preserving the layout contract. FR-017(c): the same
|
|
172
|
+
* condition in a USER overlay (`overlay:*` source name) downgrades to
|
|
173
|
+
* console.warn + keep — an imperfect repo.md must not brick install/render.
|
|
129
174
|
*/
|
|
130
175
|
export function renderAll(layout, sourceTexts, varsByResolve = {}) {
|
|
131
176
|
if (!layout || !Array.isArray(layout.outputs) || layout.outputs.length === 0) {
|
|
@@ -156,9 +201,16 @@ export function renderAll(layout, sourceTexts, varsByResolve = {}) {
|
|
|
156
201
|
}
|
|
157
202
|
}
|
|
158
203
|
for (const [name, source] of sources) {
|
|
204
|
+
const isOverlay = name.startsWith('overlay:');
|
|
159
205
|
for (const heading of source.keys()) {
|
|
160
206
|
if (heading === PREAMBLE_KEY) continue;
|
|
161
207
|
if (!referenced.get(name) || !referenced.get(name).has(heading)) {
|
|
208
|
+
if (isOverlay) {
|
|
209
|
+
console.warn(
|
|
210
|
+
`instructionRenderer: overlay source '${name}' has ## section '${heading}' referenced by zero outputs — kept`,
|
|
211
|
+
);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
162
214
|
throw new Error(`source '${name}': ## section '${heading}' referenced by zero outputs`);
|
|
163
215
|
}
|
|
164
216
|
}
|