graphlin 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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +12 -3
- package/docs/decision-service.md +393 -0
- package/docs/extension-authoring.md +553 -0
- package/docs/model-api.md +293 -0
- package/docs/usage.md +465 -0
- package/docs/visualizer-views.md +199 -0
- package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
- package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
- package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
- package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
- package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
- package/package.json +74 -9
- package/plugin.json +4 -2
- package/runtime/core/evidence.mjs +43 -9
- package/runtime/core/graph.mjs +11 -6
- package/runtime/core/privacy.mjs +1 -0
- package/runtime/daemon/auth.mjs +7 -3
- package/runtime/daemon/diagnostics.mjs +1 -1
- package/runtime/daemon/extension-api.mjs +203 -0
- package/runtime/daemon/lineage.mjs +70 -0
- package/runtime/daemon/manager.mjs +9 -6
- package/runtime/daemon/model-api.mjs +728 -0
- package/runtime/daemon/model-persistence.mjs +220 -0
- package/runtime/daemon/server.mjs +70 -12
- package/runtime/daemon/settings.mjs +11 -3
- package/runtime/decisions/broker.mjs +349 -0
- package/runtime/decisions/contracts.mjs +179 -0
- package/runtime/decisions/evaluation.mjs +305 -0
- package/runtime/decisions/faults.mjs +32 -0
- package/runtime/decisions/index.mjs +818 -0
- package/runtime/decisions/profiles.mjs +93 -0
- package/runtime/decisions/questions.mjs +268 -0
- package/runtime/discovery/index.mjs +2 -0
- package/runtime/discovery/inventory.mjs +160 -0
- package/runtime/discovery/parser.mjs +40 -0
- package/runtime/discovery/structure.mjs +232 -0
- package/runtime/extensions/contracts.mjs +59 -0
- package/runtime/extensions/frame.mjs +64 -0
- package/runtime/extensions/index.mjs +9 -0
- package/runtime/extensions/manifest.mjs +95 -0
- package/runtime/extensions/packages.mjs +222 -0
- package/runtime/extensions/profiles.mjs +36 -0
- package/runtime/extensions/projection.mjs +130 -0
- package/runtime/extensions/registry.mjs +285 -0
- package/runtime/extensions/scene.mjs +105 -0
- package/runtime/extensions/sdk.d.ts +205 -0
- package/runtime/extensions/sdk.mjs +88 -0
- package/runtime/jev/index.mjs +13 -777
- package/runtime/jev/provider.mjs +101 -0
- package/runtime/jev/questions.mjs +16 -258
- package/runtime/jev/wire.mjs +17 -25
- package/runtime/model/changes.mjs +42 -0
- package/runtime/model/history.mjs +124 -0
- package/runtime/model/index.mjs +2 -0
- package/runtime/model/project-model.mjs +889 -0
- package/runtime/model/records.mjs +239 -0
- package/runtime/pipeline.mjs +127 -48
- package/runtime/platform.mjs +254 -0
- package/runtime/visualizers/blocks.mjs +5 -0
- package/runtime/visualizers/c4.mjs +52 -0
- package/runtime/visualizers/changes.mjs +24 -0
- package/runtime/visualizers/code.mjs +5 -0
- package/runtime/visualizers/index.mjs +23 -0
- package/runtime/visualizers/structure.mjs +120 -0
- package/runtime/visualizers/timeline.mjs +66 -0
- package/runtime/web/app.js +369 -86
- package/runtime/web/extension-frame.js +128 -0
- package/runtime/web/index.html +123 -80
- package/runtime/web/model-client.js +162 -0
- package/runtime/web/platform.js +337 -0
- package/runtime/web/scene.js +111 -0
- package/runtime/web/style.css +152 -142
- package/schemas/graph.schema.json +4 -1
- package/scripts/arguments.mjs +5 -1
- package/scripts/build-packages.mjs +6 -2
- package/scripts/control.mjs +1 -1
- package/scripts/daemon.mjs +2 -1
- package/scripts/extensions.mjs +44 -0
- package/scripts/graphlin.mjs +23 -3
- package/scripts/onboarding.mjs +10 -3
- package/scripts/validate-packages.mjs +54 -8
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { freeze, hash as contentHash, isHash, isId, opaque, plain } from '../core/common.mjs';
|
|
3
|
+
import { excluded, safeLabel, privateText, safeText } from '../core/privacy.mjs';
|
|
4
|
+
import { loadParser } from './parser.mjs';
|
|
5
|
+
|
|
6
|
+
export const IDENTITY_VERSION = 'source-scope-v1';
|
|
7
|
+
export const EXTRACTOR_VERSION = 'structure-v1';
|
|
8
|
+
export const STRUCTURE_LIMITS = Object.freeze({
|
|
9
|
+
fileBytes: 256 * 1024, entities: 2048, imports: 512, bindings: 128, nodes: 50000, depth: 128, milliseconds: 100,
|
|
10
|
+
});
|
|
11
|
+
const LANGUAGES = { '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'javascript',
|
|
12
|
+
'.ts': 'typescript', '.mts': 'typescript', '.cts': 'typescript', '.tsx': 'tsx', '.py': 'python', '.pyi': 'python' };
|
|
13
|
+
const DECLARATIONS = {
|
|
14
|
+
class_declaration: 'class', abstract_class_declaration: 'class', class_definition: 'class',
|
|
15
|
+
function_declaration: 'function', generator_function_declaration: 'function',
|
|
16
|
+
function_definition: 'function', function_signature: 'function',
|
|
17
|
+
method_definition: 'method', method_signature: 'method', abstract_method_signature: 'method',
|
|
18
|
+
interface_declaration: 'interface', internal_module: 'namespace', module: 'namespace',
|
|
19
|
+
enum_declaration: 'enum', type_alias_declaration: 'type_alias',
|
|
20
|
+
};
|
|
21
|
+
const FUNCTIONS = new Set(['arrow_function', 'function_expression', 'generator_function', 'function']);
|
|
22
|
+
const field = (node, name) => node.childForFieldName(name);
|
|
23
|
+
const lines = node => ({ startLine: node.startPosition.row + 1, endLine: Math.max(node.startPosition.row + 1,
|
|
24
|
+
node.endPosition.row + (node.endPosition.column === 0 ? 0 : 1)) });
|
|
25
|
+
const simpleName = node => node && ['identifier', 'property_identifier', 'private_property_identifier',
|
|
26
|
+
'type_identifier', 'dotted_name', 'nested_identifier'].includes(node.type) ? node.text : null;
|
|
27
|
+
|
|
28
|
+
function budget(options = {}) {
|
|
29
|
+
options = plain(options) ? options : {};
|
|
30
|
+
return Object.fromEntries(Object.entries(STRUCTURE_LIMITS).map(([key, max]) =>
|
|
31
|
+
[key, Number.isSafeInteger(options[key]) && options[key] > 0 ? Math.min(max, options[key]) : max]));
|
|
32
|
+
}
|
|
33
|
+
function literal(node) {
|
|
34
|
+
if (!node || node.type !== 'string' || node.hasError) return null;
|
|
35
|
+
const text = node.text;
|
|
36
|
+
// Escaped/interpolated/dynamic module specifiers require a resolver. Do not
|
|
37
|
+
// turn their source spelling into a claimed literal target.
|
|
38
|
+
return text.length > 2 && text[0] === text.at(-1) && !/[\\\r\n]/.test(text.slice(1, -1)) ? text.slice(1, -1) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract local structure from an already-authorized immutable capture.
|
|
43
|
+
* No source I/O, transmission, classification, or runtime claims occur here.
|
|
44
|
+
* `complete:false` is mandatory for caller-truncated captures/windows.
|
|
45
|
+
*/
|
|
46
|
+
export async function extractStructure({
|
|
47
|
+
artifactId, relativePath, text, hash, generation, complete = true, limits: options, signal,
|
|
48
|
+
} = {}) {
|
|
49
|
+
if (!isId(artifactId) || typeof relativePath !== 'string' || !relativePath || path.posix.isAbsolute(relativePath) ||
|
|
50
|
+
relativePath.split('/').some(part => !part || part === '..' || part === '.') || /[\\\0\r\n]/.test(relativePath) ||
|
|
51
|
+
!Number.isSafeInteger(generation) || generation < 1 || !isHash(hash)) throw new TypeError('INVALID_STRUCTURE_INPUT');
|
|
52
|
+
const limits = budget(options);
|
|
53
|
+
const language = LANGUAGES[path.posix.extname(relativePath).toLowerCase()] ?? null;
|
|
54
|
+
const scopeId = opaque('entity', IDENTITY_VERSION, artifactId, language, 'module');
|
|
55
|
+
const omissions = [];
|
|
56
|
+
const omit = reason => { if (!omissions.includes(reason)) omissions.push(reason); };
|
|
57
|
+
const entities = [], relations = [], imports = [];
|
|
58
|
+
const enumeration = {
|
|
59
|
+
complete: false, extractor: 'file-level', version: '1', scopeId, omissions, artifactId, hash, generation,
|
|
60
|
+
identityVersion: IDENTITY_VERSION, capability: language ? 'lexical' : 'unsupported', coveredRanges: [],
|
|
61
|
+
};
|
|
62
|
+
const result = () => freeze({ entities, relations, imports, enumeration });
|
|
63
|
+
if (excluded(relativePath) || !safeText(relativePath, 4096)) { omit('excluded_path'); return result(); }
|
|
64
|
+
const label = path.posix.basename(relativePath);
|
|
65
|
+
if (!safeLabel(label)) { omit('unsafe_label'); return result(); }
|
|
66
|
+
// File identity is metadata; without usable source there are no declaration
|
|
67
|
+
// spans or ownership edges. The host still owns the display/persistence grant.
|
|
68
|
+
entities.push({ id: scopeId, label, kind: 'module', parentId: null, artifactId,
|
|
69
|
+
startLine: 1, endLine: 1, qualifiedName: relativePath });
|
|
70
|
+
if (!language) { omit('unsupported_language'); return result(); }
|
|
71
|
+
if (typeof text !== 'string') { omit('source_unavailable'); return result(); }
|
|
72
|
+
if (text.length > limits.fileBytes || Buffer.byteLength(text, 'utf8') > limits.fileBytes) {
|
|
73
|
+
omit('file_bytes'); return result();
|
|
74
|
+
}
|
|
75
|
+
if (text.includes('\0') || privateText(text)) { omit('source_filtered'); return result(); }
|
|
76
|
+
if (contentHash(text) !== hash) { omit('hash_mismatch'); return result(); }
|
|
77
|
+
if (complete !== true) omit('partial_capture');
|
|
78
|
+
if (signal?.aborted) { omit('aborted'); return result(); }
|
|
79
|
+
entities[0].endLine = Math.max(1, text.split('\n').length - (text.endsWith('\n') ? 1 : 0));
|
|
80
|
+
|
|
81
|
+
let parser, tree, loaded;
|
|
82
|
+
try {
|
|
83
|
+
loaded = await loadParser(language);
|
|
84
|
+
if (signal?.aborted) { omit('aborted'); return result(); }
|
|
85
|
+
parser = new loaded.Parser();
|
|
86
|
+
parser.setLanguage(loaded.grammar);
|
|
87
|
+
const deadline = performance.now() + limits.milliseconds;
|
|
88
|
+
tree = parser.parse(text, null, { progressCallback: () => signal?.aborted || performance.now() >= deadline });
|
|
89
|
+
if (!tree) { omit(signal?.aborted ? 'aborted' : 'parse_timeout'); return result(); }
|
|
90
|
+
enumeration.extractor = 'tree-sitter';
|
|
91
|
+
enumeration.version = `${EXTRACTOR_VERSION}/${loaded.version}`;
|
|
92
|
+
enumeration.capability = 'parsed';
|
|
93
|
+
if (tree.rootNode.hasError) omit('parse_error');
|
|
94
|
+
const counts = new Map();
|
|
95
|
+
const add = (name, kind, node, owner) => {
|
|
96
|
+
if (!safeLabel(name)) { omit('unsafe_label'); return null; }
|
|
97
|
+
if (entities.length >= limits.entities) { omit('entity_limit'); return null; }
|
|
98
|
+
const key = JSON.stringify([owner.id, kind, name]);
|
|
99
|
+
const ordinal = counts.get(key) ?? 0;
|
|
100
|
+
counts.set(key, ordinal + 1);
|
|
101
|
+
const id = opaque('entity', IDENTITY_VERSION, artifactId, language, owner.id, kind, name, ordinal);
|
|
102
|
+
const entity = { id, label: name, kind, parentId: owner.id, artifactId, ...lines(node),
|
|
103
|
+
qualifiedName: `${owner.qualifiedName}::${name}${ordinal ? `#${ordinal + 1}` : ''}` };
|
|
104
|
+
entities.push(entity);
|
|
105
|
+
relations.push({ id: opaque('relation', 'contains', owner.id, id), source: owner.id, target: id, kind: 'contains' });
|
|
106
|
+
return entity;
|
|
107
|
+
};
|
|
108
|
+
const importCounts = new Map();
|
|
109
|
+
const addImport = (specifier, bindings, node, owner, kind = 'import') => {
|
|
110
|
+
if (!specifier || !safeText(specifier, 512) || /[\r\n]/.test(specifier)) { omit('unresolved_import'); return; }
|
|
111
|
+
if (imports.length >= limits.imports) { omit('import_limit'); return; }
|
|
112
|
+
if (bindings.length > limits.bindings) omit('binding_limit');
|
|
113
|
+
const boundedBindings = bindings.slice(0, limits.bindings);
|
|
114
|
+
const valid = boundedBindings
|
|
115
|
+
.filter(binding => safeLabel(binding.local) && (binding.imported === '*' || safeLabel(binding.imported)));
|
|
116
|
+
if (valid.length !== boundedBindings.length) omit('unsafe_import_binding');
|
|
117
|
+
const key = JSON.stringify([owner.id, kind, specifier, valid]);
|
|
118
|
+
const ordinal = importCounts.get(key) ?? 0;
|
|
119
|
+
importCounts.set(key, ordinal + 1);
|
|
120
|
+
imports.push({ id: opaque('import', IDENTITY_VERSION, artifactId, language, key, ordinal),
|
|
121
|
+
artifactId, ownerId: owner.id, specifier, bindings: valid, ...lines(node), kind, resolved: false });
|
|
122
|
+
};
|
|
123
|
+
const stack = [{ node: tree.rootNode, owner: entities[0], depth: 0, skipDeclaration: false }];
|
|
124
|
+
let visited = 0;
|
|
125
|
+
while (stack.length) {
|
|
126
|
+
if (++visited > limits.nodes) { omit('node_limit'); break; }
|
|
127
|
+
if (signal?.aborted || performance.now() >= deadline) { omit(signal?.aborted ? 'aborted' : 'extraction_timeout'); break; }
|
|
128
|
+
const { node, owner, depth, skipDeclaration } = stack.pop();
|
|
129
|
+
if (depth > limits.depth) { omit('depth_limit'); continue; }
|
|
130
|
+
// Error recovery can preserve valid siblings, but never establishes the
|
|
131
|
+
// lexical parent of a declaration hidden inside an error subtree.
|
|
132
|
+
if (node.isError || node.isMissing) { omit('parse_error'); continue; }
|
|
133
|
+
let childOwner = owner, skipValue = null;
|
|
134
|
+
let kind = DECLARATIONS[node.type], name = simpleName(field(node, 'name'));
|
|
135
|
+
if (node.type === 'module' && language === 'python') kind = null;
|
|
136
|
+
if (node.type === 'function_definition' && owner.kind === 'class') kind = 'method';
|
|
137
|
+
if (node.type === 'variable_declarator') {
|
|
138
|
+
name = simpleName(field(node, 'name'));
|
|
139
|
+
const value = field(node, 'value');
|
|
140
|
+
kind = value && FUNCTIONS.has(value.type) ? 'function' : value?.type === 'class' ? 'class' : 'variable';
|
|
141
|
+
if (value && (FUNCTIONS.has(value.type) || ['class', 'object'].includes(value.type))) skipValue = value.id;
|
|
142
|
+
} else if (['pair', 'public_field_definition', 'field_definition'].includes(node.type)) {
|
|
143
|
+
const value = field(node, 'value');
|
|
144
|
+
if (value && (FUNCTIONS.has(value.type) || value.type === 'object' || value.type === 'class')) {
|
|
145
|
+
name = simpleName(field(node, 'key') ?? field(node, 'property') ?? field(node, 'name'));
|
|
146
|
+
kind = FUNCTIONS.has(value.type) ? 'method' : value.type === 'class' ? 'class' : 'variable';
|
|
147
|
+
skipValue = value.id;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!skipDeclaration && kind) {
|
|
151
|
+
if (!name) {
|
|
152
|
+
// Binding patterns, computed names, and anonymous declarations cannot
|
|
153
|
+
// be assigned an invented stable owner.
|
|
154
|
+
omit('unsupported_declaration'); continue;
|
|
155
|
+
}
|
|
156
|
+
if (node.hasError) { omit('parse_error'); continue; }
|
|
157
|
+
childOwner = add(name, kind, node, owner);
|
|
158
|
+
if (!childOwner) continue;
|
|
159
|
+
} else if (!skipDeclaration && (FUNCTIONS.has(node.type) || ['class', 'lambda', 'object'].includes(node.type))) {
|
|
160
|
+
omit('anonymous_scope'); continue;
|
|
161
|
+
}
|
|
162
|
+
if (language === 'python' && ['import_statement', 'import_from_statement'].includes(node.type) && !node.hasError) {
|
|
163
|
+
const module = field(node, 'module_name')?.text;
|
|
164
|
+
const names = node.namedChildren.filter(child => child.id !== field(node, 'module_name')?.id);
|
|
165
|
+
if (node.type === 'import_from_statement') {
|
|
166
|
+
const bindings = names.map(child => child.type === 'aliased_import'
|
|
167
|
+
? { imported: field(child, 'name').text, local: field(child, 'alias').text }
|
|
168
|
+
: { imported: child.text, local: child.text });
|
|
169
|
+
addImport(module, bindings, node, owner);
|
|
170
|
+
} else {
|
|
171
|
+
for (const child of names) {
|
|
172
|
+
const specifier = child.type === 'aliased_import' ? field(child, 'name').text : child.text;
|
|
173
|
+
addImport(specifier, [{ imported: '*', local: field(child, 'alias')?.text ?? specifier.split('.')[0] }], node, owner);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} else if (language !== 'python' && ['import_statement', 'export_statement'].includes(node.type) && !node.hasError) {
|
|
177
|
+
const requireClause = node.namedChildren.find(child => child.type === 'import_require_clause');
|
|
178
|
+
const source = field(node, 'source') ?? (requireClause && field(requireClause, 'source'));
|
|
179
|
+
if (source) {
|
|
180
|
+
const bindings = [];
|
|
181
|
+
const clause = node.namedChildren.find(child => child.type === 'import_clause');
|
|
182
|
+
for (const child of clause?.namedChildren ?? []) {
|
|
183
|
+
if (child.type === 'identifier') bindings.push({ imported: 'default', local: child.text });
|
|
184
|
+
else if (child.type === 'namespace_import') bindings.push({ imported: '*', local: child.namedChildren[0].text });
|
|
185
|
+
else if (child.type === 'named_imports') for (const specifier of child.namedChildren) {
|
|
186
|
+
bindings.push({ imported: field(specifier, 'name')?.text, local: (field(specifier, 'alias') ?? field(specifier, 'name'))?.text });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (requireClause) bindings.push({ imported: '*', local: requireClause.namedChildren[0].text });
|
|
190
|
+
const exports = node.namedChildren.find(child => child.type === 'export_clause');
|
|
191
|
+
for (const specifier of exports?.namedChildren ?? []) {
|
|
192
|
+
bindings.push({ imported: field(specifier, 'name')?.text, local: (field(specifier, 'alias') ?? field(specifier, 'name'))?.text });
|
|
193
|
+
}
|
|
194
|
+
const namespace = node.namedChildren.find(child => child.type === 'namespace_export');
|
|
195
|
+
if (namespace) bindings.push({ imported: '*', local: namespace.namedChildren[0].text });
|
|
196
|
+
addImport(literal(source), bindings, node, owner, node.type === 'export_statement' ? 'reexport' : 'import');
|
|
197
|
+
} else if (node.type === 'import_statement') omit('unresolved_import');
|
|
198
|
+
} else if (node.type === 'call_expression' && field(node, 'function')?.type === 'import') {
|
|
199
|
+
const source = field(node, 'arguments')?.namedChildren[0];
|
|
200
|
+
addImport(literal(source), [], node, owner, 'dynamic_import');
|
|
201
|
+
} else if (node.type === 'call_expression' && field(node, 'function')?.text === 'require') {
|
|
202
|
+
// A call spelled `require` can be shadowed. Preserve only the syntactic
|
|
203
|
+
// reference; a binding resolver must establish that it loads a module.
|
|
204
|
+
addImport(literal(field(node, 'arguments')?.namedChildren[0]), [], node, owner, 'require_reference');
|
|
205
|
+
omit('require_resolution');
|
|
206
|
+
}
|
|
207
|
+
// Limit traversal width as well as the total number of nodes. Iterating
|
|
208
|
+
// children in reverse preserves source order without recursive JS calls.
|
|
209
|
+
if (node.namedChildCount + stack.length > limits.nodes) { omit('node_limit'); break; }
|
|
210
|
+
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
|
211
|
+
const child = node.namedChild(i);
|
|
212
|
+
stack.push({ node: child, owner: childOwner, depth: depth + 1, skipDeclaration: child.id === skipValue });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
enumeration.complete = omissions.length === 0;
|
|
216
|
+
if (enumeration.complete) enumeration.coveredRanges = [{ startLine: 1, endLine: entities[0].endLine }];
|
|
217
|
+
return result();
|
|
218
|
+
} catch {
|
|
219
|
+
// Dependency/grammar failures leave a visibly limited file-level fallback.
|
|
220
|
+
// Fixed diagnostics avoid retaining source text or local filesystem paths.
|
|
221
|
+
entities.splice(1); relations.length = 0; imports.length = 0;
|
|
222
|
+
enumeration.capability = 'lexical';
|
|
223
|
+
enumeration.extractor = 'file-level';
|
|
224
|
+
enumeration.version = '1';
|
|
225
|
+
enumeration.coveredRanges = [];
|
|
226
|
+
omit('parser_unavailable');
|
|
227
|
+
return result();
|
|
228
|
+
} finally {
|
|
229
|
+
tree?.delete();
|
|
230
|
+
parser?.delete();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Browser-safe API constants and small validators shared by the host and SDK.
|
|
2
|
+
export const API_VERSION = 1;
|
|
3
|
+
export const MODEL_SCHEMA = 2;
|
|
4
|
+
export const SCENE_VERSION = 1;
|
|
5
|
+
export const MANIFEST_FILE = 'graphlin.extension.json';
|
|
6
|
+
export const FEATURES = Object.freeze([
|
|
7
|
+
'containment', 'canonical-mappings', 'scene-groups', 'activity', 'checkpoints',
|
|
8
|
+
]);
|
|
9
|
+
export const CAPABILITIES = Object.freeze([
|
|
10
|
+
'model.read', 'activity.read', 'selection.request', 'inspection.request', 'history.read',
|
|
11
|
+
'analysis.request',
|
|
12
|
+
]);
|
|
13
|
+
export const DATA_FIELDS = Object.freeze([
|
|
14
|
+
'entities', 'relations', 'interpretations', 'activity', 'coverage', 'sessions', 'checkpoints',
|
|
15
|
+
]);
|
|
16
|
+
export const EXTENSION_LIMITS = Object.freeze({
|
|
17
|
+
manifestBytes: 64 * 1024, assetBytes: 2 * 1024 * 1024, packageBytes: 8 * 1024 * 1024,
|
|
18
|
+
archiveBytes: 12 * 1024 * 1024, assets: 32, files: 128, depth: 8,
|
|
19
|
+
nodes: 256, groups: 64, edges: 768, sceneBytes: 1024 * 1024,
|
|
20
|
+
coordinate: 100_000, dimension: 20_000, members: 256, text: 240,
|
|
21
|
+
projectionBytes: 8 * 1024 * 1024, entities: 20_000, relations: 40_000,
|
|
22
|
+
});
|
|
23
|
+
export function extensionError(code) {
|
|
24
|
+
return Object.assign(new Error(code), { code });
|
|
25
|
+
}
|
|
26
|
+
export function check(condition, code = 'invalid_extension') {
|
|
27
|
+
if (!condition) throw extensionError(code);
|
|
28
|
+
}
|
|
29
|
+
export const plain = value => value !== null && typeof value === 'object' &&
|
|
30
|
+
!Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
31
|
+
export const exact = (value, required, optional = []) => plain(value) &&
|
|
32
|
+
required.every(key => Object.hasOwn(value, key)) &&
|
|
33
|
+
Reflect.ownKeys(value).every(key => typeof key === 'string' &&
|
|
34
|
+
(required.includes(key) || optional.includes(key)) &&
|
|
35
|
+
Object.hasOwn(Object.getOwnPropertyDescriptor(value, key), 'value'));
|
|
36
|
+
export const id = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(value);
|
|
37
|
+
export const extensionId = value => typeof value === 'string' &&
|
|
38
|
+
/^[a-z][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/.test(value) && value.length <= 100;
|
|
39
|
+
export const version = value => typeof value === 'string' && value.length <= 80 &&
|
|
40
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[a-zA-Z0-9]+(?:[.-][a-zA-Z0-9]+)*)?$/.test(value);
|
|
41
|
+
export const digest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
42
|
+
export const integer = (value, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(value) && value >= 0 && value <= max;
|
|
43
|
+
export const text = (value, max = EXTENSION_LIMITS.text) => typeof value === 'string' &&
|
|
44
|
+
value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f<>]/.test(value) &&
|
|
45
|
+
!/(?:[a-z][a-z0-9+.-]*:\/\/|(?:javascript|data|file):|url\s*\()/i.test(value);
|
|
46
|
+
export const uniqueStrings = (value, predicate, max = 64) => Array.isArray(value) &&
|
|
47
|
+
value.length <= max && value.every(predicate) && new Set(value).size === value.length;
|
|
48
|
+
export function assetPath(value) {
|
|
49
|
+
return typeof value === 'string' && value.length <= 200 &&
|
|
50
|
+
value.split('/').length <= EXTENSION_LIMITS.depth &&
|
|
51
|
+
value.split('/').every(part => /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(part) &&
|
|
52
|
+
!['node_modules', '__proto__', 'constructor', 'prototype'].includes(part));
|
|
53
|
+
}
|
|
54
|
+
export function jsonBytes(value, limit, code = 'extension_payload_limit') {
|
|
55
|
+
let result;
|
|
56
|
+
try { result = JSON.stringify(value); } catch { throw extensionError(code); }
|
|
57
|
+
check(typeof result === 'string' && new TextEncoder().encode(result).length <= limit, code);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { check } from './contracts.mjs';
|
|
3
|
+
import { validateManifest, validateAssets } from './manifest.mjs';
|
|
4
|
+
|
|
5
|
+
const scriptHash = value => `'sha256-${createHash('sha256').update(value).digest('base64')}'`;
|
|
6
|
+
const scriptJSON = value => JSON.stringify(value).replaceAll('<', '\\u003c')
|
|
7
|
+
.replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
|
|
8
|
+
|
|
9
|
+
/** Creates a response body AND mandatory response headers. Never serve body
|
|
10
|
+
* alone or as srcdoc: response sandboxing must survive opening the URL directly.
|
|
11
|
+
*/
|
|
12
|
+
export function createFrameDocument({ manifest: input, assets: inputAssets, nonce } = {}) {
|
|
13
|
+
const manifest = validateManifest(input);
|
|
14
|
+
const assets = validateAssets(manifest, inputAssets);
|
|
15
|
+
check(typeof nonce === 'string' && /^[A-Za-z0-9_-]{24,128}$/.test(nonce), 'invalid_frame_nonce');
|
|
16
|
+
const source = assets[manifest.entry].toString('utf8');
|
|
17
|
+
const data = Object.fromEntries(Object.entries(assets).filter(([name]) => name.endsWith('.json'))
|
|
18
|
+
.map(([name, bytes]) => [name, JSON.parse(bytes.toString('utf8'))]));
|
|
19
|
+
// The trusted prelude is installed before third-party code executes. Source is
|
|
20
|
+
// embedded as escaped JSON, not HTML. Appending a script preserves exact bytes
|
|
21
|
+
// for CSP hashing even if an author has a literal closing script tag.
|
|
22
|
+
const prelude = `(() => {
|
|
23
|
+
"use strict";
|
|
24
|
+
const expected = ${scriptJSON(nonce)};
|
|
25
|
+
const parentWindow = window.parent;
|
|
26
|
+
const dispatch = window.dispatchEvent.bind(window);
|
|
27
|
+
const Event = window.CustomEvent;
|
|
28
|
+
let used = false;
|
|
29
|
+
const receive = event => {
|
|
30
|
+
if (used || parentWindow === window || event.source !== parentWindow) return;
|
|
31
|
+
const message = event.data;
|
|
32
|
+
if (!message || message.type !== "graphlin:bootstrap" || message.apiVersion !== 1 ||
|
|
33
|
+
message.nonce !== expected || event.ports.length !== 1) return;
|
|
34
|
+
used = true;
|
|
35
|
+
window.removeEventListener("message", receive);
|
|
36
|
+
const port = event.ports[0];
|
|
37
|
+
const send = port.postMessage.bind(port);
|
|
38
|
+
port.start();
|
|
39
|
+
dispatch(new Event("graphlin:connect", {detail: {port, nonce: expected, apiVersion: 1,
|
|
40
|
+
assets: ${scriptJSON(data)}}}));
|
|
41
|
+
send({type: "graphlin:ready", apiVersion: 1, nonce: expected});
|
|
42
|
+
};
|
|
43
|
+
window.addEventListener("message", receive);
|
|
44
|
+
const script = document.createElement("script");
|
|
45
|
+
script.textContent = ${scriptJSON(source)};
|
|
46
|
+
document.body.appendChild(script);
|
|
47
|
+
})();`;
|
|
48
|
+
const csp = [
|
|
49
|
+
"default-src 'none'", `script-src ${scriptHash(prelude)} ${scriptHash(source)}`,
|
|
50
|
+
"script-src-attr 'none'", "connect-src 'none'", "img-src 'none'", "style-src 'none'",
|
|
51
|
+
"font-src 'none'", "media-src 'none'", "object-src 'none'", "frame-src 'none'",
|
|
52
|
+
"worker-src 'none'", "child-src 'none'", "base-uri 'none'", "form-action 'none'",
|
|
53
|
+
"frame-ancestors 'self'", 'sandbox allow-scripts',
|
|
54
|
+
].join('; ');
|
|
55
|
+
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><title>Graphlin extension</title></head><body><main id="graphlin-extension"></main><script>${prelude}</script></body></html>`;
|
|
56
|
+
return { body, csp, headers: {
|
|
57
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
58
|
+
'Content-Security-Policy': csp,
|
|
59
|
+
'Cache-Control': 'no-store',
|
|
60
|
+
'X-Content-Type-Options': 'nosniff',
|
|
61
|
+
'Referrer-Policy': 'no-referrer',
|
|
62
|
+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), clipboard-read=(), clipboard-write=()',
|
|
63
|
+
} };
|
|
64
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './contracts.mjs';
|
|
2
|
+
export { validateManifest, validateAssets, bundleDigest, sha256, decisionProfiles } from './manifest.mjs';
|
|
3
|
+
export { validateDecisionProfile } from './profiles.mjs';
|
|
4
|
+
export { validateScene, SCENE_KINDS, SCENE_SHAPES, EDGE_KINDS } from './scene.mjs';
|
|
5
|
+
export { validateGrant, getExtensionDataProjection } from './projection.mjs';
|
|
6
|
+
export { createExtensionRegistry } from './registry.mjs';
|
|
7
|
+
export { createFrameDocument } from './frame.mjs';
|
|
8
|
+
export { validateMessage } from './sdk.mjs';
|
|
9
|
+
export { parsePackageSpec, readPackageArchive, npmPackTransport } from './packages.mjs';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
API_VERSION, MODEL_SCHEMA, SCENE_VERSION, MANIFEST_FILE, FEATURES, CAPABILITIES,
|
|
4
|
+
EXTENSION_LIMITS as L, check, plain, exact, extensionId, version, text,
|
|
5
|
+
uniqueStrings, assetPath, jsonBytes,
|
|
6
|
+
} from './contracts.mjs';
|
|
7
|
+
import { validateDecisionProfile } from './profiles.mjs';
|
|
8
|
+
|
|
9
|
+
export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
|
|
11
|
+
export function validateManifest(input) {
|
|
12
|
+
jsonBytes(input, L.manifestBytes, 'manifest_too_large');
|
|
13
|
+
check(exact(input, ['manifestVersion', 'graphlinApi', 'modelSchema', 'id', 'version',
|
|
14
|
+
'requiredFeatures', 'entry', 'assets', 'views', 'renderer', 'capabilities'], ['name', 'decisionProfiles']), 'invalid_manifest');
|
|
15
|
+
check(input.manifestVersion === 1 && input.graphlinApi === String(API_VERSION) &&
|
|
16
|
+
input.modelSchema === String(MODEL_SCHEMA), 'incompatible_extension');
|
|
17
|
+
check(extensionId(input.id) && version(input.version) &&
|
|
18
|
+
(input.name === undefined || text(input.name, 80)), 'invalid_manifest');
|
|
19
|
+
check(uniqueStrings(input.requiredFeatures, value => FEATURES.includes(value)), 'unknown_required_feature');
|
|
20
|
+
check(uniqueStrings(input.capabilities, value => CAPABILITIES.includes(value)), 'unknown_capability');
|
|
21
|
+
check(uniqueStrings(input.views, value => /^[a-z][a-z0-9-]{0,39}$/.test(value), 16) &&
|
|
22
|
+
input.views.length > 0, 'invalid_views');
|
|
23
|
+
const renderer = input.renderer;
|
|
24
|
+
check((exact(renderer, ['kind', 'sceneVersion']) && renderer.kind === 'graphlin-scene' &&
|
|
25
|
+
renderer.sceneVersion === String(SCENE_VERSION)) ||
|
|
26
|
+
(exact(renderer, ['kind']) && renderer.kind === 'custom'), 'unsupported_renderer');
|
|
27
|
+
check(plain(input.assets) && Object.keys(input.assets).length > 0 &&
|
|
28
|
+
Object.keys(input.assets).length <= L.assets, 'invalid_assets');
|
|
29
|
+
for (const [name, hash] of Object.entries(input.assets)) {
|
|
30
|
+
check(assetPath(name) && name !== MANIFEST_FILE && /\.(?:js|json)$/.test(name) &&
|
|
31
|
+
typeof hash === 'string' && /^sha256-[a-f0-9]{64}$/.test(hash), 'invalid_asset');
|
|
32
|
+
}
|
|
33
|
+
const names = Object.keys(input.assets);
|
|
34
|
+
check(new Set(names.map(name => name.toLowerCase())).size === names.length, 'duplicate_asset');
|
|
35
|
+
check(assetPath(input.entry) && input.entry.endsWith('.js') &&
|
|
36
|
+
Object.hasOwn(input.assets, input.entry), 'invalid_entry');
|
|
37
|
+
// The single executable bundle contains its dependencies. Other declared
|
|
38
|
+
// assets are inert JSON supplied by the frame prelude; no runtime imports.
|
|
39
|
+
check(names.filter(name => name.endsWith('.js')).length === 1, 'unbundled_scripts');
|
|
40
|
+
if (input.decisionProfiles !== undefined) {
|
|
41
|
+
check(uniqueStrings(input.decisionProfiles, name => assetPath(name) && name.endsWith('.json') &&
|
|
42
|
+
Object.hasOwn(input.assets, name), 8), 'invalid_decision_profiles');
|
|
43
|
+
check(!input.decisionProfiles.length || input.capabilities.includes('analysis.request'), 'missing_analysis_capability');
|
|
44
|
+
}
|
|
45
|
+
return JSON.parse(JSON.stringify(input));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function validateAssets(manifestInput, input) {
|
|
49
|
+
const manifest = validateManifest(manifestInput);
|
|
50
|
+
check(plain(input) && Object.keys(input).length === Object.keys(manifest.assets).length, 'asset_set_mismatch');
|
|
51
|
+
let total = 0;
|
|
52
|
+
const assets = {};
|
|
53
|
+
for (const [name, hash] of Object.entries(manifest.assets)) {
|
|
54
|
+
check(Object.hasOwn(input, name) && (typeof input[name] === 'string' || input[name] instanceof Uint8Array),
|
|
55
|
+
'missing_asset');
|
|
56
|
+
const bytes = Buffer.from(input[name]);
|
|
57
|
+
total += bytes.length;
|
|
58
|
+
check(bytes.length <= L.assetBytes && total <= L.packageBytes, 'asset_too_large');
|
|
59
|
+
check(`sha256-${sha256(bytes)}` === hash, 'asset_integrity_mismatch');
|
|
60
|
+
// Reject non-UTF8 bytes rather than silently changing executable content.
|
|
61
|
+
let source;
|
|
62
|
+
try { source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
|
|
63
|
+
catch { check(false, 'asset_encoding'); }
|
|
64
|
+
if (name.endsWith('.json')) {
|
|
65
|
+
try { JSON.parse(source); } catch { check(false, 'invalid_json_asset'); }
|
|
66
|
+
} else {
|
|
67
|
+
// This is packaging validation, not a JavaScript security sandbox. CSP
|
|
68
|
+
// remains authoritative if a bundle constructs a URL dynamically.
|
|
69
|
+
check(!/\bimport\s*(?:[('"*{]|[A-Za-z_$].*\bfrom\b)|\bexport\s|\/\/[#@]\s*sourceMappingURL\s*=/.test(source),
|
|
70
|
+
'unbundled_scripts');
|
|
71
|
+
}
|
|
72
|
+
assets[name] = bytes;
|
|
73
|
+
}
|
|
74
|
+
return assets;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function canonical(value) {
|
|
78
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
79
|
+
if (plain(value)) return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function bundleDigest(manifest) {
|
|
84
|
+
return sha256(JSON.stringify(canonical(validateManifest(manifest))));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function decisionProfiles(manifest, assets) {
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
return (manifest.decisionProfiles ?? []).map(name => {
|
|
90
|
+
const profile = validateDecisionProfile(JSON.parse(Buffer.from(assets[name]).toString('utf8')));
|
|
91
|
+
check(!seen.has(profile.id), 'duplicate_decision_profile');
|
|
92
|
+
seen.add(profile.id);
|
|
93
|
+
return { ...profile, namespace: `${manifest.id}.${profile.id}` };
|
|
94
|
+
});
|
|
95
|
+
}
|