@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/config/project-knowledge-graph.json +42 -5
  3. package/dist/engine/core-bridge-project-graph.d.ts +13 -0
  4. package/dist/engine/core-bridge-project-graph.js +499 -0
  5. package/dist/engine/core-bridge.d.ts +7 -2
  6. package/dist/engine/core-bridge.js +55 -0
  7. package/dist/engine/frontmatter-parser.js +73 -23
  8. package/dist/engine/model-tier-resolver.d.ts +8 -7
  9. package/dist/engine/model-tier-resolver.js +70 -73
  10. package/dist/engine/next-spec-resolver/orchestration-planner.d.ts +5 -0
  11. package/dist/engine/next-spec-resolver/orchestration-planner.js +34 -5
  12. package/dist/engine/project-graph/builder.js +271 -36
  13. package/dist/engine/project-graph/cache.d.ts +22 -4
  14. package/dist/engine/project-graph/cache.js +412 -33
  15. package/dist/engine/project-graph/index.d.ts +1 -0
  16. package/dist/engine/project-graph/index.js +1 -0
  17. package/dist/engine/project-graph/native.d.ts +3 -0
  18. package/dist/engine/project-graph/native.js +36 -0
  19. package/dist/engine/project-graph/query.js +34 -2
  20. package/dist/engine/provider-adapters/adapters/claude.js +38 -14
  21. package/dist/engine/scan-project/index.js +88 -15
  22. package/dist/engine/spec-format/lean-spec-generator.d.ts +2 -2
  23. package/dist/engine/spec-format/lean-spec-generator.js +65 -50
  24. package/dist/engine/spec-format/metadata-value-policy.d.ts +161 -0
  25. package/dist/engine/spec-format/metadata-value-policy.js +87 -0
  26. package/dist/engine/spec-format/value-only-spec-serializer.d.ts +12 -0
  27. package/dist/engine/spec-format/value-only-spec-serializer.js +18 -0
  28. package/dist/engine/spec-generator/fallback-generator.js +4 -2
  29. package/dist/engine/spec-generator/opus-generator.js +5 -2
  30. package/dist/engine/spec-migrator/lean-migration.js +26 -13
  31. package/dist/storage/spec-store.js +6 -6
  32. package/dist/tools/create-spec.js +1027 -739
  33. package/dist/tools/render-spec-for-provider.js +4 -3
  34. package/dist/tools/reverse-engineer/handler.js +76 -43
  35. package/dist/tools/spec-split-handler.js +36 -75
  36. package/dist/types/conventions.d.ts +9 -0
  37. package/dist/types/core-bridge.d.ts +72 -0
  38. package/dist/types/next-spec.d.ts +2 -1
  39. package/dist/types/project-knowledge-graph.d.ts +58 -0
  40. package/dist/types/spec/core.d.ts +7 -4
  41. package/dist/types/spec-format.d.ts +2 -1
  42. package/dist/types/spec-generator.d.ts +8 -2
  43. package/package.json +11 -9
  44. package/planu-native.json +1 -1
  45. package/planu-plugin.json +1 -1
  46. package/dist/engine/spec-format/model-budget-deriver.d.ts +0 -5
  47. package/dist/engine/spec-format/model-budget-deriver.js +0 -7
@@ -1,10 +1,29 @@
1
- // @crash-shield-ignore-file graph artifacts are Planu-owned JSON written by this subsystem.
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
- export async function readProjectGraph(projectId, policy) {
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
- return readJson(projectGraphCachePath(projectId, policy), {});
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 writeJson(projectGraphCachePath(projectId, policy), hashes);
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 diffSourceHashes(sources, previous) {
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 changed = sources
39
- .filter((source) => previous[source.id] !== source.hash)
40
- .map((source) => source.id);
41
- const skipped = sources
42
- .filter((source) => previous[source.id] === source.hash)
43
- .map((source) => source.id);
44
- return { changed, skipped, next };
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
- const info = await stat(path);
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 content = await readFile(args.path, 'utf-8');
74
- return { ...args, content, hash: hashText(content) };
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.includes(path) ? 'validation' : 'handoff',
447
+ kind: validationFiles.has(path) ? 'validation' : 'handoff',
98
448
  path,
449
+ filePath: path,
99
450
  })),
100
451
  ...((await fileExists(transitionLog))
101
- ? [{ id: 'transition-log', kind: 'transition-log', path: transitionLog }]
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
- ? [{ id: 'release-events', kind: 'release-events', path: releaseEvents }]
465
+ ? [
466
+ {
467
+ id: 'release-events',
468
+ kind: 'release-events',
469
+ path: releaseEvents,
470
+ filePath: releaseEvents,
471
+ },
472
+ ]
108
473
  : []),
109
- ];
110
- const sources = await Promise.all(raw.map((source) => sourceFromFile(source)));
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 = sources
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 nodes = graph.nodes.filter((node) => ids.has(node.id)).slice(0, maxNodes);
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) && nodeIds.has(edge.to))
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
  }