@weftlet/toolkit 0.5.0 → 0.6.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 +139 -14
- package/dist/application/cache-adoption.d.ts +2 -1
- package/dist/application/cache-adoption.js +12 -5
- package/dist/application/document-coverage.d.ts +28 -0
- package/dist/application/document-coverage.js +58 -0
- package/dist/application/document-overlay.d.ts +14 -0
- package/dist/application/document-overlay.js +31 -0
- package/dist/application/extraction-identity.d.ts +2 -1
- package/dist/application/extraction-identity.js +7 -0
- package/dist/application/hydration-inputs.d.ts +15 -0
- package/dist/application/hydration-inputs.js +55 -0
- package/dist/application/hydration-plan.d.ts +7 -0
- package/dist/application/hydration-plan.js +18 -5
- package/dist/application/hydrator.js +26 -14
- package/dist/application/overlay.js +26 -8
- package/dist/application/saved-evidence.d.ts +2 -2
- package/dist/application/semantic-scope.d.ts +1 -1
- package/dist/application/semantic-scope.js +2 -2
- package/dist/configuration/cache.js +13 -1
- package/dist/configuration/config.d.ts +2 -0
- package/dist/configuration/config.js +5 -0
- package/dist/configuration/document-traversal.d.ts +7 -0
- package/dist/configuration/document-traversal.js +13 -0
- package/dist/extraction/canonical-document-links.d.ts +28 -0
- package/dist/extraction/canonical-document-links.js +95 -0
- package/dist/extraction/document-classification.d.ts +44 -0
- package/dist/extraction/document-classification.js +161 -0
- package/dist/extraction/document-evidence.d.ts +21 -0
- package/dist/extraction/document-evidence.js +83 -0
- package/dist/extraction/document-traversal.d.ts +76 -0
- package/dist/extraction/document-traversal.js +210 -0
- package/dist/extraction/document.d.ts +3 -1
- package/dist/extraction/document.js +3 -1
- package/dist/graph/authority.js +3 -0
- package/dist/graph/coverage.d.ts +8 -0
- package/dist/graph/coverage.js +96 -0
- package/dist/graph/document-role-description.d.ts +5 -0
- package/dist/graph/document-role-description.js +18 -0
- package/dist/graph/query.d.ts +2 -2
- package/dist/graph/query.js +22 -62
- package/dist/graph/rdf.js +11 -0
- package/dist/graph/shacl-shapes.js +26 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/interfaces/cli.js +18 -1
- package/dist/interfaces/document-coverage.d.ts +9 -0
- package/dist/interfaces/document-coverage.js +117 -0
- package/dist/repository/git.d.ts +11 -0
- package/dist/repository/git.js +38 -0
- package/dist/visualization/visualization.js +21 -6
- package/docs/ADR-017-shacl-validation-union.md +4 -0
- package/docs/ADR-021-identity-preflight-and-validation-timing.md +5 -2
- package/docs/ADR-022-release-documentation.md +44 -0
- package/docs/ADR-023-document-coverage-preview.md +64 -0
- package/docs/ADR-024-implementation-coverage.md +51 -0
- package/docs/ADR-025-document-classification.md +78 -0
- package/docs/ADR-026-traversal-hydration.md +94 -0
- package/docs/ADR-027-validation-closeout.md +155 -0
- package/docs/CONSUMER-TRIAL.md +1 -1
- package/docs/MIGRATING-TO-WEFTLET.md +1 -1
- package/docs/PROVIDERS.md +10 -1
- package/docs/RELEASE-1.0-PLAN.md +135 -23
- package/docs/RELEASING.md +46 -8
- package/docs/ROADMAP.md +41 -16
- package/docs/VALIDATION.md +312 -0
- package/docs/benchmarks/validation-2026-10-28/README.md +25 -0
- package/docs/benchmarks/validation-2026-10-28/large.json +1 -0
- package/docs/benchmarks/validation-2026-10-28/lifecycle.json +1 -0
- package/docs/benchmarks/validation-2026-10-28/native.json +1 -0
- package/docs/benchmarks/validation-2026-10-28/rich.json +1 -0
- package/docs/extraction-manifest.json +67 -17
- package/examples/generic-typescript/document-policy.json +9 -0
- package/package.json +7 -4
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { sha256, stableJson } from '@weftlet/core';
|
|
3
|
+
import { matchesRepositoryGlob } from '../repository/glob.js';
|
|
4
|
+
const maximumDocuments = 10_000;
|
|
5
|
+
const maximumMappings = 1000;
|
|
6
|
+
const compare = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
7
|
+
const uniqueSorted = (values) => [...new Set(values)].sort(compare);
|
|
8
|
+
const roles = new Set(['intent', 'plan', 'validation', 'reference']);
|
|
9
|
+
const unsafe = /[\x00-\x1f\x7f-\x9f\uFFFD:[\]{}]/;
|
|
10
|
+
function record(value, name, fields) {
|
|
11
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
12
|
+
Object.keys(value).some(key => !fields.includes(key))) {
|
|
13
|
+
throw new Error(`${name} must be an object containing only supported fields`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function array(value, name, maximum) {
|
|
18
|
+
if (!Array.isArray(value) || value.length > maximum) {
|
|
19
|
+
throw new Error(`${name} must be an array of at most ${maximum} entries`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function documentPath(value, name) {
|
|
24
|
+
if (typeof value !== 'string' || !value || value.length > 4096 || value !== value.trim() ||
|
|
25
|
+
unsafe.test(value) || /[\\*?]/.test(value) || value.startsWith('/') || value.startsWith('../') ||
|
|
26
|
+
posix.normalize(value) !== value || !/\.(?:md|mdx|txt)$/i.test(value)) {
|
|
27
|
+
throw new Error(`${name} must be a canonical repository-relative .md, .mdx or .txt document path`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function documentPaths(value, name) {
|
|
32
|
+
const paths = array(value, name, maximumDocuments).map(path => documentPath(path, name));
|
|
33
|
+
if (new Set(paths).size !== paths.length)
|
|
34
|
+
throw new Error(`${name} must not contain duplicate document paths`);
|
|
35
|
+
return paths.sort(compare);
|
|
36
|
+
}
|
|
37
|
+
function pattern(value, name, evidenceRoot = false) {
|
|
38
|
+
if (typeof value !== 'string' || value.length > 4096 || value !== value.trim() || unsafe.test(value)) {
|
|
39
|
+
throw new Error(`${name} must be a repository-relative pattern using only * and ? wildcards`);
|
|
40
|
+
}
|
|
41
|
+
const normalized = value.replaceAll('\\', '/').replace(/^\.\//, '');
|
|
42
|
+
const result = evidenceRoot ? normalized.replace(/\/$/, '') || '.' : normalized;
|
|
43
|
+
const segments = result.split('/');
|
|
44
|
+
if (!result || result.startsWith('!') || result.startsWith('/') ||
|
|
45
|
+
(result !== '.' && segments.some(segment => !segment || segment === '.' || segment === '..')) ||
|
|
46
|
+
(!evidenceRoot && result === '.') || /\*{3}/.test(result)) {
|
|
47
|
+
throw new Error(`${name} must be a repository-relative pattern using only * and ? wildcards`);
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
function patterns(value, name, evidenceRoot = false) {
|
|
52
|
+
return uniqueSorted(array(value, name, maximumMappings).map(item => pattern(item, name, evidenceRoot)));
|
|
53
|
+
}
|
|
54
|
+
function mappingId(value, ids) {
|
|
55
|
+
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(value) || ids.has(value)) {
|
|
56
|
+
throw new Error('Document policy mapping IDs must be unique ASCII identifiers of at most 128 characters');
|
|
57
|
+
}
|
|
58
|
+
ids.add(value);
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
/** Parse bounded explicit policy; normalize ordering, never infer approval or ownership. */
|
|
62
|
+
export function parseDocumentClassificationPolicy(value) {
|
|
63
|
+
const input = record(value, 'Document policy', ['schemaVersion', 'roles', 'owners']);
|
|
64
|
+
if (input.schemaVersion !== 1)
|
|
65
|
+
throw new Error('Document policy schemaVersion must be 1');
|
|
66
|
+
const ids = new Set();
|
|
67
|
+
const roleMappings = array(input.roles === undefined ? [] : input.roles, 'Document policy roles', maximumMappings).map(value => {
|
|
68
|
+
const mapping = record(value, 'Document role mapping', ['id', 'pattern', 'role']);
|
|
69
|
+
if (typeof mapping.role !== 'string' || !roles.has(mapping.role))
|
|
70
|
+
throw new Error('Document policy role must be intent, plan, validation or reference');
|
|
71
|
+
return { id: mappingId(mapping.id, ids), pattern: pattern(mapping.pattern, 'Document role pattern'),
|
|
72
|
+
role: mapping.role };
|
|
73
|
+
});
|
|
74
|
+
const ownerMappings = array(input.owners === undefined ? [] : input.owners, 'Document policy owners', maximumMappings).map(value => {
|
|
75
|
+
const mapping = record(value, 'Document owner mapping', ['id', 'pattern', 'ownerDocument']);
|
|
76
|
+
return { id: mappingId(mapping.id, ids), pattern: pattern(mapping.pattern, 'Document owner pattern'),
|
|
77
|
+
ownerDocument: documentPath(mapping.ownerDocument, 'Owner document') };
|
|
78
|
+
});
|
|
79
|
+
return { schemaVersion: 1, roles: roleMappings.sort((a, b) => compare(a.id, b.id)),
|
|
80
|
+
owners: ownerMappings.sort((a, b) => compare(a.id, b.id)) };
|
|
81
|
+
}
|
|
82
|
+
function evidenceRootMatches(path, root) {
|
|
83
|
+
return root === '.' || path === root || path.startsWith(`${root}/`) ||
|
|
84
|
+
matchesRepositoryGlob(path, root) || matchesRepositoryGlob(path, `${root}/**`);
|
|
85
|
+
}
|
|
86
|
+
function classifyRole(path, settings) {
|
|
87
|
+
const approvedBy = settings.approvedIntentSources.filter(pattern => matchesRepositoryGlob(path, pattern));
|
|
88
|
+
const matches = settings.policy.roles.filter(mapping => matchesRepositoryGlob(path, mapping.pattern));
|
|
89
|
+
const selectedRoles = new Set(matches.map(mapping => mapping.role));
|
|
90
|
+
let sourceRole = 'unclassified';
|
|
91
|
+
let roleStatus = 'default';
|
|
92
|
+
if (selectedRoles.size > 1)
|
|
93
|
+
roleStatus = 'ambiguous';
|
|
94
|
+
else if (matches.length) {
|
|
95
|
+
sourceRole = matches[0].role;
|
|
96
|
+
roleStatus = 'explicit';
|
|
97
|
+
}
|
|
98
|
+
else if (settings.evidenceRoots.some(root => evidenceRootMatches(path, root)))
|
|
99
|
+
sourceRole = 'validation';
|
|
100
|
+
else if (approvedBy.length)
|
|
101
|
+
sourceRole = 'intent';
|
|
102
|
+
return { sourceRole, roleStatus, matchedRoleMappings: matches.map(mapping => mapping.id), approvedBy,
|
|
103
|
+
sourceAuthority: approvedBy.length ? 'declared' : 'inferred' };
|
|
104
|
+
}
|
|
105
|
+
function classifyOwnership(path, role, settings) {
|
|
106
|
+
const mappings = settings.policy.owners.filter(mapping => matchesRepositoryGlob(path, mapping.pattern));
|
|
107
|
+
const ownerDocuments = uniqueSorted(mappings.map(mapping => mapping.ownerDocument));
|
|
108
|
+
const mappingIds = mappings.map(mapping => mapping.id);
|
|
109
|
+
const result = (status) => ({ status, ownerDocuments, mappingIds });
|
|
110
|
+
if (!ownerDocuments.length)
|
|
111
|
+
return result(['validation', 'reference'].includes(role) ? 'not-required' : 'missing');
|
|
112
|
+
if (ownerDocuments.length > 1)
|
|
113
|
+
return result('ambiguous');
|
|
114
|
+
const owner = ownerDocuments[0];
|
|
115
|
+
if (!settings.availableDocuments.has(owner))
|
|
116
|
+
return result('unavailable');
|
|
117
|
+
const classification = classifyRole(owner, settings);
|
|
118
|
+
if (!classification.approvedBy.length)
|
|
119
|
+
return result('unapproved');
|
|
120
|
+
if (classification.sourceRole !== 'intent' || classification.roleStatus === 'ambiguous')
|
|
121
|
+
return result('ineligible');
|
|
122
|
+
return result('resolved');
|
|
123
|
+
}
|
|
124
|
+
const ownershipMessages = {
|
|
125
|
+
missing: 'This document role requires an explicit source-of-truth owner mapping.',
|
|
126
|
+
ambiguous: 'Explicit owner mappings disagree on the source-of-truth document.',
|
|
127
|
+
unavailable: 'The explicit owner is not an available read document in the selected coverage.',
|
|
128
|
+
unapproved: 'The explicit owner does not match the configured approved intent sources.',
|
|
129
|
+
ineligible: 'The approved explicit owner does not have an unambiguous intent role.',
|
|
130
|
+
};
|
|
131
|
+
function classificationIssues(document) {
|
|
132
|
+
const issues = [];
|
|
133
|
+
const add = (code, message, paths) => issues.push({ code, severity: 'warning', message, paths });
|
|
134
|
+
if (document.roleStatus === 'ambiguous')
|
|
135
|
+
add('role-ambiguous', 'Explicit document role mappings conflict; no role was selected.', [document.path]);
|
|
136
|
+
else if (document.sourceRole === 'unclassified')
|
|
137
|
+
add('role-unclassified', 'No explicit role or configured default classifies this document.', [document.path]);
|
|
138
|
+
const message = ownershipMessages[document.ownership.status];
|
|
139
|
+
if (message)
|
|
140
|
+
add(`ownership-${document.ownership.status}`, message, uniqueSorted([document.path, ...document.ownership.ownerDocuments]));
|
|
141
|
+
return issues;
|
|
142
|
+
}
|
|
143
|
+
/** Classify source-level evidence only; links, owner mappings and roles never approve a child. */
|
|
144
|
+
export function classifyDocumentSources(paths, context, policy) {
|
|
145
|
+
const selected = documentPaths(paths, 'Document paths');
|
|
146
|
+
const normalized = parseDocumentClassificationPolicy(policy === undefined ? { schemaVersion: 1 } : policy);
|
|
147
|
+
const settings = {
|
|
148
|
+
policy: { schemaVersion: 1, roles: normalized.roles, owners: normalized.owners },
|
|
149
|
+
approvedIntentSources: patterns(context.approvedIntentSources, 'Approved intent source patterns'),
|
|
150
|
+
evidenceRoots: patterns(context.evidenceRoots, 'Evidence roots', true),
|
|
151
|
+
availableDocuments: new Set(documentPaths(context.availableDocuments, 'Available document paths')),
|
|
152
|
+
};
|
|
153
|
+
const documents = selected.map(path => {
|
|
154
|
+
const classification = classifyRole(path, settings);
|
|
155
|
+
return { path, ...classification, ownership: classifyOwnership(path, classification.sourceRole, settings) };
|
|
156
|
+
});
|
|
157
|
+
const issues = documents.flatMap(classificationIssues);
|
|
158
|
+
const policyDigest = sha256(stableJson({ policy: settings.policy,
|
|
159
|
+
approvedIntentSources: settings.approvedIntentSources, evidenceRoots: settings.evidenceRoots }));
|
|
160
|
+
return { policyDigest, documents, issues, complete: !issues.length };
|
|
161
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ExtractionContext, type GraphDiagnostic, type GraphFact } from '@weftlet/core';
|
|
2
|
+
import type { GitRepository } from '../repository/git.js';
|
|
3
|
+
import { type DocumentClassificationPolicy, type DocumentClassificationReport } from './document-classification.js';
|
|
4
|
+
import { type DocumentTraversalOptions, type DocumentTraversalReport } from './document-traversal.js';
|
|
5
|
+
export declare const documentEvidenceExtractorIdentity: {
|
|
6
|
+
readonly id: "document-evidence-extractor";
|
|
7
|
+
readonly version: "1.0.0";
|
|
8
|
+
};
|
|
9
|
+
type DocumentEvidenceOptions = DocumentTraversalOptions & {
|
|
10
|
+
readonly documentPolicy?: DocumentClassificationPolicy;
|
|
11
|
+
readonly approvedIntentSources?: readonly string[];
|
|
12
|
+
readonly evidenceRoots?: readonly string[];
|
|
13
|
+
};
|
|
14
|
+
/** Deterministic committed document structure only; never interprets prose, approves rules or publishes evidence. */
|
|
15
|
+
export declare function extractDocumentEvidence(repository: Pick<GitRepository, 'listEntriesAt' | 'readFileAt'>, context: ExtractionContext, options: DocumentEvidenceOptions): Promise<{
|
|
16
|
+
facts: readonly GraphFact[];
|
|
17
|
+
diagnostics: readonly GraphDiagnostic[];
|
|
18
|
+
traversal: DocumentTraversalReport;
|
|
19
|
+
classification: DocumentClassificationReport;
|
|
20
|
+
}>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { repositoryFileNode } from '@weftlet/core';
|
|
2
|
+
import { sha256, stableJson } from '../infrastructure/util.js';
|
|
3
|
+
import { classifyDocumentSources } from './document-classification.js';
|
|
4
|
+
import { traverseDocuments } from './document-traversal.js';
|
|
5
|
+
export const documentEvidenceExtractorIdentity = { id: 'document-evidence-extractor', version: '1.0.0' };
|
|
6
|
+
function span(state, path, startLine = 1, endLine = startLine) {
|
|
7
|
+
const document = state.documents.get(path);
|
|
8
|
+
return document ? { path, startLine, endLine, digest: document.digest, commitSha: state.context.commitSha } : undefined;
|
|
9
|
+
}
|
|
10
|
+
function addFact(state, subject, predicate, object, provenance) {
|
|
11
|
+
const classification = state.classified.get(provenance.path);
|
|
12
|
+
const record = { subject, predicate, object, provenance, extractor: documentEvidenceExtractorIdentity,
|
|
13
|
+
confidence: 1, authority: 'inferred', extractionReview: 'deterministic',
|
|
14
|
+
sourceAuthority: classification.sourceAuthority, sourceRole: classification.sourceRole,
|
|
15
|
+
sourceOwnership: classification.ownership, sourcePolicyDigest: state.policyDigest };
|
|
16
|
+
const id = sha256(stableJson({ ...record, repositoryId: state.context.repositoryId }));
|
|
17
|
+
state.facts.set(id, { id, ...record, snapshotId: state.context.snapshotId });
|
|
18
|
+
}
|
|
19
|
+
function addDiagnostic(state, kind, reason, provenance, paths) {
|
|
20
|
+
const citations = [...new Map(provenance.map(citation => [stableJson(citation), citation])).values()];
|
|
21
|
+
const relatedNodeIds = [...new Set(paths.map(path => repositoryFileNode(path).id))].sort();
|
|
22
|
+
const record = { kind, reason, provenance: citations, relatedNodeIds, evidenceScope: 'source' };
|
|
23
|
+
const id = sha256(stableJson({ ...record, extractor: documentEvidenceExtractorIdentity,
|
|
24
|
+
commitSha: state.context.commitSha, policyDigest: state.policyDigest, repositoryId: state.context.repositoryId }));
|
|
25
|
+
state.diagnostics.set(id, { id, ...record });
|
|
26
|
+
}
|
|
27
|
+
function linkCitations(state, link) {
|
|
28
|
+
const citations = [span(state, link.sourcePath, link.startLine, link.endLine)];
|
|
29
|
+
if (link.referenceStartLine !== undefined) {
|
|
30
|
+
citations.push(span(state, link.sourcePath, link.referenceStartLine, link.referenceEndLine));
|
|
31
|
+
}
|
|
32
|
+
for (const definition of link.definitions ?? []) {
|
|
33
|
+
citations.push(span(state, link.sourcePath, definition.startLine, definition.endLine));
|
|
34
|
+
}
|
|
35
|
+
return citations.filter((citation) => citation !== undefined);
|
|
36
|
+
}
|
|
37
|
+
function extractLinks(state, links) {
|
|
38
|
+
for (const link of links) {
|
|
39
|
+
const citations = linkCitations(state, link);
|
|
40
|
+
if ((link.status === 'document' || link.status === 'non-document') && link.path) {
|
|
41
|
+
for (const citation of citations) {
|
|
42
|
+
addFact(state, repositoryFileNode(link.sourcePath), 'references', repositoryFileNode(link.path), citation);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else if (link.status !== 'external' && link.status !== 'fragment') {
|
|
46
|
+
addDiagnostic(state, 'implementationGaps', `Canonical document link is unresolved (${link.status}); no relationship was emitted.`, citations, [link.sourcePath, ...(link.path ? [link.path] : [])]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function reportGaps(state, traversal, classification) {
|
|
51
|
+
for (const issue of traversal.issues) {
|
|
52
|
+
// Link diagnostics above retain their exact declaration/use citations instead of line-one placeholders.
|
|
53
|
+
if (/^(?:link-|ambiguous-reference$|malformed$|outside-repository$)/.test(issue.code))
|
|
54
|
+
continue;
|
|
55
|
+
const citations = issue.paths.flatMap(path => { const citation = span(state, path); return citation ? [citation] : []; });
|
|
56
|
+
addDiagnostic(state, 'implementationGaps', `Document traversal gap [${issue.code}]: ${issue.message}`, citations, issue.paths);
|
|
57
|
+
}
|
|
58
|
+
for (const issue of classification.issues) {
|
|
59
|
+
const citations = issue.paths.flatMap(path => { const citation = span(state, path); return citation ? [citation] : []; });
|
|
60
|
+
addDiagnostic(state, issue.code.includes('ambiguous') ? 'ambiguousIntent' : 'missingIntent', `Document classification gap [${issue.code}]: ${issue.message}`, citations, issue.paths);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Deterministic committed document structure only; never interprets prose, approves rules or publishes evidence. */
|
|
64
|
+
export async function extractDocumentEvidence(repository, context, options) {
|
|
65
|
+
const traversal = await traverseDocuments(repository, context.commitSha, options);
|
|
66
|
+
const documents = traversal.documents.filter((document) => document.status === 'read' && document.digest !== undefined);
|
|
67
|
+
const paths = documents.map(document => document.path);
|
|
68
|
+
const classification = classifyDocumentSources(paths, {
|
|
69
|
+
approvedIntentSources: options.approvedIntentSources ?? [], evidenceRoots: options.evidenceRoots ?? [],
|
|
70
|
+
availableDocuments: paths,
|
|
71
|
+
}, options.documentPolicy);
|
|
72
|
+
const state = { context, documents: new Map(documents.map(document => [document.path, document])),
|
|
73
|
+
classified: new Map(classification.documents.map(document => [document.path, document])),
|
|
74
|
+
policyDigest: classification.policyDigest, facts: new Map(), diagnostics: new Map() };
|
|
75
|
+
const project = { id: `project:${context.repositoryId}`, kind: 'Project', label: context.repositoryId };
|
|
76
|
+
for (const document of documents) {
|
|
77
|
+
addFact(state, project, 'contains', repositoryFileNode(document.path), span(state, document.path));
|
|
78
|
+
}
|
|
79
|
+
extractLinks(state, traversal.links);
|
|
80
|
+
reportGaps(state, traversal, classification);
|
|
81
|
+
return { facts: [...state.facts.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
82
|
+
diagnostics: [...state.diagnostics.values()].sort((a, b) => a.id.localeCompare(b.id)), traversal, classification };
|
|
83
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { GitRepository } from '../repository/git.js';
|
|
2
|
+
import { type CanonicalDocumentLink } from './canonical-document-links.js';
|
|
3
|
+
export interface DocumentTraversalOptions {
|
|
4
|
+
readonly entryPoints: readonly string[];
|
|
5
|
+
readonly roots?: readonly string[];
|
|
6
|
+
readonly maxReadDocuments?: number;
|
|
7
|
+
readonly maxReadBytes?: number;
|
|
8
|
+
readonly maxDocumentBytes?: number;
|
|
9
|
+
readonly maxDocuments?: number;
|
|
10
|
+
readonly maxSourceBytes?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface DocumentReachingLink {
|
|
13
|
+
readonly sourcePath: string;
|
|
14
|
+
readonly startLine: number;
|
|
15
|
+
readonly endLine: number;
|
|
16
|
+
readonly referenceStartLine?: number;
|
|
17
|
+
readonly referenceEndLine?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface TraversalDocument {
|
|
20
|
+
readonly path: string;
|
|
21
|
+
readonly bytes: number;
|
|
22
|
+
readonly digest?: string;
|
|
23
|
+
readonly status: 'read' | 'unread';
|
|
24
|
+
readonly entryPoint: boolean;
|
|
25
|
+
readonly reachedBy: readonly DocumentReachingLink[];
|
|
26
|
+
}
|
|
27
|
+
export type DocumentLinkStatus = 'document' | 'non-document' | 'external' | 'fragment' | 'missing' | 'outside-scope' | 'outside-repository' | 'directory' | 'symlink' | 'submodule' | 'ambiguous' | 'malformed' | 'unsupported';
|
|
28
|
+
export interface DocumentTraversalLink extends DocumentReachingLink {
|
|
29
|
+
readonly target?: string;
|
|
30
|
+
readonly path?: string;
|
|
31
|
+
readonly status: DocumentLinkStatus;
|
|
32
|
+
readonly definitions?: CanonicalDocumentLink['definitions'];
|
|
33
|
+
}
|
|
34
|
+
export interface DocumentTraversalIssue {
|
|
35
|
+
readonly code: string;
|
|
36
|
+
readonly severity: 'error' | 'warning';
|
|
37
|
+
readonly message: string;
|
|
38
|
+
readonly paths: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
export interface DocumentTraversalReport {
|
|
41
|
+
readonly entryPoints: readonly string[];
|
|
42
|
+
readonly roots: readonly string[];
|
|
43
|
+
readonly documents: readonly TraversalDocument[];
|
|
44
|
+
readonly links: readonly DocumentTraversalLink[];
|
|
45
|
+
readonly issues: readonly DocumentTraversalIssue[];
|
|
46
|
+
readonly coverage: {
|
|
47
|
+
readonly complete: boolean;
|
|
48
|
+
readonly unvisited: readonly string[];
|
|
49
|
+
};
|
|
50
|
+
readonly limits: {
|
|
51
|
+
readonly maxReadDocuments: number;
|
|
52
|
+
readonly maxReadBytes: number;
|
|
53
|
+
readonly maxDocumentBytes: number;
|
|
54
|
+
};
|
|
55
|
+
/** Read counters include attempted reads, charged at committed blob sizes even on failure. */
|
|
56
|
+
readonly counters: {
|
|
57
|
+
readonly readDocuments: number;
|
|
58
|
+
readonly readBytes: number;
|
|
59
|
+
readonly discoveredDocuments: number;
|
|
60
|
+
readonly links: number;
|
|
61
|
+
};
|
|
62
|
+
/** Prospective extraction comparison; incomplete traversal yields only known lower bounds. */
|
|
63
|
+
readonly budgets: {
|
|
64
|
+
readonly maxDocuments: number;
|
|
65
|
+
readonly maxSourceBytes: number;
|
|
66
|
+
readonly documents: number;
|
|
67
|
+
readonly bytes: number;
|
|
68
|
+
readonly withinLimits: boolean;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
type TraversalRepository = Pick<GitRepository, 'listEntriesAt' | 'readFileAt'>;
|
|
72
|
+
/** Validate operation/configuration bounds without reading repository data. */
|
|
73
|
+
export declare function validateDocumentTraversalOptions(options: DocumentTraversalOptions): void;
|
|
74
|
+
/** Prospective committed Markdown closure only: never loads execution adapters or writes evidence. */
|
|
75
|
+
export declare function traverseDocuments(repository: TraversalRepository, commitSha: string, options: DocumentTraversalOptions): Promise<DocumentTraversalReport>;
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
import { canonicalDocumentLinks, resolveCanonicalDocumentLink } from './canonical-document-links.js';
|
|
4
|
+
const compare = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
5
|
+
const supportedDocument = (path) => /\.(?:md|mdx|txt)$/i.test(path);
|
|
6
|
+
const unsafePath = (path) => /[\\:*?\[\]{}\x00-\x1f\x7f-\x9f\uFFFD]/.test(path);
|
|
7
|
+
const inside = (path, root) => root === '.' || path === root || path.startsWith(`${root}/`);
|
|
8
|
+
function paths(value, name, allowRoot) {
|
|
9
|
+
if (!Array.isArray(value) || !value.length || value.length > 10_000 ||
|
|
10
|
+
new Set(value).size !== value.length || value.some(path => typeof path !== 'string' || !path ||
|
|
11
|
+
path !== path.trim() || unsafePath(path) || path.length > 4096 || path.endsWith('/') || posix.isAbsolute(path) ||
|
|
12
|
+
path === '..' || path.startsWith('../') || posix.normalize(path) !== path || (!allowRoot && path === '.'))) {
|
|
13
|
+
throw new Error(`${name} must contain unique canonical repository-relative paths without globs or control characters`);
|
|
14
|
+
}
|
|
15
|
+
return [...value].sort(compare);
|
|
16
|
+
}
|
|
17
|
+
function bound(value, fallback, maximum, name) {
|
|
18
|
+
const resolved = value === undefined ? fallback : value;
|
|
19
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) {
|
|
20
|
+
throw new Error(`${name} must be an integer from 1 through ${maximum}`);
|
|
21
|
+
}
|
|
22
|
+
return resolved;
|
|
23
|
+
}
|
|
24
|
+
function settings(options) {
|
|
25
|
+
return {
|
|
26
|
+
entryPoints: paths(options.entryPoints, 'entryPoints', false), roots: paths(options.roots ?? ['.'], 'roots', true),
|
|
27
|
+
limits: {
|
|
28
|
+
maxReadDocuments: bound(options.maxReadDocuments, 1000, 10_000, 'maxReadDocuments'),
|
|
29
|
+
maxReadBytes: bound(options.maxReadBytes, 16 * 1024 * 1024, 64 * 1024 * 1024, 'maxReadBytes'),
|
|
30
|
+
maxDocumentBytes: bound(options.maxDocumentBytes, 1024 * 1024, 4 * 1024 * 1024, 'maxDocumentBytes'),
|
|
31
|
+
},
|
|
32
|
+
maxDocuments: bound(options.maxDocuments, 8, 10_000, 'maxDocuments'),
|
|
33
|
+
maxSourceBytes: bound(options.maxSourceBytes, 65_536, 4 * 1024 * 1024, 'maxSourceBytes'),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Validate operation/configuration bounds without reading repository data. */
|
|
37
|
+
export function validateDocumentTraversalOptions(options) {
|
|
38
|
+
settings(options);
|
|
39
|
+
}
|
|
40
|
+
function issue(state, code, message, affected, coverage = true) {
|
|
41
|
+
state.issues.push({ code, severity: 'error', message, paths: affected });
|
|
42
|
+
if (coverage)
|
|
43
|
+
state.complete = false;
|
|
44
|
+
}
|
|
45
|
+
function entryBoundary(entry) {
|
|
46
|
+
if (entry?.type === 'commit' || entry?.mode === '160000')
|
|
47
|
+
return 'submodule';
|
|
48
|
+
return entry?.mode === '120000' ? 'symlink' : undefined;
|
|
49
|
+
}
|
|
50
|
+
function regularBlob(entry) {
|
|
51
|
+
return entry?.type === 'blob' && ['100644', '100755'].includes(entry.mode) &&
|
|
52
|
+
Number.isSafeInteger(entry.size) && entry.size >= 0;
|
|
53
|
+
}
|
|
54
|
+
function classifyTarget(path, tracked, roots) {
|
|
55
|
+
if (unsafePath(path))
|
|
56
|
+
return 'unsupported';
|
|
57
|
+
const segments = path.split('/');
|
|
58
|
+
for (let count = 1; count < segments.length; count++) {
|
|
59
|
+
const ancestor = tracked.get(segments.slice(0, count).join('/'));
|
|
60
|
+
const boundary = ancestor?.mode === '120000' ? 'symlink' : entryBoundary(ancestor);
|
|
61
|
+
if (boundary)
|
|
62
|
+
return boundary;
|
|
63
|
+
}
|
|
64
|
+
const entry = tracked.get(path);
|
|
65
|
+
const boundary = entryBoundary(entry);
|
|
66
|
+
if (boundary)
|
|
67
|
+
return boundary;
|
|
68
|
+
// Roots bound document traversal; an existing regular code/asset link needs no body read.
|
|
69
|
+
const regular = regularBlob(entry);
|
|
70
|
+
if (regular && !supportedDocument(path))
|
|
71
|
+
return 'non-document';
|
|
72
|
+
if (!roots.some(root => inside(path, root)))
|
|
73
|
+
return 'outside-scope';
|
|
74
|
+
if (path === '.' || entry?.type === 'tree')
|
|
75
|
+
return 'directory';
|
|
76
|
+
if (!entry)
|
|
77
|
+
return 'missing';
|
|
78
|
+
return regular ? 'document' : 'unsupported';
|
|
79
|
+
}
|
|
80
|
+
function discover(state, entryPoints, path, reaching) {
|
|
81
|
+
const old = state.documents.get(path);
|
|
82
|
+
if (old) {
|
|
83
|
+
if (reaching)
|
|
84
|
+
state.documents.set(path, { ...old, reachedBy: [...old.reachedBy, reaching] });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
state.documents.set(path, { path, bytes: state.tracked.get(path).size, status: 'unread',
|
|
88
|
+
entryPoint: entryPoints.includes(path), reachedBy: reaching ? [reaching] : [] });
|
|
89
|
+
state.queue.push(path);
|
|
90
|
+
}
|
|
91
|
+
function initializeEntries(state, selection) {
|
|
92
|
+
for (const root of selection.roots)
|
|
93
|
+
if (root !== '.' && !state.tracked.has(root)) {
|
|
94
|
+
issue(state, 'missing-root', 'A configured traversal root is not a committed entry.', [root]);
|
|
95
|
+
}
|
|
96
|
+
for (const path of selection.entryPoints) {
|
|
97
|
+
const status = classifyTarget(path, state.tracked, selection.roots);
|
|
98
|
+
if (status === 'document')
|
|
99
|
+
discover(state, selection.entryPoints, path);
|
|
100
|
+
else
|
|
101
|
+
issue(state, `entry-${status}`, 'An entry point is not an in-scope regular document.', [path]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function readLimit(document, state, limits) {
|
|
105
|
+
if (document.bytes > limits.maxDocumentBytes)
|
|
106
|
+
return 'document-read-bytes';
|
|
107
|
+
if (state.readDocuments >= limits.maxReadDocuments)
|
|
108
|
+
return 'document-read-count';
|
|
109
|
+
return state.readBytes + document.bytes > limits.maxReadBytes ? 'total-read-bytes' : undefined;
|
|
110
|
+
}
|
|
111
|
+
async function readDocumentLinks(repository, commitSha, document, state, limits) {
|
|
112
|
+
const blocked = readLimit(document, state, limits);
|
|
113
|
+
if (blocked) {
|
|
114
|
+
issue(state, blocked, 'A discovered document was not read because a traversal safety limit would be exceeded.', [document.path]);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
state.readDocuments++;
|
|
118
|
+
state.readBytes += document.bytes;
|
|
119
|
+
let content;
|
|
120
|
+
try {
|
|
121
|
+
content = await repository.readFileAt(commitSha, document.path);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
issue(state, 'document-read-failed', 'A committed document could not be read.', [document.path]);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Git's string API cannot prove whether U+FFFD was literal or introduced by lossy UTF-8 decoding.
|
|
128
|
+
if (content.includes('\uFFFD') || content.includes('\0') || Buffer.byteLength(content) !== document.bytes) {
|
|
129
|
+
issue(state, 'unsupported-document-encoding', 'Document bytes cannot be represented unambiguously as supported UTF-8 text.', [document.path]);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
return { references: canonicalDocumentLinks(content), digest: createHash('sha256').update(content).digest('hex') };
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
issue(state, 'document-parse-failed', 'Committed Markdown links could not be parsed.', [document.path]);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function followReference(state, selection, path, reference) {
|
|
141
|
+
const citation = { sourcePath: path, startLine: reference.startLine, endLine: reference.endLine,
|
|
142
|
+
...(reference.referenceStartLine === undefined ? {} : { referenceStartLine: reference.referenceStartLine,
|
|
143
|
+
referenceEndLine: reference.referenceEndLine }) };
|
|
144
|
+
if (reference.kind === 'ambiguous-reference') {
|
|
145
|
+
state.links.push({ ...citation, status: 'ambiguous', definitions: reference.definitions });
|
|
146
|
+
issue(state, 'ambiguous-reference', 'A used reference has conflicting Markdown definitions.', [path]);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const resolved = resolveCanonicalDocumentLink(path, reference.target);
|
|
150
|
+
if (resolved.kind !== 'path') {
|
|
151
|
+
state.links.push({ ...citation, target: reference.target, status: resolved.kind });
|
|
152
|
+
if (resolved.kind !== 'external' && resolved.kind !== 'fragment')
|
|
153
|
+
issue(state, resolved.kind, resolved.reason, [path]);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const classified = classifyTarget(resolved.path, state.tracked, selection.roots);
|
|
157
|
+
const status = resolved.directoryHint && ['document', 'non-document'].includes(classified) ? 'unsupported' : classified;
|
|
158
|
+
state.links.push({ ...citation, target: reference.target, path: resolved.path, status });
|
|
159
|
+
if (status === 'document')
|
|
160
|
+
discover(state, selection.entryPoints, resolved.path, citation);
|
|
161
|
+
else if (status !== 'non-document')
|
|
162
|
+
issue(state, `link-${status}`, 'A canonical local link could not be traversed as a regular in-scope document.', [path, resolved.path]);
|
|
163
|
+
}
|
|
164
|
+
async function walkDocuments(repository, commitSha, state, selection) {
|
|
165
|
+
for (let index = 0; index < state.queue.length; index++) {
|
|
166
|
+
const path = state.queue[index];
|
|
167
|
+
const read = await readDocumentLinks(repository, commitSha, state.documents.get(path), state, selection.limits);
|
|
168
|
+
if (!read)
|
|
169
|
+
continue;
|
|
170
|
+
state.documents.set(path, { ...state.documents.get(path), status: 'read', digest: read.digest });
|
|
171
|
+
for (const reference of read.references)
|
|
172
|
+
followReference(state, selection, path, reference);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function finishReport(state, selection, indexReadable) {
|
|
176
|
+
const { entryPoints, roots, limits, maxDocuments, maxSourceBytes } = selection;
|
|
177
|
+
const ordered = [...state.documents.values()].sort((a, b) => compare(a.path, b.path));
|
|
178
|
+
const bytes = ordered.reduce((sum, document) => sum + document.bytes, 0);
|
|
179
|
+
const oversized = ordered.filter(document => document.bytes > maxSourceBytes).map(document => document.path);
|
|
180
|
+
if (ordered.length > maxDocuments)
|
|
181
|
+
issue(state, 'document-budget', 'Known reachable documents exceed the prospective extraction count budget.', ordered.map(document => document.path), false);
|
|
182
|
+
if (oversized.length)
|
|
183
|
+
issue(state, 'source-byte-budget', 'Known reachable documents exceed the prospective per-source extraction byte budget.', oversized, false);
|
|
184
|
+
return { entryPoints, roots, documents: ordered, links: state.links, issues: state.issues,
|
|
185
|
+
coverage: { complete: state.complete, unvisited: indexReadable ? ordered.filter(document => document.status === 'unread').map(document => document.path) : entryPoints },
|
|
186
|
+
limits, counters: { readDocuments: state.readDocuments, readBytes: state.readBytes, discoveredDocuments: ordered.length, links: state.links.length },
|
|
187
|
+
budgets: { maxDocuments, maxSourceBytes, documents: ordered.length, bytes,
|
|
188
|
+
withinLimits: ordered.length <= maxDocuments && !oversized.length } };
|
|
189
|
+
}
|
|
190
|
+
/** Prospective committed Markdown closure only: never loads execution adapters or writes evidence. */
|
|
191
|
+
export async function traverseDocuments(repository, commitSha, options) {
|
|
192
|
+
if (!/^(?:[a-f\d]{40}|[a-f\d]{64})$/i.test(commitSha)) {
|
|
193
|
+
throw new Error('Document traversal requires a resolved immutable commit SHA');
|
|
194
|
+
}
|
|
195
|
+
const selection = settings(options);
|
|
196
|
+
const state = { tracked: new Map(), documents: new Map(), links: [], issues: [], queue: [],
|
|
197
|
+
complete: true, readDocuments: 0, readBytes: 0 };
|
|
198
|
+
let indexReadable = true;
|
|
199
|
+
try {
|
|
200
|
+
state.tracked = new Map((await repository.listEntriesAt(commitSha)).map(entry => [entry.path, entry]));
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
indexReadable = false;
|
|
204
|
+
issue(state, 'repository-index-unavailable', 'Committed repository entries could not be read.', selection.entryPoints);
|
|
205
|
+
}
|
|
206
|
+
if (indexReadable)
|
|
207
|
+
initializeEntries(state, selection);
|
|
208
|
+
await walkDocuments(repository, commitSha, state, selection);
|
|
209
|
+
return finishReport(state, selection, indexReadable);
|
|
210
|
+
}
|
|
@@ -6,7 +6,9 @@ export declare const documentLinkerIdentity: {
|
|
|
6
6
|
export declare function linkSemanticDocuments(context: ExtractionContext, hydrated: readonly {
|
|
7
7
|
source: SemanticSource;
|
|
8
8
|
artifact: SemanticArtifact;
|
|
9
|
-
}[], trackedPaths: readonly string[], approvedSourcePaths?: readonly string[]
|
|
9
|
+
}[], trackedPaths: readonly string[], approvedSourcePaths?: readonly string[], options?: {
|
|
10
|
+
readonly canonicalReferences?: boolean;
|
|
11
|
+
}): {
|
|
10
12
|
readonly facts: readonly GraphFact[];
|
|
11
13
|
readonly diagnostics: readonly GraphDiagnostic[];
|
|
12
14
|
};
|
|
@@ -2,7 +2,7 @@ import { sha256, stableJson } from '../infrastructure/util.js';
|
|
|
2
2
|
import { documentReferences, resolveDocumentReference } from './document-references.js';
|
|
3
3
|
import { repositoryFileNode as fileNode } from '@weftlet/core';
|
|
4
4
|
export const documentLinkerIdentity = { id: 'document-linker', version: '2.0.0' };
|
|
5
|
-
export function linkSemanticDocuments(context, hydrated, trackedPaths, approvedSourcePaths = []) {
|
|
5
|
+
export function linkSemanticDocuments(context, hydrated, trackedPaths, approvedSourcePaths = [], options = {}) {
|
|
6
6
|
const facts = new Map();
|
|
7
7
|
const diagnostics = new Map();
|
|
8
8
|
const tracked = new Set(trackedPaths);
|
|
@@ -27,6 +27,8 @@ export function linkSemanticDocuments(context, hydrated, trackedPaths, approvedS
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
for (const reference of documentReferences(source.content)) {
|
|
30
|
+
if (options.canonicalReferences === false && reference.relativeTo === 'document')
|
|
31
|
+
continue;
|
|
30
32
|
const resolved = resolveDocumentReference(source.path, reference, tracked);
|
|
31
33
|
if (resolved.kind === 'ignored')
|
|
32
34
|
continue;
|
package/dist/graph/authority.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export function contextPackEvidenceAuthority(fact) {
|
|
2
2
|
if (fact.authority)
|
|
3
3
|
return fact.authority;
|
|
4
|
+
// A document's role describes its source, not whether its claims were checked.
|
|
5
|
+
if (fact.sourceRole !== undefined)
|
|
6
|
+
return 'inferred';
|
|
4
7
|
if (fact.extractor.id === 'otlp-runtime-evidence')
|
|
5
8
|
return 'observed';
|
|
6
9
|
if (fact.extractor.id.startsWith('codex-cli:'))
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ContextPackCoverage, type ContextPackLane, type GraphFact } from '@weftlet/core';
|
|
2
|
+
export declare const contextPackLanes: readonly ContextPackLane[];
|
|
3
|
+
export declare function isDeclaredFact(fact: GraphFact): boolean;
|
|
4
|
+
export declare function contextPackLaneForFact(fact: GraphFact): ContextPackLane | undefined;
|
|
5
|
+
export declare function coverageDescription(coverage: ContextPackCoverage): string;
|
|
6
|
+
/** Reporting only: never changes a fact's authority, identity, primary lane or stored bytes. */
|
|
7
|
+
export declare function isImplementationEvidence(fact: GraphFact): boolean;
|
|
8
|
+
export declare function coverageForFacts(facts: readonly GraphFact[]): ContextPackCoverage;
|