@planu/cli 4.10.12 → 4.11.1
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 +16 -0
- package/dist/config/project-knowledge-graph.json +42 -5
- package/dist/engine/core-bridge-project-graph.d.ts +13 -0
- package/dist/engine/core-bridge-project-graph.js +499 -0
- package/dist/engine/core-bridge.d.ts +7 -2
- package/dist/engine/core-bridge.js +55 -0
- package/dist/engine/frontmatter-parser.js +73 -23
- package/dist/engine/model-tier-resolver.d.ts +8 -7
- package/dist/engine/model-tier-resolver.js +70 -73
- package/dist/engine/next-spec-resolver/orchestration-planner.d.ts +5 -0
- package/dist/engine/next-spec-resolver/orchestration-planner.js +34 -5
- package/dist/engine/project-graph/builder.js +271 -36
- package/dist/engine/project-graph/cache.d.ts +22 -4
- package/dist/engine/project-graph/cache.js +412 -33
- package/dist/engine/project-graph/index.d.ts +1 -0
- package/dist/engine/project-graph/index.js +1 -0
- package/dist/engine/project-graph/native.d.ts +3 -0
- package/dist/engine/project-graph/native.js +36 -0
- package/dist/engine/project-graph/query.js +34 -2
- package/dist/engine/provider-adapters/adapters/claude.js +38 -14
- package/dist/engine/scan-project/index.js +88 -15
- package/dist/engine/spec-format/lean-spec-generator.d.ts +2 -2
- package/dist/engine/spec-format/lean-spec-generator.js +65 -50
- package/dist/engine/spec-format/metadata-value-policy.d.ts +161 -0
- package/dist/engine/spec-format/metadata-value-policy.js +87 -0
- package/dist/engine/spec-format/value-only-spec-serializer.d.ts +12 -0
- package/dist/engine/spec-format/value-only-spec-serializer.js +18 -0
- package/dist/engine/spec-generator/fallback-generator.js +4 -2
- package/dist/engine/spec-generator/opus-generator.js +5 -2
- package/dist/engine/spec-migrator/lean-migration.js +26 -13
- package/dist/storage/spec-store.js +6 -6
- package/dist/tools/create-spec.js +1027 -739
- package/dist/tools/render-spec-for-provider.js +4 -3
- package/dist/tools/reverse-engineer/handler.js +76 -43
- package/dist/tools/spec-split-handler.js +36 -75
- package/dist/types/conventions.d.ts +9 -0
- package/dist/types/core-bridge.d.ts +72 -0
- package/dist/types/next-spec.d.ts +2 -1
- package/dist/types/project-knowledge-graph.d.ts +58 -0
- package/dist/types/spec/core.d.ts +7 -4
- package/dist/types/spec-format.d.ts +2 -1
- package/dist/types/spec-generator.d.ts +8 -2
- package/package.json +11 -9
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
- package/dist/engine/spec-format/model-budget-deriver.d.ts +0 -5
- package/dist/engine/spec-format/model-budget-deriver.js +0 -7
|
@@ -1,10 +1,29 @@
|
|
|
1
|
-
|
|
1
|
+
/* eslint-disable max-lines -- cache validation, collection, and freshness share one artifact contract. */
|
|
2
|
+
// @crash-shield-ignore-file - graph artifacts are Planu-owned JSON written by this subsystem.
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
4
|
-
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { open, readdir, readFile, stat } from 'node:fs/promises';
|
|
5
|
+
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
7
|
import { fileURLToPath } from 'node:url';
|
|
6
8
|
import { projectDataDir, readJson, writeJson } from '../../storage/base-store.js';
|
|
9
|
+
import { LockBusyError, withSpecLock } from '../safety/cross-process-lock.js';
|
|
7
10
|
const POLICY_PATH = join(dirname(fileURLToPath(import.meta.url)), '../../config/project-knowledge-graph.json');
|
|
11
|
+
const CACHE_VERSION = 2;
|
|
12
|
+
const PROJECT_CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.json', '.md']);
|
|
13
|
+
const PROJECT_CODE_IGNORED_DIRS = new Set([
|
|
14
|
+
'node_modules',
|
|
15
|
+
'.git',
|
|
16
|
+
'dist',
|
|
17
|
+
'build',
|
|
18
|
+
'coverage',
|
|
19
|
+
'.next',
|
|
20
|
+
'target',
|
|
21
|
+
'npm',
|
|
22
|
+
]);
|
|
23
|
+
const CONTENT_HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
24
|
+
const PROJECT_GRAPH_LOCK_WAIT_MS = 30_000;
|
|
25
|
+
const buildTails = new Map();
|
|
26
|
+
const activeBuilds = new Map();
|
|
8
27
|
export async function loadProjectGraphPolicy() {
|
|
9
28
|
const raw = await readFile(POLICY_PATH, 'utf-8');
|
|
10
29
|
return JSON.parse(raw);
|
|
@@ -12,6 +31,9 @@ export async function loadProjectGraphPolicy() {
|
|
|
12
31
|
export function hashText(text) {
|
|
13
32
|
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
14
33
|
}
|
|
34
|
+
export function projectGraphExtractorVersion(kind) {
|
|
35
|
+
return `project-graph-${kind}-v2`;
|
|
36
|
+
}
|
|
15
37
|
export function projectGraphDir(projectId, policy) {
|
|
16
38
|
return join(projectDataDir(projectId), policy.artifactPaths.directory);
|
|
17
39
|
}
|
|
@@ -21,44 +43,282 @@ export function projectGraphPath(projectId, policy) {
|
|
|
21
43
|
export function projectGraphCachePath(projectId, policy) {
|
|
22
44
|
return join(projectGraphDir(projectId, policy), policy.artifactPaths.sourceHashes);
|
|
23
45
|
}
|
|
24
|
-
|
|
46
|
+
function isObject(value) {
|
|
47
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
48
|
+
}
|
|
49
|
+
function isCacheRecord(value) {
|
|
50
|
+
if (!isObject(value)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return (typeof value.sourceId === 'string' &&
|
|
54
|
+
typeof value.path === 'string' &&
|
|
55
|
+
typeof value.kind === 'string' &&
|
|
56
|
+
typeof value.contentHash === 'string' &&
|
|
57
|
+
CONTENT_HASH_PATTERN.test(value.contentHash) &&
|
|
58
|
+
typeof value.extractorVersion === 'string' &&
|
|
59
|
+
typeof value.nativeCoreVersion === 'string' &&
|
|
60
|
+
typeof value.size === 'number' &&
|
|
61
|
+
Number.isFinite(value.size) &&
|
|
62
|
+
typeof value.mtimeMs === 'number' &&
|
|
63
|
+
Number.isFinite(value.mtimeMs));
|
|
64
|
+
}
|
|
65
|
+
function isCacheFragment(value) {
|
|
66
|
+
if (!isObject(value)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return (value.version === 1 &&
|
|
70
|
+
typeof value.sourceId === 'string' &&
|
|
71
|
+
typeof value.kind === 'string' &&
|
|
72
|
+
typeof value.contentHash === 'string' &&
|
|
73
|
+
CONTENT_HASH_PATTERN.test(value.contentHash) &&
|
|
74
|
+
typeof value.extractorVersion === 'string' &&
|
|
75
|
+
typeof value.nativeCoreVersion === 'string' &&
|
|
76
|
+
Array.isArray(value.nodes) &&
|
|
77
|
+
value.nodes.every((node) => isObject(node) && typeof node.id === 'string' && typeof node.type === 'string') &&
|
|
78
|
+
Array.isArray(value.edges) &&
|
|
79
|
+
value.edges.every((edge) => isObject(edge) &&
|
|
80
|
+
typeof edge.id === 'string' &&
|
|
81
|
+
typeof edge.from === 'string' &&
|
|
82
|
+
typeof edge.to === 'string' &&
|
|
83
|
+
typeof edge.type === 'string'));
|
|
84
|
+
}
|
|
85
|
+
function kindFromLegacySourceId(sourceId) {
|
|
86
|
+
if (sourceId.startsWith('spec:')) {
|
|
87
|
+
return 'spec';
|
|
88
|
+
}
|
|
89
|
+
if (sourceId.startsWith('handoff:')) {
|
|
90
|
+
return 'handoff';
|
|
91
|
+
}
|
|
92
|
+
if (sourceId.startsWith('project-code:')) {
|
|
93
|
+
return 'project-code';
|
|
94
|
+
}
|
|
95
|
+
if (sourceId === 'decisions') {
|
|
96
|
+
return 'decisions';
|
|
97
|
+
}
|
|
98
|
+
if (sourceId === 'release-events') {
|
|
99
|
+
return 'release-events';
|
|
100
|
+
}
|
|
101
|
+
return sourceId;
|
|
102
|
+
}
|
|
103
|
+
function legacyRecords(raw) {
|
|
104
|
+
const records = {};
|
|
105
|
+
for (const [sourceId, value] of Object.entries(raw)) {
|
|
106
|
+
const objectValue = isObject(value) ? value : {};
|
|
107
|
+
const contentHash = typeof value === 'string'
|
|
108
|
+
? value
|
|
109
|
+
: typeof objectValue.contentHash === 'string'
|
|
110
|
+
? objectValue.contentHash
|
|
111
|
+
: '';
|
|
112
|
+
records[sourceId] = {
|
|
113
|
+
sourceId,
|
|
114
|
+
path: typeof objectValue.path === 'string' ? objectValue.path : sourceId,
|
|
115
|
+
kind: typeof objectValue.kind === 'string' ? objectValue.kind : kindFromLegacySourceId(sourceId),
|
|
116
|
+
contentHash,
|
|
117
|
+
extractorVersion: 'legacy',
|
|
118
|
+
nativeCoreVersion: 'legacy',
|
|
119
|
+
size: -1,
|
|
120
|
+
mtimeMs: -1,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return records;
|
|
124
|
+
}
|
|
125
|
+
export async function readProjectGraphCache(projectId, policy) {
|
|
126
|
+
let parsed;
|
|
127
|
+
try {
|
|
128
|
+
parsed = JSON.parse(await readFile(projectGraphCachePath(projectId, policy), 'utf-8'));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return { cache: null, knownSourceIds: [], legacy: false };
|
|
132
|
+
}
|
|
133
|
+
if (!isObject(parsed)) {
|
|
134
|
+
return { cache: null, knownSourceIds: [], legacy: false };
|
|
135
|
+
}
|
|
136
|
+
if (parsed.version !== CACHE_VERSION) {
|
|
137
|
+
const records = legacyRecords(parsed);
|
|
138
|
+
return {
|
|
139
|
+
cache: {
|
|
140
|
+
version: CACHE_VERSION,
|
|
141
|
+
generation: '',
|
|
142
|
+
records,
|
|
143
|
+
fragments: {},
|
|
144
|
+
},
|
|
145
|
+
knownSourceIds: Object.keys(parsed),
|
|
146
|
+
legacy: true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const rawRecords = isObject(parsed.records) ? parsed.records : {};
|
|
150
|
+
const rawFragments = isObject(parsed.fragments) ? parsed.fragments : {};
|
|
151
|
+
const records = Object.fromEntries(Object.entries(rawRecords).filter((entry) => isCacheRecord(entry[1])));
|
|
152
|
+
const fragments = Object.fromEntries(Object.entries(rawFragments).filter((entry) => isCacheFragment(entry[1])));
|
|
153
|
+
return {
|
|
154
|
+
cache: {
|
|
155
|
+
version: CACHE_VERSION,
|
|
156
|
+
generation: typeof parsed.generation === 'string' ? parsed.generation : '',
|
|
157
|
+
records,
|
|
158
|
+
fragments,
|
|
159
|
+
},
|
|
160
|
+
knownSourceIds: Object.keys(rawRecords),
|
|
161
|
+
legacy: false,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export async function readProjectGraphArtifact(projectId, policy) {
|
|
25
165
|
return readJson(projectGraphPath(projectId, policy), null);
|
|
26
166
|
}
|
|
167
|
+
export async function readProjectGraph(projectId, policy) {
|
|
168
|
+
const key = projectGraphDir(projectId, policy);
|
|
169
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
170
|
+
const active = activeBuilds.get(key);
|
|
171
|
+
if (active !== undefined) {
|
|
172
|
+
await active;
|
|
173
|
+
}
|
|
174
|
+
const [graph, cacheResult] = await Promise.all([
|
|
175
|
+
readProjectGraphArtifact(projectId, policy),
|
|
176
|
+
readProjectGraphCache(projectId, policy),
|
|
177
|
+
]);
|
|
178
|
+
if (graph === null) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
if (typeof graph.generation === 'string' &&
|
|
182
|
+
graph.generation.length > 0 &&
|
|
183
|
+
cacheResult.cache?.generation === graph.generation) {
|
|
184
|
+
return graph;
|
|
185
|
+
}
|
|
186
|
+
if (!activeBuilds.has(key)) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
27
192
|
export async function writeProjectGraph(projectId, policy, graph) {
|
|
28
193
|
await writeJson(projectGraphPath(projectId, policy), graph);
|
|
29
194
|
}
|
|
30
195
|
export async function readProjectGraphSourceHashes(projectId, policy) {
|
|
31
|
-
|
|
196
|
+
const active = activeBuilds.get(projectGraphDir(projectId, policy));
|
|
197
|
+
if (active !== undefined) {
|
|
198
|
+
await active;
|
|
199
|
+
}
|
|
200
|
+
return (await readProjectGraphCache(projectId, policy)).cache?.records ?? {};
|
|
32
201
|
}
|
|
33
202
|
export async function writeProjectGraphSourceHashes(projectId, policy, hashes) {
|
|
34
|
-
await
|
|
203
|
+
const current = await readProjectGraphCache(projectId, policy);
|
|
204
|
+
await writeJson(projectGraphCachePath(projectId, policy), {
|
|
205
|
+
version: CACHE_VERSION,
|
|
206
|
+
generation: current.cache?.generation ?? '',
|
|
207
|
+
records: hashes,
|
|
208
|
+
fragments: current.cache?.fragments ?? {},
|
|
209
|
+
});
|
|
35
210
|
}
|
|
36
|
-
export function
|
|
211
|
+
export async function writeProjectGraphArtifacts(args) {
|
|
212
|
+
if (args.graph.generation.length === 0 ||
|
|
213
|
+
args.cache.generation.length === 0 ||
|
|
214
|
+
args.graph.generation !== args.cache.generation) {
|
|
215
|
+
throw new Error('[Planu] Project graph/cache generation mismatch');
|
|
216
|
+
}
|
|
217
|
+
// Readers verify the shared generation, so neither write order can expose a mixed snapshot.
|
|
218
|
+
await writeJson(projectGraphCachePath(args.projectId, args.policy), args.cache);
|
|
219
|
+
await writeJson(projectGraphPath(args.projectId, args.policy), args.graph);
|
|
220
|
+
}
|
|
221
|
+
export async function withProjectGraphBuildLock(projectId, policy, task) {
|
|
222
|
+
const key = projectGraphDir(projectId, policy);
|
|
223
|
+
const previous = buildTails.get(key) ?? Promise.resolve();
|
|
224
|
+
let release;
|
|
225
|
+
const slot = new Promise((resolveSlot) => {
|
|
226
|
+
release = resolveSlot;
|
|
227
|
+
});
|
|
228
|
+
const tail = previous.then(() => slot);
|
|
229
|
+
buildTails.set(key, tail);
|
|
230
|
+
await previous;
|
|
231
|
+
activeBuilds.set(key, slot);
|
|
232
|
+
try {
|
|
233
|
+
const startedAt = Date.now();
|
|
234
|
+
for (;;) {
|
|
235
|
+
try {
|
|
236
|
+
return await withSpecLock(key, 'PROJECT-GRAPH-BUILD', async () => task(), {
|
|
237
|
+
reason: `project graph build for ${projectId}`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (!(error instanceof LockBusyError) ||
|
|
242
|
+
Date.now() - startedAt >= PROJECT_GRAPH_LOCK_WAIT_MS) {
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
await delay(25);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
if (activeBuilds.get(key) === slot) {
|
|
251
|
+
activeBuilds.delete(key);
|
|
252
|
+
}
|
|
253
|
+
release?.();
|
|
254
|
+
if (buildTails.get(key) === tail) {
|
|
255
|
+
buildTails.delete(key);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function nativeVersionForSource(kind, nativeCoreVersion) {
|
|
260
|
+
return kind === 'project-code' ? (nativeCoreVersion ?? 'typescript-fallback') : 'not-applicable';
|
|
261
|
+
}
|
|
262
|
+
function recordMatchesSource(record, source, nativeCoreVersion) {
|
|
263
|
+
return (record?.sourceId === source.id &&
|
|
264
|
+
record.path === source.path &&
|
|
265
|
+
record.kind === source.kind &&
|
|
266
|
+
record.contentHash === source.hash &&
|
|
267
|
+
record.extractorVersion === projectGraphExtractorVersion(source.kind) &&
|
|
268
|
+
(source.kind !== 'project-code' ||
|
|
269
|
+
nativeCoreVersion === undefined ||
|
|
270
|
+
record.nativeCoreVersion === nativeCoreVersion));
|
|
271
|
+
}
|
|
272
|
+
function fragmentMatchesSource(fragment, source, nativeCoreVersion) {
|
|
273
|
+
return (fragment?.sourceId === source.id &&
|
|
274
|
+
fragment.kind === source.kind &&
|
|
275
|
+
fragment.contentHash === source.hash &&
|
|
276
|
+
fragment.extractorVersion === projectGraphExtractorVersion(source.kind) &&
|
|
277
|
+
(source.kind !== 'project-code' ||
|
|
278
|
+
nativeCoreVersion === undefined ||
|
|
279
|
+
fragment.nativeCoreVersion === nativeCoreVersion));
|
|
280
|
+
}
|
|
281
|
+
export function diffSourceHashes(sources, previous, nativeCoreVersion, options = {}) {
|
|
37
282
|
const next = Object.fromEntries(sources.map((source) => [source.id, source.hash]));
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
283
|
+
const records = Object.fromEntries(sources.map((source) => [
|
|
284
|
+
source.id,
|
|
285
|
+
{
|
|
286
|
+
sourceId: source.id,
|
|
287
|
+
path: source.path,
|
|
288
|
+
kind: source.kind,
|
|
289
|
+
contentHash: source.hash,
|
|
290
|
+
extractorVersion: projectGraphExtractorVersion(source.kind),
|
|
291
|
+
nativeCoreVersion: nativeVersionForSource(source.kind, nativeCoreVersion),
|
|
292
|
+
size: source.size ?? Buffer.byteLength(source.content),
|
|
293
|
+
mtimeMs: source.mtimeMs ?? 0,
|
|
294
|
+
},
|
|
295
|
+
]));
|
|
296
|
+
const canReuse = (source) => recordMatchesSource(previous[source.id], source, nativeCoreVersion) &&
|
|
297
|
+
(options.fragments === undefined ||
|
|
298
|
+
fragmentMatchesSource(options.fragments[source.id], source, nativeCoreVersion));
|
|
299
|
+
const changed = sources.filter((source) => !canReuse(source)).map((source) => source.id);
|
|
300
|
+
const skipped = sources.filter(canReuse).map((source) => source.id);
|
|
301
|
+
const currentIds = new Set(sources.map((source) => source.id));
|
|
302
|
+
const removed = [...new Set(options.knownSourceIds ?? Object.keys(previous))]
|
|
303
|
+
.filter((sourceId) => !currentIds.has(sourceId))
|
|
304
|
+
.sort();
|
|
305
|
+
return { changed, removed, skipped, next, records };
|
|
45
306
|
}
|
|
46
307
|
async function fileExists(path) {
|
|
47
308
|
try {
|
|
48
|
-
|
|
49
|
-
return info.isFile();
|
|
309
|
+
return (await stat(path)).isFile();
|
|
50
310
|
}
|
|
51
311
|
catch {
|
|
52
312
|
return false;
|
|
53
313
|
}
|
|
54
314
|
}
|
|
55
|
-
async function collectFiles(dir, predicate) {
|
|
315
|
+
async function collectFiles(dir, predicate, descend = () => true) {
|
|
56
316
|
try {
|
|
57
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
317
|
+
const entries = (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
58
318
|
const nested = await Promise.all(entries.map(async (entry) => {
|
|
59
319
|
const path = join(dir, entry.name);
|
|
60
|
-
if (entry.isDirectory()) {
|
|
61
|
-
return collectFiles(path, predicate);
|
|
320
|
+
if (entry.isDirectory() && descend(entry.name)) {
|
|
321
|
+
return collectFiles(path, predicate, descend);
|
|
62
322
|
}
|
|
63
323
|
return entry.isFile() && predicate(path) ? [path] : [];
|
|
64
324
|
}));
|
|
@@ -68,46 +328,160 @@ async function collectFiles(dir, predicate) {
|
|
|
68
328
|
return [];
|
|
69
329
|
}
|
|
70
330
|
}
|
|
331
|
+
async function readBoundedFile(descriptor, path, maxFileBytes) {
|
|
332
|
+
const info = await descriptor.stat();
|
|
333
|
+
if (info.size > maxFileBytes) {
|
|
334
|
+
throw new Error(`PROJECT_GRAPH_FILE_TOO_LARGE: ${path} exceeds maxFileBytes (${String(maxFileBytes)})`);
|
|
335
|
+
}
|
|
336
|
+
const chunks = [];
|
|
337
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
338
|
+
let total = 0;
|
|
339
|
+
for (;;) {
|
|
340
|
+
const { bytesRead } = await descriptor.read(buffer, 0, buffer.length, null);
|
|
341
|
+
if (bytesRead === 0) {
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
total += bytesRead;
|
|
345
|
+
if (total > maxFileBytes) {
|
|
346
|
+
throw new Error(`PROJECT_GRAPH_FILE_TOO_LARGE: ${path} grew beyond maxFileBytes (${String(maxFileBytes)})`);
|
|
347
|
+
}
|
|
348
|
+
chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
content: Buffer.concat(chunks, total).toString('utf8'),
|
|
352
|
+
size: total,
|
|
353
|
+
mtimeMs: info.mtimeMs,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
71
356
|
async function sourceFromFile(args) {
|
|
72
357
|
try {
|
|
73
|
-
const
|
|
74
|
-
|
|
358
|
+
const info = await stat(args.filePath);
|
|
359
|
+
if (!info.isFile()) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
if (info.size > args.maxFileBytes) {
|
|
363
|
+
throw new Error(`PROJECT_GRAPH_FILE_TOO_LARGE: ${args.path} exceeds maxFileBytes (${String(args.maxFileBytes)})`);
|
|
364
|
+
}
|
|
365
|
+
const previous = args.previous;
|
|
366
|
+
const expectedNativeVersion = nativeVersionForSource(args.kind, args.nativeCoreVersion);
|
|
367
|
+
if (previous?.path === args.path &&
|
|
368
|
+
previous.kind === args.kind &&
|
|
369
|
+
CONTENT_HASH_PATTERN.test(previous.contentHash) &&
|
|
370
|
+
previous.size === info.size &&
|
|
371
|
+
previous.mtimeMs === info.mtimeMs &&
|
|
372
|
+
previous.extractorVersion === projectGraphExtractorVersion(args.kind) &&
|
|
373
|
+
(args.kind !== 'project-code' ||
|
|
374
|
+
args.nativeCoreVersion === undefined ||
|
|
375
|
+
previous.nativeCoreVersion === expectedNativeVersion)) {
|
|
376
|
+
return {
|
|
377
|
+
id: args.id,
|
|
378
|
+
kind: args.kind,
|
|
379
|
+
path: args.path,
|
|
380
|
+
content: '',
|
|
381
|
+
hash: previous.contentHash,
|
|
382
|
+
size: info.size,
|
|
383
|
+
mtimeMs: info.mtimeMs,
|
|
384
|
+
contentLoaded: false,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
const descriptor = await open(args.filePath, 'r');
|
|
388
|
+
let bounded;
|
|
389
|
+
try {
|
|
390
|
+
bounded = await readBoundedFile(descriptor, args.path, args.maxFileBytes);
|
|
391
|
+
}
|
|
392
|
+
finally {
|
|
393
|
+
await descriptor.close();
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
id: args.id,
|
|
397
|
+
kind: args.kind,
|
|
398
|
+
path: args.path,
|
|
399
|
+
content: bounded.content,
|
|
400
|
+
hash: hashText(bounded.content),
|
|
401
|
+
size: bounded.size,
|
|
402
|
+
mtimeMs: bounded.mtimeMs,
|
|
403
|
+
contentLoaded: true,
|
|
404
|
+
};
|
|
75
405
|
}
|
|
76
|
-
catch {
|
|
406
|
+
catch (error) {
|
|
407
|
+
if (error instanceof Error && error.message.startsWith('PROJECT_GRAPH_')) {
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
77
410
|
return null;
|
|
78
411
|
}
|
|
79
412
|
}
|
|
413
|
+
export async function hydrateProjectGraphSource(source, maxFileBytes) {
|
|
414
|
+
if (source.contentLoaded !== false || source.kind === 'project-code') {
|
|
415
|
+
return source;
|
|
416
|
+
}
|
|
417
|
+
return sourceFromFile({
|
|
418
|
+
id: source.id,
|
|
419
|
+
kind: source.kind,
|
|
420
|
+
path: source.path,
|
|
421
|
+
filePath: source.path,
|
|
422
|
+
maxFileBytes,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// eslint-disable-next-line max-lines-per-function -- source inventory is normalized in one deterministic pass.
|
|
80
426
|
export async function collectProjectGraphSources(args) {
|
|
81
427
|
const projectRoot = resolve(args.projectPath);
|
|
82
428
|
const dataRoot = projectDataDir(args.projectId);
|
|
83
429
|
const specFiles = await collectFiles(join(projectRoot, 'planu', 'specs'), (path) => path.endsWith('/spec.md'));
|
|
84
430
|
const handoffFiles = await collectFiles(join(dataRoot, 'handoffs'), (path) => /\.(json|md)$/.test(path));
|
|
85
|
-
const validationFiles = handoffFiles.filter((path) => path.includes('validation'));
|
|
431
|
+
const validationFiles = new Set(handoffFiles.filter((path) => path.includes('validation')));
|
|
86
432
|
const decisions = join(dataRoot, 'decisions.json');
|
|
87
433
|
const transitionLog = join(dataRoot, 'transition-log.jsonl');
|
|
88
434
|
const releaseEvents = join(dataRoot, '..', '..', 'release-events.jsonl');
|
|
435
|
+
const maxFiles = args.policy.limits.operational.maxFiles;
|
|
436
|
+
const maxFileBytes = args.policy.limits.operational.maxFileBytes;
|
|
437
|
+
const codeFiles = (await collectFiles(projectRoot, (path) => PROJECT_CODE_EXTENSIONS.has(extname(path).toLowerCase()), (name) => !PROJECT_CODE_IGNORED_DIRS.has(name) && !name.startsWith('.'))).slice(0, maxFiles);
|
|
89
438
|
const raw = [
|
|
90
439
|
...specFiles.map((path) => ({
|
|
91
440
|
id: `spec:${relative(projectRoot, path)}`,
|
|
92
441
|
kind: 'spec',
|
|
93
442
|
path,
|
|
443
|
+
filePath: path,
|
|
94
444
|
})),
|
|
95
445
|
...handoffFiles.map((path) => ({
|
|
96
446
|
id: `handoff:${relative(dataRoot, path)}`,
|
|
97
|
-
kind: validationFiles.
|
|
447
|
+
kind: validationFiles.has(path) ? 'validation' : 'handoff',
|
|
98
448
|
path,
|
|
449
|
+
filePath: path,
|
|
99
450
|
})),
|
|
100
451
|
...((await fileExists(transitionLog))
|
|
101
|
-
? [
|
|
452
|
+
? [
|
|
453
|
+
{
|
|
454
|
+
id: 'transition-log',
|
|
455
|
+
kind: 'transition-log',
|
|
456
|
+
path: transitionLog,
|
|
457
|
+
filePath: transitionLog,
|
|
458
|
+
},
|
|
459
|
+
]
|
|
102
460
|
: []),
|
|
103
461
|
...((await fileExists(decisions))
|
|
104
|
-
? [{ id: 'decisions', kind: 'decisions', path: decisions }]
|
|
462
|
+
? [{ id: 'decisions', kind: 'decisions', path: decisions, filePath: decisions }]
|
|
105
463
|
: []),
|
|
106
464
|
...((await fileExists(releaseEvents))
|
|
107
|
-
? [
|
|
465
|
+
? [
|
|
466
|
+
{
|
|
467
|
+
id: 'release-events',
|
|
468
|
+
kind: 'release-events',
|
|
469
|
+
path: releaseEvents,
|
|
470
|
+
filePath: releaseEvents,
|
|
471
|
+
},
|
|
472
|
+
]
|
|
108
473
|
: []),
|
|
109
|
-
|
|
110
|
-
|
|
474
|
+
...codeFiles.map((filePath) => {
|
|
475
|
+
const path = relative(projectRoot, filePath).split('\\').join('/');
|
|
476
|
+
return { id: `project-code:${path}`, kind: 'project-code', path, filePath };
|
|
477
|
+
}),
|
|
478
|
+
].sort((a, b) => a.id.localeCompare(b.id));
|
|
479
|
+
const sources = await Promise.all(raw.map((source) => sourceFromFile({
|
|
480
|
+
...source,
|
|
481
|
+
previous: args.previous?.[source.id],
|
|
482
|
+
nativeCoreVersion: args.nativeCoreVersion,
|
|
483
|
+
maxFileBytes,
|
|
484
|
+
})));
|
|
111
485
|
return sources.filter((source) => source !== null);
|
|
112
486
|
}
|
|
113
487
|
export async function getProjectGraphFreshness(args) {
|
|
@@ -134,14 +508,19 @@ export async function getProjectGraphFreshness(args) {
|
|
|
134
508
|
};
|
|
135
509
|
}
|
|
136
510
|
}
|
|
511
|
+
const cacheResult = await readProjectGraphCache(args.projectId, policy);
|
|
512
|
+
const previous = cacheResult.cache?.records ?? {};
|
|
137
513
|
const sources = await collectProjectGraphSources({
|
|
138
514
|
projectId: args.projectId,
|
|
139
515
|
projectPath: args.projectPath,
|
|
140
516
|
policy,
|
|
517
|
+
previous,
|
|
518
|
+
});
|
|
519
|
+
const diff = diffSourceHashes(sources, previous, undefined, {
|
|
520
|
+
fragments: cacheResult.cache?.fragments,
|
|
521
|
+
knownSourceIds: cacheResult.knownSourceIds,
|
|
141
522
|
});
|
|
142
|
-
const changedSources =
|
|
143
|
-
.filter((source) => graph.sourceHashes[source.id] !== source.hash)
|
|
144
|
-
.map((source) => source.id);
|
|
523
|
+
const changedSources = [...diff.changed, ...diff.removed];
|
|
145
524
|
if (changedSources.length > 0) {
|
|
146
525
|
return {
|
|
147
526
|
exists: true,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { buildProjectKnowledgeGraph } from './builder.js';
|
|
2
2
|
export { queryProjectGraphSlice, formatProjectGraphContext, getProjectGraphFreshnessHint, } from './query.js';
|
|
3
3
|
export { redactGraphText } from './redaction.js';
|
|
4
|
+
export { createNativeProjectGraphRuntime } from './native.js';
|
|
4
5
|
export { getProjectGraphFreshness, loadProjectGraphPolicy, projectGraphPath, projectGraphCachePath, } from './cache.js';
|
|
5
6
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { buildProjectKnowledgeGraph } from './builder.js';
|
|
2
2
|
export { queryProjectGraphSlice, formatProjectGraphContext, getProjectGraphFreshnessHint, } from './query.js';
|
|
3
3
|
export { redactGraphText } from './redaction.js';
|
|
4
|
+
export { createNativeProjectGraphRuntime } from './native.js';
|
|
4
5
|
export { getProjectGraphFreshness, loadProjectGraphPolicy, projectGraphPath, projectGraphCachePath, } from './cache.js';
|
|
5
6
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { NativeProjectGraphRuntime, ProjectGraphPolicy } from '../../types/project-knowledge-graph.js';
|
|
2
|
+
export declare function createNativeProjectGraphRuntime(limitsPolicy: ProjectGraphPolicy['limits']): NativeProjectGraphRuntime;
|
|
3
|
+
//# sourceMappingURL=native.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { fastExtractTsRelations, fastQueryAffectedNodes, fastQueryShortestPath, fastScanProjectGraphSources, fastSelectCompactGraphSlice, isNativeActive, } from '../core-bridge.js';
|
|
2
|
+
function toNativeNodes(nodes) {
|
|
3
|
+
return nodes.map((node) => ({ id: node.id }));
|
|
4
|
+
}
|
|
5
|
+
function toNativeEdges(edges) {
|
|
6
|
+
return edges.map((edge) => ({
|
|
7
|
+
id: edge.id,
|
|
8
|
+
from: edge.from,
|
|
9
|
+
to: edge.to,
|
|
10
|
+
relation: edge.type,
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
export function createNativeProjectGraphRuntime(limitsPolicy) {
|
|
14
|
+
return {
|
|
15
|
+
nativeActive: isNativeActive(),
|
|
16
|
+
scanSources(projectPath, maxFiles) {
|
|
17
|
+
return fastScanProjectGraphSources(projectPath, limitsPolicy, maxFiles === undefined ? {} : { maxFiles });
|
|
18
|
+
},
|
|
19
|
+
extractTypeScriptRelations(projectPath, paths = [], maxFiles) {
|
|
20
|
+
return fastExtractTsRelations(projectPath, paths, limitsPolicy, maxFiles === undefined ? {} : { maxFiles });
|
|
21
|
+
},
|
|
22
|
+
affectedNodes(seed, edges, depth, relationFilter = []) {
|
|
23
|
+
return fastQueryAffectedNodes(seed, toNativeEdges(edges), relationFilter, limitsPolicy, depth === undefined ? {} : { maxDepth: depth });
|
|
24
|
+
},
|
|
25
|
+
shortestPath(from, to, edges, relationFilter = []) {
|
|
26
|
+
return fastQueryShortestPath(from, to, toNativeEdges(edges), relationFilter, limitsPolicy);
|
|
27
|
+
},
|
|
28
|
+
compactSlice(seeds, nodes, edges, maxNodes, maxEdges) {
|
|
29
|
+
return fastSelectCompactGraphSlice(seeds, toNativeNodes(nodes), toNativeEdges(edges), limitsPolicy, {
|
|
30
|
+
...(maxNodes === undefined ? {} : { maxNodes }),
|
|
31
|
+
...(maxEdges === undefined ? {} : { maxEdges }),
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=native.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { hashProjectPath } from '../../storage/base-store.js';
|
|
2
2
|
import { buildProjectKnowledgeGraph } from './builder.js';
|
|
3
3
|
import { getProjectGraphFreshness, loadProjectGraphPolicy, readProjectGraph } from './cache.js';
|
|
4
|
+
import { createNativeProjectGraphRuntime } from './native.js';
|
|
4
5
|
import { escapeInert, redactGraphText } from './redaction.js';
|
|
5
6
|
function sanitizeLabel(label, policy) {
|
|
6
7
|
return escapeInert(redactGraphText(label, policy));
|
|
@@ -44,6 +45,8 @@ function summarize(nodes) {
|
|
|
44
45
|
risks: count('risk'),
|
|
45
46
|
tools: count('tool'),
|
|
46
47
|
releases: count('release'),
|
|
48
|
+
symbols: count('symbol'),
|
|
49
|
+
toolHandlers: count('tool_handler'),
|
|
47
50
|
};
|
|
48
51
|
}
|
|
49
52
|
export async function queryProjectGraphSlice(input) {
|
|
@@ -74,10 +77,27 @@ export async function queryProjectGraphSlice(input) {
|
|
|
74
77
|
const maxNodes = input.maxNodes ?? policy.query.maxNodes;
|
|
75
78
|
const maxEdges = input.maxEdges ?? policy.query.maxEdges;
|
|
76
79
|
const ids = relatedNodeIds(graph.nodes, graph.edges, input);
|
|
77
|
-
const
|
|
80
|
+
const runtime = createNativeProjectGraphRuntime(policy.limits);
|
|
81
|
+
const candidateNodes = graph.nodes
|
|
82
|
+
.filter((node) => ids.has(node.id))
|
|
83
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
84
|
+
.slice(0, policy.limits.hard.maxNodes);
|
|
85
|
+
const candidateNodeIds = new Set(candidateNodes.map((node) => node.id));
|
|
86
|
+
const candidateEdges = graph.edges
|
|
87
|
+
.filter((edge) => candidateNodeIds.has(edge.from) && candidateNodeIds.has(edge.to))
|
|
88
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
89
|
+
.slice(0, policy.limits.hard.maxEdges);
|
|
90
|
+
const candidateSeeds = [...ids]
|
|
91
|
+
.filter((id) => candidateNodeIds.has(id))
|
|
92
|
+
.sort((left, right) => left.localeCompare(right));
|
|
93
|
+
const sliceIds = runtime.compactSlice(candidateSeeds, candidateNodes, candidateEdges, maxNodes, maxEdges);
|
|
94
|
+
const compactIds = sliceIds.nodeIds.length > 0 ? new Set(sliceIds.nodeIds) : ids;
|
|
95
|
+
const nodes = graph.nodes.filter((node) => compactIds.has(node.id)).slice(0, maxNodes);
|
|
78
96
|
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
79
97
|
const edges = graph.edges
|
|
80
|
-
.filter((edge) => nodeIds.has(edge.from) &&
|
|
98
|
+
.filter((edge) => nodeIds.has(edge.from) &&
|
|
99
|
+
nodeIds.has(edge.to) &&
|
|
100
|
+
(sliceIds.edgeIds.length === 0 || sliceIds.edgeIds.includes(edge.id)))
|
|
81
101
|
.slice(0, maxEdges);
|
|
82
102
|
return {
|
|
83
103
|
graphVersion: graph.graphVersion,
|
|
@@ -107,6 +127,9 @@ export async function formatProjectGraphContext(slice) {
|
|
|
107
127
|
const risks = listByType(slice, 'risk', policy);
|
|
108
128
|
const specs = listByType(slice, 'spec', policy);
|
|
109
129
|
const tools = listByType(slice, 'tool', policy);
|
|
130
|
+
const symbols = listByType(slice, 'symbol', policy);
|
|
131
|
+
const toolHandlers = listByType(slice, 'tool_handler', policy);
|
|
132
|
+
const confidences = [...new Set(slice.edges.map((edge) => edge.classification))].slice(0, policy.query.maxListItems);
|
|
110
133
|
const lines = ['## Graph Context', ''];
|
|
111
134
|
lines.push(`Freshness: ${slice.freshness.stale ? `stale (${slice.freshness.reason})` : 'fresh'}; compact slice: ${String(slice.nodes.length)} nodes / ${String(slice.edges.length)} edges.`);
|
|
112
135
|
if (files.length > 0) {
|
|
@@ -127,6 +150,15 @@ export async function formatProjectGraphContext(slice) {
|
|
|
127
150
|
if (tools.length > 0) {
|
|
128
151
|
lines.push(`Tools: ${tools.join(', ')}`);
|
|
129
152
|
}
|
|
153
|
+
if (toolHandlers.length > 0) {
|
|
154
|
+
lines.push(`Tool handlers: ${toolHandlers.join(', ')}`);
|
|
155
|
+
}
|
|
156
|
+
if (symbols.length > 0) {
|
|
157
|
+
lines.push(`Symbols: ${symbols.join(', ')}`);
|
|
158
|
+
}
|
|
159
|
+
if (confidences.length > 0) {
|
|
160
|
+
lines.push(`Confidence labels: ${confidences.join(', ')}`);
|
|
161
|
+
}
|
|
130
162
|
lines.push('');
|
|
131
163
|
return lines.join('\n');
|
|
132
164
|
}
|