@weftlet/toolkit 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +36 -1
  2. package/THIRD_PARTY_NOTICES.md +33 -0
  3. package/dist/application/hydrator.js +18 -5
  4. package/dist/application/overlay.js +46 -9
  5. package/dist/application/saved-evidence.d.ts +1 -0
  6. package/dist/configuration/config.d.ts +4 -0
  7. package/dist/configuration/config.js +12 -0
  8. package/dist/configuration/scip-project.d.ts +9 -0
  9. package/dist/configuration/scip-project.js +40 -0
  10. package/dist/extraction/codex-auth.js +9 -2
  11. package/dist/extraction/codex-command.d.ts +8 -0
  12. package/dist/extraction/codex-command.js +81 -0
  13. package/dist/extraction/document-references.d.ts +18 -0
  14. package/dist/extraction/document-references.js +94 -0
  15. package/dist/extraction/document.d.ts +9 -2
  16. package/dist/extraction/document.js +44 -46
  17. package/dist/extraction/module-request.d.ts +3 -0
  18. package/dist/extraction/module-request.js +25 -0
  19. package/dist/extraction/routing.d.ts +14 -0
  20. package/dist/extraction/routing.js +151 -0
  21. package/dist/extraction/scip-project.d.ts +3 -0
  22. package/dist/extraction/scip-project.js +33 -0
  23. package/dist/extraction/semantic-process.js +17 -5
  24. package/dist/extraction/semantic.js +13 -2
  25. package/dist/extraction/static.d.ts +7 -3
  26. package/dist/extraction/static.js +15 -9
  27. package/dist/graph/query.js +21 -34
  28. package/dist/graph/ranking.d.ts +10 -0
  29. package/dist/graph/ranking.js +85 -0
  30. package/dist/interfaces/doctor.js +12 -3
  31. package/docs/ADR-002-saved-evidence.md +7 -1
  32. package/docs/ADR-003-draft-overlays.md +6 -0
  33. package/docs/ADR-004-rdf-query-path.md +6 -0
  34. package/docs/ADR-006-provider-boundaries.md +5 -0
  35. package/docs/ADR-010-committed-source-indexing.md +77 -0
  36. package/docs/ADR-011-explicit-document-links.md +48 -0
  37. package/docs/ADR-012-impact-ranking.md +50 -0
  38. package/docs/ADR-013-relative-module-requests.md +58 -0
  39. package/docs/ADR-014-routing-declarations.md +108 -0
  40. package/docs/CONSUMER-TRIAL.md +165 -0
  41. package/docs/ENVIRONMENT-VALIDATION.md +9 -0
  42. package/docs/IMPACT-EVALUATION.md +363 -0
  43. package/docs/PROVIDERS.md +30 -2
  44. package/docs/RELEASING.md +3 -1
  45. package/docs/ROADMAP.md +44 -4
  46. package/docs/SOURCE-INVENTORY.md +10 -0
  47. package/docs/VALIDATION.md +366 -0
  48. package/docs/VIEWER.md +5 -0
  49. package/docs/extraction-manifest.json +87 -16
  50. package/package.json +5 -2
@@ -0,0 +1,94 @@
1
+ import { posix } from 'node:path';
2
+ import { fromMarkdown } from 'mdast-util-from-markdown';
3
+ // Markdown destinations are document-relative. Literal paths are repository-relative unless
4
+ // prefixed with ./ or ../. Parsing Markdown keeps examples, URLs and link labels out of the graph.
5
+ const barePath = /(?<![\w/:@.-])(?:\.{1,2}\/|\/)?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+/g;
6
+ const fileName = /\.(?:md|mdx|txt|ts|tsx|mts|cts|js|jsx|mjs|cjs|json|ya?ml|prisma|sh)$/i;
7
+ export function documentReferences(content) {
8
+ const tree = fromMarkdown(content);
9
+ const definitions = new Map();
10
+ const walk = (node, visit) => {
11
+ visit(node);
12
+ if ('children' in node)
13
+ for (const child of node.children)
14
+ walk(child, visit);
15
+ };
16
+ walk(tree, (node) => {
17
+ if (node.type === 'definition' && !definitions.has(node.identifier)) {
18
+ definitions.set(node.identifier, node);
19
+ }
20
+ });
21
+ const references = [];
22
+ const add = (node, target, relativeTo) => {
23
+ if (node.position)
24
+ references.push({ target, relativeTo,
25
+ startLine: node.position.start.line, endLine: node.position.end.line });
26
+ };
27
+ const visit = (node) => {
28
+ if (node.type === 'link') {
29
+ add(node, node.url, 'document');
30
+ return;
31
+ }
32
+ if (node.type === 'linkReference') {
33
+ const definition = definitions.get(node.identifier);
34
+ // Cite the declaration containing the destination, not a path invented from its label.
35
+ if (definition)
36
+ add(definition, definition.url, 'document');
37
+ return;
38
+ }
39
+ if (node.type === 'inlineCode') {
40
+ if (!/\s/.test(node.value) && (node.value.includes('/') || fileName.test(node.value))) {
41
+ add(node, node.value, 'repository');
42
+ }
43
+ return;
44
+ }
45
+ if (node.type === 'text' && node.position) {
46
+ const raw = content.slice(node.position.start.offset, node.position.end.offset);
47
+ for (const match of raw.matchAll(barePath)) {
48
+ const target = match[0].replace(/[.,;:]+$/, '');
49
+ if (!/[A-Za-z]/.test(target))
50
+ continue;
51
+ const startLine = node.position.start.line + raw.slice(0, match.index).split('\n').length - 1;
52
+ references.push({ target, startLine, endLine: startLine, relativeTo: 'repository' });
53
+ }
54
+ return;
55
+ }
56
+ // Code blocks, images, HTML and unused reference definitions are not repository mappings.
57
+ if ('children' in node)
58
+ for (const child of node.children)
59
+ visit(child);
60
+ };
61
+ visit(tree);
62
+ return references;
63
+ }
64
+ export function resolveDocumentReference(sourcePath, reference, tracked) {
65
+ const target = reference.target;
66
+ // No network, filesystem lookup or guessed basename fallback. A leading / denotes repository root.
67
+ if (!target || target.startsWith('#') || target.startsWith('//') || /^[a-z][\w+.-]*:/i.test(target)) {
68
+ return { kind: 'ignored' };
69
+ }
70
+ let path;
71
+ try {
72
+ path = decodeURIComponent(target.split(/[?#]/, 1)[0]);
73
+ }
74
+ catch {
75
+ return { kind: 'missing', reason: `Malformed repository link destination: ${target}` };
76
+ }
77
+ if (/[\\\x00-\x1f\x7f]/.test(path)) {
78
+ return { kind: 'missing', reason: `Unsupported repository link destination: ${target}` };
79
+ }
80
+ const relative = reference.relativeTo === 'document' || /^\.{1,2}\//.test(path);
81
+ path = posix.normalize(path.startsWith('/') ? path.slice(1)
82
+ : posix.join(relative ? posix.dirname(sourcePath) : '', path));
83
+ if (path === '..' || path.startsWith('../') || posix.isAbsolute(path)) {
84
+ return { kind: 'missing', reason: `Repository link escapes the repository: ${target}` };
85
+ }
86
+ if (tracked.has(path))
87
+ return { kind: 'resolved', path };
88
+ // Slash-separated prose (RDF/SHACL), package names and example paths are not declarations
89
+ // of missing repository files. Only an explicit Markdown destination requests link validation.
90
+ if (reference.relativeTo === 'repository')
91
+ return { kind: 'ignored' };
92
+ return { kind: 'missing', path,
93
+ reason: `Document reference ${target} resolves to ${path}, which is not a committed file. Directory references are not expanded.` };
94
+ }
@@ -1,5 +1,12 @@
1
- import type { ExtractionContext, GraphFact, SemanticArtifact, SemanticSource } from "@weftlet/core";
1
+ import type { ExtractionContext, GraphDiagnostic, GraphFact, SemanticArtifact, SemanticSource } from '@weftlet/core';
2
+ export declare const documentLinkerIdentity: {
3
+ readonly id: "document-linker";
4
+ readonly version: "2.0.0";
5
+ };
2
6
  export declare function linkSemanticDocuments(context: ExtractionContext, hydrated: readonly {
3
7
  source: SemanticSource;
4
8
  artifact: SemanticArtifact;
5
- }[], trackedPaths?: readonly string[]): readonly GraphFact[];
9
+ }[], trackedPaths: readonly string[], approvedSourcePaths?: readonly string[]): {
10
+ readonly facts: readonly GraphFact[];
11
+ readonly diagnostics: readonly GraphDiagnostic[];
12
+ };
@@ -1,54 +1,52 @@
1
- import { sha256, stableJson } from "../infrastructure/util.js";
2
- const identity = { id: 'document-linker', version: '1.0.0' };
3
- const repositoryPathPattern = /\b[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+/g;
4
- function documentNode(path) {
5
- return { id: `document:${path}`, kind: 'Document', label: path };
1
+ import { sha256, stableJson } from '../infrastructure/util.js';
2
+ import { documentReferences, resolveDocumentReference } from './document-references.js';
3
+ export const documentLinkerIdentity = { id: 'document-linker', version: '2.0.0' };
4
+ function fileNode(path) {
5
+ if (/\.(?:md|mdx|txt)$/i.test(path))
6
+ return { id: `document:${path}`, kind: 'Document', label: path };
7
+ return { id: `component:file:${path}`, label: path,
8
+ kind: /\.(spec|test)\.[^.]+$/i.test(path) ? 'Evidence' : 'Component' };
6
9
  }
7
- export function linkSemanticDocuments(context, hydrated, trackedPaths = []) {
8
- const facts = [];
9
- const makeFact = (source, subject, predicate, object) => ({
10
- id: sha256(stableJson({ source: source.path, subject: subject.id, predicate, object: object.id })),
11
- subject,
12
- predicate,
13
- object,
14
- provenance: {
15
- path: source.path,
16
- startLine: 1,
17
- endLine: Math.max(1, source.content.split('\n').length),
18
- digest: source.digest,
19
- commitSha: context.commitSha,
20
- },
21
- extractor: identity,
22
- confidence: 1,
23
- authority: 'inferred',
24
- extractionReview: predicate === 'contains' ? 'unreviewed' : 'deterministic',
25
- snapshotId: context.snapshotId,
26
- });
10
+ export function linkSemanticDocuments(context, hydrated, trackedPaths, approvedSourcePaths = []) {
11
+ const facts = new Map();
12
+ const diagnostics = new Map();
13
+ const tracked = new Set(trackedPaths);
27
14
  for (const { source, artifact } of hydrated) {
28
- const owner = documentNode(source.path);
29
- const nodes = new Map(artifact.facts.flatMap((fact) => [fact.subject, fact.object]).map((node) => [node.id, node]));
30
- for (const node of nodes.values())
31
- facts.push(makeFact(source, owner, 'contains', node));
32
- const references = [
33
- ...new Set((source.content.match(repositoryPathPattern) ?? []).map((path) => path.replace(/[.,;:]+$/, ''))),
34
- ].sort();
35
- const resolvedReferences = new Set();
36
- for (const path of references) {
37
- if (trackedPaths.includes(path) || /\.(?:md|mdx|txt)$/i.test(path)) {
38
- resolvedReferences.add(path);
15
+ const owner = { id: `document:${source.path}`, kind: 'Document', label: source.path };
16
+ const span = (startLine, endLine) => ({
17
+ path: source.path, startLine, endLine, digest: source.digest, commitSha: context.commitSha,
18
+ });
19
+ const add = (predicate, object, provenance) => {
20
+ const id = sha256(stableJson({ subject: owner.id, predicate, object: object.id, provenance,
21
+ extractor: documentLinkerIdentity }));
22
+ facts.set(id, { id, subject: owner, predicate, object, provenance,
23
+ extractor: documentLinkerIdentity, confidence: 1, authority: 'inferred',
24
+ sourceAuthority: approvedSourcePaths.includes(source.path) ? 'declared' : 'inferred',
25
+ extractionReview: predicate === 'contains' ? 'unreviewed' : 'deterministic',
26
+ snapshotId: context.snapshotId });
27
+ };
28
+ // Containment follows the assertion's actual cited passage and stays unreviewed.
29
+ for (const fact of artifact.facts) {
30
+ for (const node of [fact.subject, fact.object]) {
31
+ add('contains', node, span(fact.provenance.startLine, fact.provenance.endLine));
32
+ }
33
+ }
34
+ for (const reference of documentReferences(source.content)) {
35
+ const resolved = resolveDocumentReference(source.path, reference, tracked);
36
+ if (resolved.kind === 'ignored')
39
37
  continue;
38
+ const provenance = span(reference.startLine, reference.endLine);
39
+ if (resolved.kind === 'resolved') {
40
+ add('references', fileNode(resolved.path), provenance);
40
41
  }
41
- for (const trackedPath of trackedPaths) {
42
- if (trackedPath.startsWith(`${path}/`))
43
- resolvedReferences.add(trackedPath);
42
+ else {
43
+ const id = sha256(stableJson({ provenance, reason: resolved.reason, extractor: documentLinkerIdentity }));
44
+ diagnostics.set(id, { id, kind: 'implementationGaps', reason: resolved.reason,
45
+ provenance: [provenance], relatedNodeIds: [owner.id, ...(resolved.path ? [fileNode(resolved.path).id] : [])],
46
+ evidenceScope: 'source' });
44
47
  }
45
48
  }
46
- for (const path of [...resolvedReferences].sort()) {
47
- const target = /\.(?:md|mdx|txt)$/i.test(path)
48
- ? documentNode(path)
49
- : { id: `component:file:${path}`, kind: 'Component', label: path };
50
- facts.push(makeFact(source, owner, 'references', target));
51
- }
52
49
  }
53
- return facts;
50
+ return { facts: [...facts.values()].sort((a, b) => a.id.localeCompare(b.id)),
51
+ diagnostics: [...diagnostics.values()].sort((a, b) => a.id.localeCompare(b.id)) };
54
52
  }
@@ -0,0 +1,3 @@
1
+ import type { GraphNode } from '@weftlet/core';
2
+ /** Identity of a literal module request, not a resolved implementation or an existence check. */
3
+ export declare function moduleRequestNode(importer: string, specifier: string): GraphNode;
@@ -0,0 +1,25 @@
1
+ import { posix } from 'node:path';
2
+ import { sha256, stableJson } from '../infrastructure/util.js';
3
+ /** Identity of a literal module request, not a resolved implementation or an existence check. */
4
+ export function moduleRequestNode(importer, specifier) {
5
+ const relative = specifier === '.' || specifier === '..' ||
6
+ specifier.startsWith('./') || specifier.startsWith('../');
7
+ if (!relative)
8
+ return { id: `contract:module:${specifier}`, kind: 'Contract', label: specifier };
9
+ // Git paths use POSIX separators on every host. Do not interpret loader suffixes, escapes,
10
+ // encoded paths or requests outside this repository as canonical local destinations.
11
+ const location = posix.normalize(posix.join(posix.dirname(importer), specifier));
12
+ const opaque = /[\\%?#\x00-\x1f\x7f-\x9f]/.test(specifier) || importer.includes('\\') ||
13
+ location === '..' || location.startsWith('../') || posix.isAbsolute(location);
14
+ if (opaque)
15
+ return {
16
+ id: `contract:module:./?${sha256(stableJson({ importer, specifier }))}`,
17
+ kind: 'Contract', label: `${specifier} (from ${importer})`,
18
+ attributes: { identityScope: 'importer-local-request', resolution: 'unresolved' },
19
+ };
20
+ // The ./ prefix cannot collide with a bare import; ? is reserved for opaque identities.
21
+ // Encode the location so distinct literal path characters cannot act as ID delimiters.
22
+ return { id: `contract:module:./${encodeURIComponent(location)}`, kind: 'Contract',
23
+ label: `./${location}`,
24
+ attributes: { identityScope: 'repository-relative-request', resolution: 'unresolved' } };
25
+ }
@@ -0,0 +1,14 @@
1
+ import type { ExtractionContext, GraphDiagnostic, GraphFact } from '@weftlet/core';
2
+ import type { GitRepository } from '../repository/git.js';
3
+ export declare const routingExtractorIdentity: {
4
+ readonly id: "routing-declaration-extractor";
5
+ readonly version: "1.0.0";
6
+ };
7
+ /** Reads explicit mapping declarations, never infers routing from matching topic text. */
8
+ export declare function extractRoutingDeclarations(repository: GitRepository, context: ExtractionContext, paths: readonly string[], options?: {
9
+ readonly allowMissingSources?: boolean;
10
+ }): Promise<{
11
+ readonly facts: readonly GraphFact[];
12
+ readonly diagnostics: readonly GraphDiagnostic[];
13
+ readonly referencedPaths: readonly string[];
14
+ }>;
@@ -0,0 +1,151 @@
1
+ import { posix } from 'node:path';
2
+ import { sha256, stableJson } from '../infrastructure/util.js';
3
+ export const routingExtractorIdentity = { id: 'routing-declaration-extractor', version: '1.0.0' };
4
+ function record(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ function exactKeys(value, keys) {
8
+ return Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
9
+ }
10
+ function label(value) {
11
+ return typeof value === 'string' && value.length > 0 && value.length <= 200 &&
12
+ value === value.trim() && !/[\x00-\x1f\x7f-\x9f]/.test(value);
13
+ }
14
+ function repositoryPath(value) {
15
+ return typeof value === 'string' && value.length > 0 && value.length <= 1024 &&
16
+ value === value.trim() && !/[\\:*?\[\]{}%#\x00-\x1f\x7f-\x9f]/.test(value) &&
17
+ !posix.isAbsolute(value) && posix.normalize(value) === value &&
18
+ value.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
19
+ }
20
+ /** JSON.parse alone silently accepts duplicate keys, which would hide conflicting declarations. */
21
+ function parseUniqueJson(content, path) {
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(content);
25
+ }
26
+ catch {
27
+ throw new Error(`Invalid routing source ${path}: malformed JSON`);
28
+ }
29
+ const objects = [];
30
+ for (const token of content.matchAll(/"(?:\\.|[^"\\])*"|[{}\[\]]/g)) {
31
+ const value = token[0];
32
+ if (value === '{' || value === '[')
33
+ objects.push(value === '{' ? new Set() : undefined);
34
+ else if (value === '}' || value === ']')
35
+ objects.pop();
36
+ else if (/^\s*:/.test(content.slice(token.index + value.length))) {
37
+ const keys = objects.at(-1);
38
+ const key = JSON.parse(value);
39
+ if (keys.has(key))
40
+ throw new Error(`Invalid routing source ${path}: duplicate JSON key ${key}`);
41
+ keys.add(key);
42
+ }
43
+ }
44
+ return parsed;
45
+ }
46
+ function parseRoutes(content, path) {
47
+ if (Buffer.byteLength(content) > 65_536)
48
+ throw new Error(`Routing source ${path} exceeds 64 KiB`);
49
+ const parsed = parseUniqueJson(content, path);
50
+ if (!record(parsed) || !exactKeys(parsed, ['schemaVersion', 'routes']) ||
51
+ parsed.schemaVersion !== 1 || !Array.isArray(parsed.routes)) {
52
+ throw new Error(`Invalid routing source ${path}: expected schemaVersion 1 and routes array`);
53
+ }
54
+ return parsed.routes.map((route, index) => {
55
+ if (!record(route) || !exactKeys(route, ['id', 'scope', 'channel', 'producer', 'consumer']) ||
56
+ !label(route.id) || !label(route.scope) || !label(route.channel) ||
57
+ !repositoryPath(route.producer) || !repositoryPath(route.consumer)) {
58
+ throw new Error(`Invalid routing source ${path}: route ${index + 1} requires exact id/scope/channel labels and normalized producer/consumer paths`);
59
+ }
60
+ return { id: route.id, scope: route.scope, channel: route.channel,
61
+ producer: route.producer, consumer: route.consumer };
62
+ });
63
+ }
64
+ function fileNode(path) {
65
+ return { id: `component:file:${path}`, label: path,
66
+ kind: /\.(spec|test)\.[^.]+$/i.test(path) ? 'Evidence' : 'Component' };
67
+ }
68
+ /** Reads explicit mapping declarations, never infers routing from matching topic text. */
69
+ export async function extractRoutingDeclarations(repository, context, paths, options = {}) {
70
+ if (paths.length > 16 || new Set(paths).size !== paths.length ||
71
+ paths.some((path) => !repositoryPath(path) || !path.endsWith('.json'))) {
72
+ throw new Error('Routing sources require at most 16 unique exact repository-relative .json paths');
73
+ }
74
+ if (paths.length === 0)
75
+ return { facts: [], diagnostics: [], referencedPaths: [] };
76
+ // NUL-delimited tree entries preserve spaces/Unicode regardless of core.quotepath.
77
+ // A symlink or submodule is not evidence of a regular source file at this path.
78
+ const entries = (await repository.run(['ls-tree', '-r', '-z', context.commitSha, '--', '.']))
79
+ .split('\0').filter(Boolean).map((entry) => {
80
+ const fields = /^(\d{6}) (blob|tree|commit) [a-f0-9]+\t([\s\S]+)$/.exec(entry);
81
+ if (!fields)
82
+ throw new Error('Unable to read exact committed routing file entries');
83
+ return { mode: fields[1], type: fields[2], path: fields[3] };
84
+ });
85
+ const tracked = new Set(entries.map((entry) => entry.path));
86
+ const regular = new Set(entries.filter((entry) => entry.type === 'blob' &&
87
+ ['100644', '100755'].includes(entry.mode)).map((entry) => entry.path));
88
+ const cited = [];
89
+ const diagnostics = [];
90
+ const addDiagnostic = (reason, provenance, relatedNodeIds) => {
91
+ diagnostics.push({ id: sha256(stableJson({ reason, provenance, relatedNodeIds,
92
+ extractor: routingExtractorIdentity, commitSha: context.commitSha })),
93
+ kind: 'implementationGaps', reason, provenance, relatedNodeIds, evidenceScope: 'source' });
94
+ };
95
+ for (const path of [...paths].sort()) {
96
+ if (!tracked.has(path)) {
97
+ if (!options.allowMissingSources)
98
+ throw new Error(`Routing source ${path} is not a committed file at ${context.commitSha}`);
99
+ addDiagnostic(`Routing source ${path} is absent from this draft; no bindings from that source were retained.`, [], [`document:${path}`]);
100
+ continue;
101
+ }
102
+ if (!regular.has(path))
103
+ throw new Error(`Routing source ${path} is not a regular committed file at ${context.commitSha}`);
104
+ const content = await repository.readFileAt(context.commitSha, path);
105
+ const provenance = { path, startLine: 1, endLine: content.split('\n').length,
106
+ digest: sha256(content), commitSha: context.commitSha };
107
+ cited.push(...parseRoutes(content, path).map((route) => ({ route, provenance })));
108
+ if (cited.length > 500)
109
+ throw new Error('Routing sources exceed the 500 declaration bound');
110
+ }
111
+ const groups = new Map();
112
+ const referencedPaths = new Set();
113
+ for (const item of cited) {
114
+ const key = stableJson({ repositoryId: context.repositoryId, scope: item.route.scope, id: item.route.id });
115
+ const group = groups.get(key) ?? [];
116
+ group.push(item);
117
+ groups.set(key, group);
118
+ referencedPaths.add(item.route.producer);
119
+ referencedPaths.add(item.route.consumer);
120
+ }
121
+ const facts = [];
122
+ for (const [key, group] of groups) {
123
+ const { route, provenance } = group[0];
124
+ const binding = { id: `workflow:routing:${sha256(key)}`, kind: 'Workflow',
125
+ label: `Routing ${route.scope}/${route.id}: ${route.channel}`,
126
+ attributes: { evidenceLayer: 'routing-declaration', routeId: route.id, scope: route.scope,
127
+ channel: route.channel } };
128
+ if (group.length > 1) {
129
+ addDiagnostic(`Ambiguous routing declaration ${route.scope}/${route.id}: ${group.length} records share this scoped identity; all of its bindings were suppressed.`, [...new Map(group.map((item) => [stableJson(item.provenance), item.provenance])).values()], [binding.id, ...new Set(group.flatMap((item) => [fileNode(item.route.producer).id, fileNode(item.route.consumer).id]))]);
130
+ continue;
131
+ }
132
+ const missing = [...new Set([route.producer, route.consumer])].filter((path) => !regular.has(path));
133
+ if (missing.length > 0) {
134
+ addDiagnostic(`Routing declaration ${route.scope}/${route.id} names missing or non-regular committed endpoint(s): ${missing.join(', ')}. Both binding relationships were suppressed; no path or case was guessed.`, [provenance], [binding.id, fileNode(route.producer).id, fileNode(route.consumer).id]);
135
+ continue;
136
+ }
137
+ const relationships = [
138
+ { subject: fileNode(route.producer), predicate: 'emits', object: binding },
139
+ { subject: binding, predicate: 'routesThrough', object: fileNode(route.consumer) },
140
+ ];
141
+ for (const relationship of relationships) {
142
+ const assertion = { ...relationship, provenance, extractor: routingExtractorIdentity,
143
+ confidence: 1, authority: 'inferred', sourceAuthority: 'inferred',
144
+ extractionReview: 'deterministic' };
145
+ facts.push({ ...assertion, id: sha256(stableJson(assertion)), snapshotId: context.snapshotId });
146
+ }
147
+ }
148
+ return { facts: facts.sort((a, b) => a.id.localeCompare(b.id)),
149
+ diagnostics: diagnostics.sort((a, b) => a.id.localeCompare(b.id)),
150
+ referencedPaths: [...referencedPaths].sort() };
151
+ }
@@ -0,0 +1,3 @@
1
+ import type { ScipSourceProject } from '../configuration/scip-project.js';
2
+ /** Only writes compiler configuration into a disposable committed tree; never source or dependencies. */
3
+ export declare function prepareScipSourceProject(root: string, project: ScipSourceProject, trackedPaths: readonly string[]): Promise<string>;
@@ -0,0 +1,33 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { matchesRepositoryGlob } from '../repository/glob.js';
4
+ const generatedProject = '.weftlet-scip-source-project.json';
5
+ /** Only writes compiler configuration into a disposable committed tree; never source or dependencies. */
6
+ export async function prepareScipSourceProject(root, project, trackedPaths) {
7
+ if (trackedPaths.includes(generatedProject))
8
+ throw new Error(`Reserved SCIP project path is already tracked: ${generatedProject}`);
9
+ const files = trackedPaths.filter((path) => /\.(?:[cm]?ts|tsx)$/.test(path) &&
10
+ project.include.some((pattern) => matchesRepositoryGlob(path, pattern))).sort();
11
+ if (!files.length || files.length > 5000)
12
+ throw new Error('SCIP source project must select 1–5000 committed TypeScript files');
13
+ const tracked = new Set(trackedPaths);
14
+ const paths = Object.create(null);
15
+ for (const [name, path] of Object.entries(project.aliases ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
16
+ if (!tracked.has(path))
17
+ throw new Error(`SCIP source alias ${name} targets an absent committed file: ${path}`);
18
+ if (!files.includes(path))
19
+ throw new Error(`SCIP source alias ${name} target must be selected by sourceProject.include: ${path}`);
20
+ paths[name] = [`./${path}`];
21
+ }
22
+ // A separate program deliberately avoids inherited build exclusions, output files,
23
+ // external tsconfig packages and machine-specific path mappings. This is local-symbol
24
+ // analysis, not a replacement for the repository's compiler or dependency installation.
25
+ await writeFile(join(root, generatedProject), JSON.stringify({
26
+ compilerOptions: {
27
+ target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', jsx: 'preserve',
28
+ noEmit: true, skipLibCheck: true, types: [], baseUrl: '.', paths,
29
+ },
30
+ files: files.map((path) => `./${path}`), include: [], exclude: [],
31
+ }) + '\n', { flag: 'wx' });
32
+ return generatedProject;
33
+ }
@@ -1,4 +1,5 @@
1
- import { spawn } from 'node:child_process';
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { join } from 'node:path';
2
3
  export class SemanticProviderError extends Error {
3
4
  code;
4
5
  retryable;
@@ -25,8 +26,17 @@ export function runSemanticProcess(command, args, cwd, prompt, environment, opti
25
26
  try {
26
27
  if (process.platform !== 'win32' && child.pid)
27
28
  process.kill(-child.pid, signal);
28
- else
29
- child.kill(signal);
29
+ else if (child.pid) {
30
+ // npm's Windows launcher starts a native child. Terminate its tree, not just the Node wrapper.
31
+ const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT;
32
+ const taskkill = systemRoot ? join(systemRoot, 'System32', 'taskkill.exe') : 'taskkill.exe';
33
+ execFile(taskkill, ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 5000 }, error => {
34
+ if (error && child.exitCode === null && child.signalCode === null) {
35
+ failure = new SemanticProviderError('unavailable', 'Could not terminate the semantic provider process tree');
36
+ child.kill('SIGKILL');
37
+ }
38
+ });
39
+ }
30
40
  }
31
41
  catch { /* The process may have already exited. */ }
32
42
  };
@@ -35,8 +45,10 @@ export function runSemanticProcess(command, args, cwd, prompt, environment, opti
35
45
  return;
36
46
  failure = new SemanticProviderError(code, `Semantic extraction ${code}`);
37
47
  kill('SIGTERM');
38
- killTimer = setTimeout(() => kill('SIGKILL'), 1000);
39
- killTimer.unref();
48
+ if (process.platform !== 'win32') {
49
+ killTimer = setTimeout(() => kill('SIGKILL'), 1000);
50
+ killTimer.unref();
51
+ }
40
52
  };
41
53
  const cancel = () => stop('cancelled');
42
54
  options.signal?.addEventListener('abort', cancel, { once: true });
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { prepareCodexAuthentication, readCodexLoginMode } from './codex-auth.js';
6
6
  export { semanticCommandEnvironment } from './codex-auth.js';
7
+ import { resolveCodexInvocation } from './codex-command.js';
7
8
  import { runSemanticProcess, SemanticProviderError } from "./semantic-process.js";
8
9
  export { SemanticProviderError } from "./semantic-process.js";
9
10
  import { sha256, stableJson } from "../infrastructure/util.js";
@@ -75,7 +76,17 @@ function renderOml(key, assertions) {
75
76
  '}',
76
77
  ].join('\n');
77
78
  }
78
- const spawnSemanticCommand = { run: runSemanticProcess };
79
+ const spawnSemanticCommand = {
80
+ async run(command, args, cwd, prompt, environment, options) {
81
+ const started = performance.now();
82
+ const timeoutMs = options?.timeoutMs ?? 120_000;
83
+ const invocation = await resolveCodexInvocation(command, environment, process.platform, options);
84
+ const remaining = Math.ceil(timeoutMs - (performance.now() - started));
85
+ if (remaining <= 0)
86
+ throw new SemanticProviderError('timeout', 'Semantic extraction timeout');
87
+ return runSemanticProcess(invocation.command, [...invocation.prefixArgs, ...args], cwd, prompt, environment, { ...options, timeoutMs: remaining });
88
+ },
89
+ };
79
90
  const kinds = [
80
91
  'Project',
81
92
  'Document',
@@ -209,7 +220,7 @@ export class CodexCliSemanticExtractor {
209
220
  if (Buffer.byteLength(source.content) > (this.config.maxSourceBytes ?? 65_536)) {
210
221
  throw new SemanticProviderError('invalid-output', 'Semantic source exceeds configured byte budget');
211
222
  }
212
- const command = process.platform === 'win32' ? 'codex.exe' : 'codex';
223
+ const command = 'codex';
213
224
  const environment = await prepareCodexAuthentication(this.config.auth, process.env, command, options, this.loginProbe);
214
225
  const runRoot = await mkdtemp(join(tmpdir(), 'context-graph-codex-'));
215
226
  const schemaPath = join(runRoot, 'schema.json');
@@ -1,6 +1,6 @@
1
1
  import type { ContextGraphConfig } from "../configuration/config.js";
2
2
  import type { ExtractionContext, FactExtractor, GraphFact } from "@weftlet/core";
3
- import type { GitRepository } from "../repository/git.js";
3
+ import { GitRepository } from "../repository/git.js";
4
4
  export interface ScipIndexResult {
5
5
  readonly path: string;
6
6
  readonly digest: string;
@@ -27,13 +27,17 @@ export declare class ScipSymbolExtractor implements FactExtractor {
27
27
  constructor(repository: GitRepository, index: ScipIndexResult);
28
28
  extract(context: ExtractionContext): Promise<readonly GraphFact[]>;
29
29
  }
30
+ export declare const repositoryFileExtractorIdentity: {
31
+ readonly id: "repository-file-extractor";
32
+ readonly version: "2.0.0";
33
+ };
30
34
  export declare class RepositoryFileExtractor implements FactExtractor {
31
35
  private readonly repository;
32
36
  private readonly roots;
33
37
  private readonly scipDigest;
34
38
  readonly identity: {
35
- id: string;
36
- version: string;
39
+ readonly id: "repository-file-extractor";
40
+ readonly version: "2.0.0";
37
41
  };
38
42
  constructor(repository: GitRepository, roots: readonly string[], scipDigest: string);
39
43
  extract(context: ExtractionContext): Promise<readonly GraphFact[]>;
@@ -5,6 +5,10 @@ import { copyFile, mkdir, readFile, rename, stat, writeFile } from 'node:fs/prom
5
5
  import { createRequire } from 'node:module';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
8
+ import { GitRepository } from "../repository/git.js";
9
+ import { assertScipSourceProject, assertSourceProjectInvocation } from '../configuration/scip-project.js';
10
+ import { prepareScipSourceProject } from './scip-project.js';
11
+ import { moduleRequestNode } from './module-request.js';
8
12
  import { matchesRepositoryGlob } from "../repository/glob.js";
9
13
  import { runProcess } from "../infrastructure/process.js";
10
14
  import { sha256, stableJson } from "../infrastructure/util.js";
@@ -16,6 +20,10 @@ export class ScipTypeScriptIndexer {
16
20
  this.storageRoot = storageRoot;
17
21
  }
18
22
  async index(context) {
23
+ if (this.config.sourceProject !== undefined) {
24
+ assertScipSourceProject(this.config.sourceProject);
25
+ assertSourceProjectInvocation(this.config.command, this.config.args);
26
+ }
19
27
  const require = createRequire(import.meta.url);
20
28
  const destination = join(this.storageRoot, 'scip', `${context.snapshotId}.scip`);
21
29
  await mkdir(join(this.storageRoot, 'scip'), { recursive: true });
@@ -30,7 +38,8 @@ export class ScipTypeScriptIndexer {
30
38
  ]
31
39
  : this.config.args;
32
40
  await withCommittedTree(context.repositoryRoot, context.commitSha, async (worktreeRoot) => {
33
- await runProcess(command, args, worktreeRoot, {
41
+ const project = this.config.sourceProject ? await prepareScipSourceProject(worktreeRoot, this.config.sourceProject, await new GitRepository(context.repositoryRoot).listAllFilesAt(context.commitSha)) : undefined;
42
+ await runProcess(command, project ? [...args, project] : args, worktreeRoot, {
34
43
  ...process.env,
35
44
  NODE_OPTIONS: process.env.NODE_OPTIONS ?? '--max-old-space-size=8192',
36
45
  });
@@ -161,11 +170,12 @@ export class ScipSymbolExtractor {
161
170
  return facts;
162
171
  }
163
172
  }
173
+ export const repositoryFileExtractorIdentity = { id: 'repository-file-extractor', version: '2.0.0' };
164
174
  export class RepositoryFileExtractor {
165
175
  repository;
166
176
  roots;
167
177
  scipDigest;
168
- identity = { id: 'repository-file-extractor', version: '1.0.0' };
178
+ identity = repositoryFileExtractorIdentity;
169
179
  constructor(repository, roots, scipDigest) {
170
180
  this.repository = repository;
171
181
  this.roots = roots;
@@ -198,7 +208,7 @@ export class RepositoryFileExtractor {
198
208
  commitSha: context.commitSha,
199
209
  };
200
210
  facts.push({
201
- id: sha256(stableJson({ project, component, provenance })),
211
+ id: sha256(stableJson({ project, component, provenance, extractor: this.identity })),
202
212
  subject: project,
203
213
  predicate: 'contains',
204
214
  object: component,
@@ -212,14 +222,10 @@ export class RepositoryFileExtractor {
212
222
  const imported = line.match(/(?:from\s+|import\s*\()['"]([^'"]+)['"]/)?.[1];
213
223
  if (!imported)
214
224
  continue;
215
- const contract = {
216
- id: `contract:module:${imported}`,
217
- kind: 'Contract',
218
- label: imported,
219
- };
225
+ const contract = moduleRequestNode(path, imported);
220
226
  const importProvenance = { ...provenance, startLine: index + 1, endLine: index + 1 };
221
227
  facts.push({
222
- id: sha256(stableJson({ component, imported, importProvenance })),
228
+ id: sha256(stableJson({ component, module: contract.id, importProvenance, extractor: this.identity })),
223
229
  subject: component,
224
230
  predicate: 'consumes',
225
231
  object: contract,