@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.
- package/README.md +36 -1
- package/THIRD_PARTY_NOTICES.md +33 -0
- package/dist/application/hydrator.js +18 -5
- package/dist/application/overlay.js +46 -9
- package/dist/application/saved-evidence.d.ts +1 -0
- package/dist/configuration/config.d.ts +4 -0
- package/dist/configuration/config.js +12 -0
- package/dist/configuration/scip-project.d.ts +9 -0
- package/dist/configuration/scip-project.js +40 -0
- package/dist/extraction/codex-auth.js +9 -2
- package/dist/extraction/codex-command.d.ts +8 -0
- package/dist/extraction/codex-command.js +81 -0
- package/dist/extraction/document-references.d.ts +18 -0
- package/dist/extraction/document-references.js +94 -0
- package/dist/extraction/document.d.ts +9 -2
- package/dist/extraction/document.js +44 -46
- package/dist/extraction/module-request.d.ts +3 -0
- package/dist/extraction/module-request.js +25 -0
- package/dist/extraction/routing.d.ts +14 -0
- package/dist/extraction/routing.js +151 -0
- package/dist/extraction/scip-project.d.ts +3 -0
- package/dist/extraction/scip-project.js +33 -0
- package/dist/extraction/semantic-process.js +17 -5
- package/dist/extraction/semantic.js +13 -2
- package/dist/extraction/static.d.ts +7 -3
- package/dist/extraction/static.js +15 -9
- package/dist/graph/query.js +21 -34
- package/dist/graph/ranking.d.ts +10 -0
- package/dist/graph/ranking.js +85 -0
- package/dist/interfaces/doctor.js +12 -3
- package/docs/ADR-002-saved-evidence.md +7 -1
- package/docs/ADR-003-draft-overlays.md +6 -0
- package/docs/ADR-004-rdf-query-path.md +6 -0
- package/docs/ADR-006-provider-boundaries.md +5 -0
- package/docs/ADR-010-committed-source-indexing.md +77 -0
- package/docs/ADR-011-explicit-document-links.md +48 -0
- package/docs/ADR-012-impact-ranking.md +50 -0
- package/docs/ADR-013-relative-module-requests.md +58 -0
- package/docs/ADR-014-routing-declarations.md +108 -0
- package/docs/CONSUMER-TRIAL.md +165 -0
- package/docs/ENVIRONMENT-VALIDATION.md +9 -0
- package/docs/IMPACT-EVALUATION.md +363 -0
- package/docs/PROVIDERS.md +30 -2
- package/docs/RELEASING.md +3 -1
- package/docs/ROADMAP.md +44 -4
- package/docs/SOURCE-INVENTORY.md +10 -0
- package/docs/VALIDATION.md +366 -0
- package/docs/VIEWER.md +5 -0
- package/docs/extraction-manifest.json +87 -16
- 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
|
|
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
|
|
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
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
function
|
|
5
|
-
|
|
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
|
|
10
|
-
|
|
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 =
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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,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.
|
|
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
|
-
|
|
39
|
-
|
|
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 = {
|
|
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 =
|
|
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
|
|
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:
|
|
36
|
-
version:
|
|
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
|
|
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 =
|
|
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,
|
|
228
|
+
id: sha256(stableJson({ component, module: contract.id, importProvenance, extractor: this.identity })),
|
|
223
229
|
subject: component,
|
|
224
230
|
predicate: 'consumes',
|
|
225
231
|
object: contract,
|