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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphlin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Live architecture and activity diagrams from observable coding-agent work.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"start": "node scripts/graphlin.mjs start",
|
|
15
15
|
"demo": "node scripts/graphlin.mjs demo",
|
|
16
16
|
"test": "node --test --test-timeout=30000",
|
|
17
|
+
"test:stress": "node --test --test-timeout=90000 tests/stress/*.mjs",
|
|
17
18
|
"build": "node scripts/build-packages.mjs",
|
|
18
19
|
"doctor": "node scripts/graphlin.mjs doctor",
|
|
19
20
|
"eval:jev": "node scripts/evaluate-jev.mjs",
|
|
@@ -51,20 +52,24 @@
|
|
|
51
52
|
"registry": "https://registry.npmjs.org/"
|
|
52
53
|
},
|
|
53
54
|
"files": [
|
|
54
|
-
"./LICENSE",
|
|
55
|
-
"./README.md",
|
|
56
|
-
"./plugin.json",
|
|
57
|
-
"./mcp.json",
|
|
58
|
-
"./.mcp.json",
|
|
59
55
|
"./.claude-plugin/plugin.json",
|
|
60
56
|
"./.codex-plugin/plugin.json",
|
|
57
|
+
"./.mcp.json",
|
|
58
|
+
"./LICENSE",
|
|
59
|
+
"./README.md",
|
|
61
60
|
"./adapters/README.md",
|
|
62
61
|
"./adapters/claude/hooks.json",
|
|
63
62
|
"./adapters/claude/profile.json",
|
|
64
63
|
"./adapters/codex/hooks.json",
|
|
65
64
|
"./adapters/codex/profile.json",
|
|
66
65
|
"./adapters/kiro/profile.json",
|
|
67
|
-
"./
|
|
66
|
+
"./docs/decision-service.md",
|
|
67
|
+
"./docs/extension-authoring.md",
|
|
68
|
+
"./docs/model-api.md",
|
|
69
|
+
"./docs/usage.md",
|
|
70
|
+
"./docs/visualizer-views.md",
|
|
71
|
+
"./mcp.json",
|
|
72
|
+
"./plugin.json",
|
|
68
73
|
"./runtime/collector/index.mjs",
|
|
69
74
|
"./runtime/core/candidates.mjs",
|
|
70
75
|
"./runtime/core/common.mjs",
|
|
@@ -80,21 +85,65 @@
|
|
|
80
85
|
"./runtime/daemon/demo.mjs",
|
|
81
86
|
"./runtime/daemon/diagnostics.mjs",
|
|
82
87
|
"./runtime/daemon/export.mjs",
|
|
88
|
+
"./runtime/daemon/extension-api.mjs",
|
|
83
89
|
"./runtime/daemon/ipc.mjs",
|
|
90
|
+
"./runtime/daemon/lineage.mjs",
|
|
84
91
|
"./runtime/daemon/lock.mjs",
|
|
85
92
|
"./runtime/daemon/manager.mjs",
|
|
93
|
+
"./runtime/daemon/model-api.mjs",
|
|
94
|
+
"./runtime/daemon/model-persistence.mjs",
|
|
86
95
|
"./runtime/daemon/paths.mjs",
|
|
87
96
|
"./runtime/daemon/persistence.mjs",
|
|
88
97
|
"./runtime/daemon/server.mjs",
|
|
89
98
|
"./runtime/daemon/settings.mjs",
|
|
99
|
+
"./runtime/decisions/broker.mjs",
|
|
100
|
+
"./runtime/decisions/contracts.mjs",
|
|
101
|
+
"./runtime/decisions/evaluation.mjs",
|
|
102
|
+
"./runtime/decisions/faults.mjs",
|
|
103
|
+
"./runtime/decisions/index.mjs",
|
|
104
|
+
"./runtime/decisions/profiles.mjs",
|
|
105
|
+
"./runtime/decisions/questions.mjs",
|
|
106
|
+
"./runtime/discovery/index.mjs",
|
|
107
|
+
"./runtime/discovery/inventory.mjs",
|
|
108
|
+
"./runtime/discovery/parser.mjs",
|
|
109
|
+
"./runtime/discovery/structure.mjs",
|
|
110
|
+
"./runtime/extensions/contracts.mjs",
|
|
111
|
+
"./runtime/extensions/frame.mjs",
|
|
112
|
+
"./runtime/extensions/index.mjs",
|
|
113
|
+
"./runtime/extensions/manifest.mjs",
|
|
114
|
+
"./runtime/extensions/packages.mjs",
|
|
115
|
+
"./runtime/extensions/profiles.mjs",
|
|
116
|
+
"./runtime/extensions/projection.mjs",
|
|
117
|
+
"./runtime/extensions/registry.mjs",
|
|
118
|
+
"./runtime/extensions/scene.mjs",
|
|
119
|
+
"./runtime/extensions/sdk.d.ts",
|
|
120
|
+
"./runtime/extensions/sdk.mjs",
|
|
90
121
|
"./runtime/jev/fixture.mjs",
|
|
91
122
|
"./runtime/jev/index.mjs",
|
|
123
|
+
"./runtime/jev/provider.mjs",
|
|
92
124
|
"./runtime/jev/questions.mjs",
|
|
93
125
|
"./runtime/jev/wire.mjs",
|
|
126
|
+
"./runtime/model/changes.mjs",
|
|
127
|
+
"./runtime/model/history.mjs",
|
|
128
|
+
"./runtime/model/index.mjs",
|
|
129
|
+
"./runtime/model/project-model.mjs",
|
|
130
|
+
"./runtime/model/records.mjs",
|
|
94
131
|
"./runtime/pipeline.mjs",
|
|
132
|
+
"./runtime/platform.mjs",
|
|
133
|
+
"./runtime/visualizers/blocks.mjs",
|
|
134
|
+
"./runtime/visualizers/c4.mjs",
|
|
135
|
+
"./runtime/visualizers/changes.mjs",
|
|
136
|
+
"./runtime/visualizers/code.mjs",
|
|
137
|
+
"./runtime/visualizers/index.mjs",
|
|
138
|
+
"./runtime/visualizers/structure.mjs",
|
|
139
|
+
"./runtime/visualizers/timeline.mjs",
|
|
95
140
|
"./runtime/web/app.js",
|
|
141
|
+
"./runtime/web/extension-frame.js",
|
|
96
142
|
"./runtime/web/index.html",
|
|
97
143
|
"./runtime/web/layout.js",
|
|
144
|
+
"./runtime/web/model-client.js",
|
|
145
|
+
"./runtime/web/platform.js",
|
|
146
|
+
"./runtime/web/scene.js",
|
|
98
147
|
"./runtime/web/sidebar.js",
|
|
99
148
|
"./runtime/web/sketch.js",
|
|
100
149
|
"./runtime/web/style.css",
|
|
@@ -108,8 +157,24 @@
|
|
|
108
157
|
"./scripts/collector.mjs",
|
|
109
158
|
"./scripts/control.mjs",
|
|
110
159
|
"./scripts/daemon.mjs",
|
|
160
|
+
"./scripts/extensions.mjs",
|
|
111
161
|
"./scripts/graphlin.mjs",
|
|
112
162
|
"./scripts/onboarding.mjs",
|
|
113
|
-
"./scripts/validate-packages.mjs"
|
|
114
|
-
|
|
163
|
+
"./scripts/validate-packages.mjs",
|
|
164
|
+
"./skills/graphlin/SKILL.md"
|
|
165
|
+
],
|
|
166
|
+
"dependencies": {
|
|
167
|
+
"@vscode/tree-sitter-wasm": "0.3.1"
|
|
168
|
+
},
|
|
169
|
+
"bundledDependencies": [
|
|
170
|
+
"@vscode/tree-sitter-wasm"
|
|
171
|
+
],
|
|
172
|
+
"exports": {
|
|
173
|
+
"./extensions/sdk": {
|
|
174
|
+
"types": "./runtime/extensions/sdk.d.ts",
|
|
175
|
+
"import": "./runtime/extensions/sdk.mjs",
|
|
176
|
+
"default": "./runtime/extensions/sdk.mjs"
|
|
177
|
+
},
|
|
178
|
+
"./extensions/scene": "./runtime/extensions/scene.mjs"
|
|
179
|
+
}
|
|
115
180
|
}
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "graphlin",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"description": "Local architecture and activity diagrams from observable coding-agent work.",
|
|
6
6
|
"extensions": {
|
|
7
7
|
"com.openai": {
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"longDescription": "A local viewer with passive event collection, explicit source-sharing controls, and an offline fixture demo.",
|
|
12
12
|
"developerName": "Graphlin contributors",
|
|
13
13
|
"category": "Productivity",
|
|
14
|
-
"defaultPrompt": [
|
|
14
|
+
"defaultPrompt": [
|
|
15
|
+
"Start Graphlin for this project in metadata-only mode."
|
|
16
|
+
],
|
|
15
17
|
"brandColor": "#20BFA9"
|
|
16
18
|
},
|
|
17
19
|
"hooks": "./adapters/codex/hooks.json"
|
|
@@ -9,13 +9,16 @@ const fingerprint = stat => [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.c
|
|
|
9
9
|
|
|
10
10
|
export class EvidenceStore {
|
|
11
11
|
#root; #inputRoot; #policy; #records = new Map(); #byId = new Map();
|
|
12
|
-
|
|
12
|
+
#maxTrackedPaths; #reconcileCursor = 0; #lineage = null; #generationFloor;
|
|
13
|
+
constructor({ projectRoot, policy, maxTrackedPaths = LIMITS.trackedPaths, generationFloor = 0 } = {}) {
|
|
13
14
|
try {
|
|
14
15
|
this.#inputRoot = path.resolve(projectRoot);
|
|
15
16
|
this.#root = realpathSync(projectRoot);
|
|
16
17
|
if (!statSync(this.#root).isDirectory()) fail();
|
|
17
18
|
} catch { fail('INVALID_PROJECT_ROOT'); }
|
|
18
19
|
this.#policy = createPolicy(policy);
|
|
20
|
+
this.#maxTrackedPaths = integer(maxTrackedPaths, 1, 20000) ? maxTrackedPaths : LIMITS.trackedPaths;
|
|
21
|
+
this.#generationFloor = integer(generationFloor, 0, Number.MAX_SAFE_INTEGER - 1) ? generationFloor : 0;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
#locator(input) {
|
|
@@ -43,17 +46,26 @@ export class EvidenceStore {
|
|
|
43
46
|
}
|
|
44
47
|
const parts = locator.relative.split('/');
|
|
45
48
|
let current = this.#root;
|
|
49
|
+
let finalStat;
|
|
46
50
|
try {
|
|
47
51
|
if (await realpath(this.#root) !== this.#root) return this.#unavailable('root_changed');
|
|
48
52
|
for (const part of parts) {
|
|
49
53
|
current = path.join(current, part);
|
|
50
54
|
const stat = await lstat(current, { bigint: true });
|
|
51
55
|
if (stat.isSymbolicLink()) return this.#unavailable(`symlink:${fingerprint(stat)}`);
|
|
56
|
+
finalStat = stat;
|
|
52
57
|
}
|
|
53
58
|
} catch (error) {
|
|
54
59
|
return absent(error) ? { status: 'missing', exists: false, complete: true, hash: null, text: null, stamp: 'missing' }
|
|
55
60
|
: this.#unavailable('unreadable');
|
|
56
61
|
}
|
|
62
|
+
// Metadata consent permits names/stat observations, never content reads or
|
|
63
|
+
// hashes. A local-source grant is distinct from permission to transmit it.
|
|
64
|
+
if (!this.#policy.readSource) {
|
|
65
|
+
if (!finalStat?.isFile()) return this.#unavailable('not_file');
|
|
66
|
+
return { status: 'present', exists: true, complete: true,
|
|
67
|
+
hash: null, text: null, stamp: fingerprint(finalStat) };
|
|
68
|
+
}
|
|
57
69
|
let handle;
|
|
58
70
|
try {
|
|
59
71
|
// NOFOLLOW closes the final-component race. Revalidate the complete path
|
|
@@ -83,7 +95,7 @@ export class EvidenceStore {
|
|
|
83
95
|
if (text.includes('\0')) return { status: 'partial', exists: true, complete: false, hash: hash(bytes), text: null, stamp };
|
|
84
96
|
return {
|
|
85
97
|
status: 'present', exists: true, complete: true, hash: hash(bytes),
|
|
86
|
-
text:
|
|
98
|
+
text: !privateText(text) ? text : null, stamp,
|
|
87
99
|
};
|
|
88
100
|
} catch {
|
|
89
101
|
// A disappearing/racing file during open/read is uncertainty. A subsequent
|
|
@@ -106,9 +118,9 @@ export class EvidenceStore {
|
|
|
106
118
|
async #captureOne(locator) {
|
|
107
119
|
const observed = await this.#inspect(locator);
|
|
108
120
|
const previous = this.#records.get(locator.relative);
|
|
109
|
-
const version = hash([observed.status, observed.hash, observed.stamp]);
|
|
121
|
+
const version = hash([this.#lineage, observed.status, observed.hash, observed.stamp]);
|
|
110
122
|
const id = opaque('artifact', this.#root, locator.relative);
|
|
111
|
-
const generation = previous ? previous.generation + (previous.version !== version ? 1 : 0) : 1;
|
|
123
|
+
const generation = previous ? previous.generation + (previous.version !== version ? 1 : 0) : this.#generationFloor + 1;
|
|
112
124
|
const artifact = {
|
|
113
125
|
id, path: locator.absolute, relativePath: locator.relative,
|
|
114
126
|
hash: observed.hash, generation, exists: observed.exists, status: observed.status,
|
|
@@ -116,7 +128,7 @@ export class EvidenceStore {
|
|
|
116
128
|
};
|
|
117
129
|
// Registry retains no source bytes; returned captures are immutable private
|
|
118
130
|
// snapshots. All path/cache cardinalities are bounded.
|
|
119
|
-
const record = { ...locator, id, generation, hash: observed.hash, status: observed.status, version };
|
|
131
|
+
const record = { ...locator, id, generation, hash: observed.hash, status: observed.status, version, lineage: this.#lineage };
|
|
120
132
|
this.#records.set(locator.relative, record);
|
|
121
133
|
this.#byId.set(id, record);
|
|
122
134
|
return freeze(artifact);
|
|
@@ -129,14 +141,35 @@ export class EvidenceStore {
|
|
|
129
141
|
const locator = this.#locator(input);
|
|
130
142
|
if (!locator || seen.has(locator.relative)) continue;
|
|
131
143
|
seen.add(locator.relative);
|
|
132
|
-
if (!this.#records.has(locator.relative) && this.#records.size >=
|
|
144
|
+
if (!this.#records.has(locator.relative) && this.#records.size >= this.#maxTrackedPaths) continue;
|
|
133
145
|
results.push(await this.#captureOne(locator));
|
|
134
146
|
}
|
|
135
147
|
return results;
|
|
136
148
|
}
|
|
137
|
-
|
|
149
|
+
setLineage(id) {
|
|
150
|
+
if (typeof id !== 'string' || !/^[a-f0-9]{64}$/.test(id)) fail('INVALID_LINEAGE');
|
|
151
|
+
if (id === this.#lineage) return [];
|
|
152
|
+
this.#lineage = id;
|
|
153
|
+
// A checkout change invalidates in-flight references immediately. The next
|
|
154
|
+
// real capture advances each generation, even if its bytes are identical.
|
|
155
|
+
return [...this.#records.values()].map(record => ({
|
|
156
|
+
id: record.id, path: record.absolute, relativePath: record.relative,
|
|
157
|
+
generation: record.generation, hash: record.hash, status: 'unavailable',
|
|
158
|
+
complete: false, exists: null, text: null,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
async reconcile({ refs, limit } = {}) {
|
|
138
162
|
const results = [];
|
|
139
|
-
|
|
163
|
+
let records;
|
|
164
|
+
if (Array.isArray(refs)) {
|
|
165
|
+
records = [...new Set(refs.slice(0, LIMITS.trackedPaths).map(ref => this.#byId.get(ref.artifactId)).filter(Boolean))];
|
|
166
|
+
} else if (integer(limit, 1, this.#maxTrackedPaths)) {
|
|
167
|
+
const all = [...this.#records.values()];
|
|
168
|
+
const count = Math.min(limit, all.length);
|
|
169
|
+
records = Array.from({ length: count }, (_, index) => all[(this.#reconcileCursor + index) % all.length]);
|
|
170
|
+
this.#reconcileCursor = all.length ? (this.#reconcileCursor + count) % all.length : 0;
|
|
171
|
+
} else records = [...this.#records.values()];
|
|
172
|
+
for (const record of records) results.push(await this.#captureOne(record));
|
|
140
173
|
return results;
|
|
141
174
|
}
|
|
142
175
|
isCurrent(refs) {
|
|
@@ -144,7 +177,8 @@ export class EvidenceStore {
|
|
|
144
177
|
return refs.every(ref => {
|
|
145
178
|
if (!plain(ref) || !isHash(ref.hash) || !integer(ref.generation, 1)) return false;
|
|
146
179
|
const current = this.#byId.get(ref.artifactId);
|
|
147
|
-
return current?.status === 'present' && current.
|
|
180
|
+
return current?.status === 'present' && current.lineage === this.#lineage &&
|
|
181
|
+
current.hash === ref.hash && current.generation === ref.generation;
|
|
148
182
|
});
|
|
149
183
|
}
|
|
150
184
|
}
|
package/runtime/core/graph.mjs
CHANGED
|
@@ -31,7 +31,8 @@ function validReference(ref) {
|
|
|
31
31
|
if (!exactKeys(ref, REF, ['excerpt', 'sourceRef']) || !isId(ref.artifactId) || !isHash(ref.hash) ||
|
|
32
32
|
!integer(ref.generation, 1) || !isId(ref.eventId) || !integer(ref.startLine, 1, 10000000) ||
|
|
33
33
|
!integer(ref.endLine, ref.startLine, 10000000) || ref.endLine - ref.startLine >= LIMITS.snippetLines ||
|
|
34
|
-
!['source', 'public_intent'].includes(ref.sourceClass) ||
|
|
34
|
+
!['source', 'public_intent'].includes(ref.sourceClass) ||
|
|
35
|
+
!['jev_interpretation', 'decision_interpretation'].includes(ref.basis) ||
|
|
35
36
|
(Object.hasOwn(ref, 'excerpt') && !safeText(ref.excerpt, LIMITS.excerptChars))) return false;
|
|
36
37
|
if (ref.sourceClass === 'public_intent' && !ref.sourceRef) return false;
|
|
37
38
|
if (ref.sourceRef) {
|
|
@@ -131,11 +132,11 @@ function makePatch(graph, operations, causedBy = []) {
|
|
|
131
132
|
baseRevision: graph.revision, revision: graph.revision + 1, causedBy, operations,
|
|
132
133
|
});
|
|
133
134
|
}
|
|
134
|
-
function sourceReference(candidate, event, policy) {
|
|
135
|
+
function sourceReference(candidate, event, policy, basis = 'jev_interpretation') {
|
|
135
136
|
const ref = {
|
|
136
137
|
artifactId: candidate.artifactId, hash: candidate.hash, generation: candidate.generation, eventId: event.id,
|
|
137
138
|
startLine: candidate.startLine, endLine: candidate.endLine, sourceClass: candidate.sourceClass,
|
|
138
|
-
basis
|
|
139
|
+
basis, sourceRef: clone(candidate.sourceRef),
|
|
139
140
|
};
|
|
140
141
|
if (policy.displayEvidence || policy.persistEvidence) {
|
|
141
142
|
const excerpt = candidate.text.slice(0, LIMITS.excerptChars);
|
|
@@ -251,6 +252,10 @@ function compileAuditedDecision(graph, { event, decision, policy }, audit) {
|
|
|
251
252
|
new Set(decision.edges.map(e => e?.proposalId)).size !== decision.edges.length) return audit.reject(decision, 'duplicate_judgments');
|
|
252
253
|
if (!decision.nodes.every(n => validNodeJudgment(n, candidates)) ||
|
|
253
254
|
!decision.edges.every(e => validEdgeJudgment(e, bundle, candidates))) return audit.reject(decision, 'invalid_judgments');
|
|
255
|
+
// The service supplies validated provider provenance. Older manual decisions
|
|
256
|
+
// omit it and keep the legacy basis; answers/candidates cannot select a basis.
|
|
257
|
+
const providerId = decision.provider?.id;
|
|
258
|
+
const basis = providerId && providerId !== 'jev' ? 'decision_interpretation' : 'jev_interpretation';
|
|
254
259
|
const existingNodes = new Map(graph.nodes.map(n => [n.id, n]));
|
|
255
260
|
const existingEdges = new Map(graph.edges.map(e => [e.id, e]));
|
|
256
261
|
const admitted = new Map(), operations = [];
|
|
@@ -278,7 +283,7 @@ function compileAuditedDecision(graph, { event, decision, policy }, audit) {
|
|
|
278
283
|
const classification = judgment.classification === 'accepted' && candidate.complete && !event.incomplete &&
|
|
279
284
|
judgment.supportProbability >= 0.85 && judgment.roleProbability >= 0.8 && judgment.roleConfidence >= 0.6 ? 'accepted' : 'tentative';
|
|
280
285
|
const index = projected.nodes.length;
|
|
281
|
-
const refs = mergeReferences(old?.sourceRefs ?? [], [sourceReference(candidate, event, policy)]);
|
|
286
|
+
const refs = mergeReferences(old?.sourceRefs ?? [], [sourceReference(candidate, event, policy, basis)]);
|
|
282
287
|
const node = {
|
|
283
288
|
id, label: candidate.label, kind: judgment.role, shape: ROLE_SHAPES[judgment.role],
|
|
284
289
|
x: old?.x ?? 80 + (index % 6) * 220, y: old?.y ?? 80 + Math.floor(index / 6) * 140,
|
|
@@ -301,10 +306,10 @@ function compileAuditedDecision(graph, { event, decision, policy }, audit) {
|
|
|
301
306
|
(ref.generation > c.generation || (old.validity !== 'current' && ref.generation === c.generation))))) {
|
|
302
307
|
report('skipped', 'stale_generation'); continue;
|
|
303
308
|
}
|
|
304
|
-
const refs = mergeReferences([], evidence.map(c => sourceReference(c, event, policy)));
|
|
309
|
+
const refs = mergeReferences([], evidence.map(c => sourceReference(c, event, policy, basis)));
|
|
305
310
|
// Every relation retains all dependencies; never silently drop evidence
|
|
306
311
|
// when the reference budget is exceeded.
|
|
307
|
-
if (refs.length !== new Set(evidence.map(c => refKey(sourceReference(c, event, policy)))).size) {
|
|
312
|
+
if (refs.length !== new Set(evidence.map(c => refKey(sourceReference(c, event, policy, basis)))).size) {
|
|
308
313
|
report('skipped', 'reference_limit'); continue;
|
|
309
314
|
}
|
|
310
315
|
const classification = judgment.classification === 'accepted' && source.classification === 'accepted' &&
|
package/runtime/core/privacy.mjs
CHANGED
|
@@ -19,6 +19,7 @@ export function createPolicy(options = {}) {
|
|
|
19
19
|
.filter(p => !DEFAULT_EXCLUDES.includes(p)).slice(0, 64) : []
|
|
20
20
|
)])].sort();
|
|
21
21
|
const fields = {
|
|
22
|
+
readSource: options.readSource === true || options.transmitSource === true,
|
|
22
23
|
transmitSource: options.transmitSource === true,
|
|
23
24
|
displayEvidence: options.displayEvidence !== false,
|
|
24
25
|
persistEvidence: options.persistEvidence === true,
|
package/runtime/daemon/auth.mjs
CHANGED
|
@@ -10,12 +10,16 @@ export function createAuth({ origin, instanceId, now = Date.now }) {
|
|
|
10
10
|
for (const [key, expires] of map) if (expires <= now()) map.delete(key);
|
|
11
11
|
while (map.size >= 16) map.delete(map.keys().next().value);
|
|
12
12
|
}
|
|
13
|
+
function validTransport(req) {
|
|
14
|
+
const hosts = req.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'host');
|
|
15
|
+
return hosts.length === 1 && req.headers.host === new URL(origin).host &&
|
|
16
|
+
['127.0.0.1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress);
|
|
17
|
+
}
|
|
13
18
|
return {
|
|
14
19
|
cookieName,
|
|
20
|
+
validTransport,
|
|
15
21
|
validRequest(req, { mutation = false } = {}) {
|
|
16
|
-
|
|
17
|
-
if (hosts.length !== 1 || req.headers.host !== new URL(origin).host) return false;
|
|
18
|
-
if (!['127.0.0.1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress)) return false;
|
|
22
|
+
if (!validTransport(req)) return false;
|
|
19
23
|
if (req.headers['sec-fetch-site'] && !['same-origin', 'none'].includes(req.headers['sec-fetch-site'])) return false;
|
|
20
24
|
if (mutation) return req.headers.origin === origin;
|
|
21
25
|
return req.headers.origin === undefined || req.headers.origin === origin;
|
|
@@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { CATEGORIES, KINDS, ROLES, RELATIONS, isId, opaque, plain } from '../core/common.mjs';
|
|
6
6
|
import { createPolicy, excluded, privateText, safeLabel } from '../core/privacy.mjs';
|
|
7
|
-
import { ACTIVITIES } from '../
|
|
7
|
+
import { ACTIVITIES } from '../decisions/questions.mjs';
|
|
8
8
|
import { runtimeError, uid } from './paths.mjs';
|
|
9
9
|
|
|
10
10
|
export const DIAGNOSTIC_LIMITS = Object.freeze({
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFrameDocument, getExtensionDataProjection, validateGrant, extensionId,
|
|
3
|
+
id, digest, integer, exact, uniqueStrings, jsonBytes, EXTENSION_LIMITS,
|
|
4
|
+
} from '../extensions/index.mjs';
|
|
5
|
+
import { safeText } from '../core/privacy.mjs';
|
|
6
|
+
|
|
7
|
+
const PREFIX = '/api/extensions';
|
|
8
|
+
const HTTP_ERROR = Symbol('extension_http_error');
|
|
9
|
+
const fail = (status, code) => { throw Object.assign(new Error(code), { status, code, [HTTP_ERROR]: true }); };
|
|
10
|
+
const sameGrant = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
11
|
+
|
|
12
|
+
function reply(res, status, value) {
|
|
13
|
+
const body = jsonBytes(value, EXTENSION_LIMITS.projectionBytes);
|
|
14
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
15
|
+
res.end(body);
|
|
16
|
+
}
|
|
17
|
+
async function bodyJSON(req) {
|
|
18
|
+
if (!/^application\/json(?:;\s*charset=utf-8)?$/i.test(req.headers['content-type'] ?? '')) fail(415, 'invalid_content_type');
|
|
19
|
+
if (req.headers['content-encoding']) fail(400, 'unsupported_content_encoding');
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let size = 0;
|
|
22
|
+
const timer = setTimeout(() => req.destroy(), 2000);
|
|
23
|
+
try {
|
|
24
|
+
for await (const chunk of req) {
|
|
25
|
+
size += chunk.length;
|
|
26
|
+
if (size > 16 * 1024) fail(413, 'extension_request_limit');
|
|
27
|
+
chunks.push(chunk);
|
|
28
|
+
}
|
|
29
|
+
let value;
|
|
30
|
+
try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); }
|
|
31
|
+
catch { fail(400, 'invalid_json'); }
|
|
32
|
+
return value;
|
|
33
|
+
} finally { clearTimeout(timer); }
|
|
34
|
+
}
|
|
35
|
+
function parameters(query, allowed) {
|
|
36
|
+
const result = {};
|
|
37
|
+
for (const [name, value] of new URLSearchParams(query)) {
|
|
38
|
+
if (!allowed.includes(name) || Object.hasOwn(result, name) || !value) fail(400, 'invalid_extension_query');
|
|
39
|
+
result[name] = value;
|
|
40
|
+
}
|
|
41
|
+
for (const field of ['scope', 'session', 'checkpoint']) {
|
|
42
|
+
if (result[field] !== undefined && !id(result[field])) fail(400, 'invalid_extension_query');
|
|
43
|
+
}
|
|
44
|
+
if (result.nonce !== undefined && !/^[A-Za-z0-9_-]{24,128}$/.test(result.nonce)) fail(400, 'invalid_frame_nonce');
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parent performs loopback/Host/Origin checks and viewer-cookie authentication.
|
|
50
|
+
* Never pass viewerAuthorized for an external read bearer or an opaque origin.
|
|
51
|
+
* getSnapshot is synchronous and reapplies current core policy, like model-api.
|
|
52
|
+
*/
|
|
53
|
+
export function createExtensionAPI({ registry, getSnapshot, projectId, runAnalysis } = {}) {
|
|
54
|
+
if (!registry || typeof getSnapshot !== 'function' || !id(projectId) ||
|
|
55
|
+
(runAnalysis !== undefined && typeof runAnalysis !== 'function')) throw new TypeError('invalid_extension_api_options');
|
|
56
|
+
|
|
57
|
+
async function requireGrant(extension, expectedDigest) {
|
|
58
|
+
const value = await registry.getGrant(extension);
|
|
59
|
+
try { validateGrant(value); } catch { fail(403, 'extension_grant_denied'); }
|
|
60
|
+
if (!value.approved || value.projectId !== projectId || value.extensionId !== extension) fail(403, 'extension_grant_denied');
|
|
61
|
+
if (expectedDigest !== undefined && value.digest !== expectedDigest) fail(409, 'extension_digest_changed');
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
function snapshot(params, grant) {
|
|
65
|
+
// A session selector can address historical work even without a checkpoint.
|
|
66
|
+
if ((params.checkpoint || params.session) && !grant.history) fail(403, 'history_not_granted');
|
|
67
|
+
const raw = getSnapshot({
|
|
68
|
+
...(params.scope ? { scopeId: params.scope } : {}),
|
|
69
|
+
...(params.session ? { sessionId: params.session } : {}),
|
|
70
|
+
...(params.checkpoint ? { checkpointId: params.checkpoint } : {}),
|
|
71
|
+
persistent: false,
|
|
72
|
+
});
|
|
73
|
+
if (!raw || typeof raw !== 'object' || raw.then || raw.projectId !== projectId) fail(503, 'invalid_model_snapshot');
|
|
74
|
+
const projected = getExtensionDataProjection(
|
|
75
|
+
params.checkpoint ? { ...raw, checkpointId: params.checkpoint, replay: true } : raw, grant);
|
|
76
|
+
if (!projected) fail(403, 'extension_grant_denied');
|
|
77
|
+
return projected;
|
|
78
|
+
}
|
|
79
|
+
async function unchanged(extension, prior) {
|
|
80
|
+
const current = await requireGrant(extension, prior.digest);
|
|
81
|
+
if (!sameGrant(prior, current)) fail(403, 'extension_grant_changed');
|
|
82
|
+
return current;
|
|
83
|
+
}
|
|
84
|
+
async function analyze(req, res) {
|
|
85
|
+
const input = await bodyJSON(req);
|
|
86
|
+
if (!exact(input, ['id', 'digest', 'profileId', 'entityIds', 'revision']) ||
|
|
87
|
+
!extensionId(input.id) || !digest(input.digest) || !id(input.profileId) ||
|
|
88
|
+
!uniqueStrings(input.entityIds, id, 256) || !input.entityIds.length || !integer(input.revision)) {
|
|
89
|
+
fail(400, 'invalid_analysis_request');
|
|
90
|
+
}
|
|
91
|
+
const grant = await requireGrant(input.id, input.digest);
|
|
92
|
+
if (!grant.profiles?.includes(input.profileId)) fail(403, 'analysis_profile_not_granted');
|
|
93
|
+
const installed = await registry.getAssets(input.id, { digest: input.digest });
|
|
94
|
+
if (!installed.manifest.capabilities.includes('analysis.request')) fail(403, 'analysis_not_granted');
|
|
95
|
+
const profile = installed.profiles.find(value => value.id === input.profileId);
|
|
96
|
+
if (!profile) fail(403, 'analysis_profile_not_granted');
|
|
97
|
+
if (profile.selectors.fields.some(field => !grant.fields.includes(field))) fail(403, 'analysis_field_not_granted');
|
|
98
|
+
await unchanged(input.id, grant);
|
|
99
|
+
const model = snapshot({}, grant);
|
|
100
|
+
if (model.revision !== input.revision) fail(409, 'analysis_revision_changed');
|
|
101
|
+
if (!input.entityIds.every(entityId => model.entities.some(value => value.id === entityId)) ||
|
|
102
|
+
(profile.selectors.candidateIds.length &&
|
|
103
|
+
!input.entityIds.every(entityId => profile.selectors.candidateIds.includes(entityId)))) fail(403, 'analysis_candidate_not_granted');
|
|
104
|
+
if (!runAnalysis) fail(501, 'extension_analysis_unavailable');
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const abort = () => controller.abort();
|
|
107
|
+
req.once('aborted', abort);
|
|
108
|
+
res.once('close', abort);
|
|
109
|
+
try {
|
|
110
|
+
// Core owns provider calls, source consent/filtering, approval capabilities,
|
|
111
|
+
// evidence-version checks, subscriptions, and recording interpretations.
|
|
112
|
+
const result = await runAnalysis({
|
|
113
|
+
projectId, extensionId: input.id, digest: input.digest, profile,
|
|
114
|
+
entityIds: [...input.entityIds], revision: input.revision, grant, signal: controller.signal,
|
|
115
|
+
});
|
|
116
|
+
await unchanged(input.id, grant);
|
|
117
|
+
const current = snapshot({}, grant);
|
|
118
|
+
if (!result || !['accepted', 'pending', 'complete', 'unavailable'].includes(result.status)) fail(503, 'invalid_analysis_result');
|
|
119
|
+
const interpretationIds = Array.isArray(result.interpretationIds) ? result.interpretationIds.slice(0, 256)
|
|
120
|
+
.filter(value => id(value) && current.interpretations.some(item =>
|
|
121
|
+
item.id === value && item.namespace === profile.namespace)) : [];
|
|
122
|
+
if (!res.destroyed) reply(res, result.status === 'pending' || result.status === 'accepted' ? 202 : 200, {
|
|
123
|
+
status: result.status,
|
|
124
|
+
...(id(result.requestId) && safeText(result.requestId, 160) ? { requestId: result.requestId } : {}),
|
|
125
|
+
interpretationIds: [...new Set(interpretationIds)],
|
|
126
|
+
});
|
|
127
|
+
} finally {
|
|
128
|
+
req.removeListener('aborted', abort);
|
|
129
|
+
res.removeListener('close', abort);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function handle(req, res, { viewerAuthorized = false } = {}) {
|
|
133
|
+
if (typeof req.url !== 'string' ||
|
|
134
|
+
!(req.url === PREFIX || req.url.startsWith(`${PREFIX}/`) || req.url.startsWith(`${PREFIX}?`))) return false;
|
|
135
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
136
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
137
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
138
|
+
try {
|
|
139
|
+
if (viewerAuthorized !== true || req.headers.origin === 'null') fail(401, 'viewer_authorization_required');
|
|
140
|
+
if (req.url.length > 4096 || /[\s\\#]/.test(req.url) || /%(?![a-f\d]{2})/i.test(req.url)) fail(400, 'invalid_extension_route');
|
|
141
|
+
const [pathname, query = '', ...extra] = req.url.split('?');
|
|
142
|
+
if (extra.length || pathname.includes('%') || pathname.includes('//') ||
|
|
143
|
+
pathname.split('/').some(part => part === '.' || part === '..')) fail(400, 'invalid_extension_route');
|
|
144
|
+
const route = pathname.slice(PREFIX.length);
|
|
145
|
+
const read = /^\/(data|frame)\/([^/]+)$/.exec(route);
|
|
146
|
+
if (read && !extensionId(read[2])) fail(400, 'invalid_extension_id');
|
|
147
|
+
const params = parameters(query, read?.[1] === 'data' ? ['scope', 'session', 'checkpoint'] :
|
|
148
|
+
read?.[1] === 'frame' ? ['nonce'] : []);
|
|
149
|
+
if (req.method === 'GET') {
|
|
150
|
+
if (req.headers['transfer-encoding'] || Number(req.headers['content-length'] ?? 0) !== 0) fail(400, 'unexpected_body');
|
|
151
|
+
if (route === '') { reply(res, 200, await registry.list()); return true; }
|
|
152
|
+
if (!read) fail(404, 'extension_route_not_found');
|
|
153
|
+
const extension = read[2];
|
|
154
|
+
const grant = await requireGrant(extension);
|
|
155
|
+
if (read[1] === 'data') {
|
|
156
|
+
reply(res, 200, snapshot(params, grant));
|
|
157
|
+
} else {
|
|
158
|
+
if (!params.nonce) fail(400, 'invalid_frame_nonce');
|
|
159
|
+
const assets = await registry.getAssets(extension, { digest: grant.digest });
|
|
160
|
+
await unchanged(extension, grant);
|
|
161
|
+
const frame = createFrameDocument({ ...assets, nonce: params.nonce });
|
|
162
|
+
// DENY remains on every other route. Response CSP sandboxing and
|
|
163
|
+
// frame-ancestors 'self' protect the directly opened frame document.
|
|
164
|
+
res.removeHeader('X-Frame-Options');
|
|
165
|
+
for (const [name, value] of Object.entries(frame.headers)) res.setHeader(name, value);
|
|
166
|
+
res.writeHead(200); res.end(frame.body);
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (req.method !== 'POST') fail(405, 'extension_method_not_allowed');
|
|
171
|
+
if (route === '/analysis') { await analyze(req, res); return true; }
|
|
172
|
+
if (!['/grant', '/revoke'].includes(route)) fail(404, 'extension_route_not_found');
|
|
173
|
+
const input = await bodyJSON(req);
|
|
174
|
+
if (route === '/grant') {
|
|
175
|
+
if (!exact(input, ['id', 'digest', 'fields', 'history', 'approved'], ['profiles']) ||
|
|
176
|
+
!extensionId(input.id)) fail(400, 'invalid_extension_grant');
|
|
177
|
+
const { id: extension, ...request } = input;
|
|
178
|
+
reply(res, 200, await registry.grant(extension, request));
|
|
179
|
+
} else {
|
|
180
|
+
if (!exact(input, ['id']) || !extensionId(input.id)) fail(400, 'invalid_extension_revoke');
|
|
181
|
+
reply(res, 200, await registry.revoke(input.id));
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const known = {
|
|
185
|
+
extension_not_installed: [404, 'extension_not_installed'],
|
|
186
|
+
extension_digest_changed: [409, 'extension_digest_changed'],
|
|
187
|
+
unrequested_data_field: [403, 'unrequested_data_field'],
|
|
188
|
+
unrequested_profile: [403, 'unrequested_profile'],
|
|
189
|
+
unrequested_history: [403, 'unrequested_history'],
|
|
190
|
+
unrequested_analysis: [403, 'unrequested_analysis'],
|
|
191
|
+
invalid_extension_grant: [400, 'invalid_extension_grant'],
|
|
192
|
+
MODEL_CHECKPOINT_UNAVAILABLE: [404, 'checkpoint_unavailable'],
|
|
193
|
+
MODEL_SCOPE_UNAVAILABLE: [404, 'scope_unavailable'],
|
|
194
|
+
}[error?.code];
|
|
195
|
+
const status = error?.[HTTP_ERROR] ? error.status : known?.[0] ?? 503;
|
|
196
|
+
const code = error?.[HTTP_ERROR] ? error.code : known?.[1] ?? 'extension_unavailable';
|
|
197
|
+
if (!res.headersSent && !res.destroyed) reply(res, status, { error: code });
|
|
198
|
+
else if (!res.destroyed) res.destroy();
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
return { handle };
|
|
203
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { safeText } from '../core/privacy.mjs';
|
|
5
|
+
|
|
6
|
+
const TTL_MS = 2000;
|
|
7
|
+
const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/;
|
|
8
|
+
const BRANCH = ['symbolic-ref', '--quiet', '--short', 'HEAD'];
|
|
9
|
+
const HEAD = ['rev-parse', '--verify', '--end-of-options', 'HEAD'];
|
|
10
|
+
const validBranch = value => safeText(value, 240) && !CONTROLS.test(value) &&
|
|
11
|
+
!/[<>\\:\s]/.test(value) && !value.startsWith('/') && !value.startsWith('-');
|
|
12
|
+
|
|
13
|
+
/** Local ref metadata only. The caller owns reconciliation and model updates. */
|
|
14
|
+
export function createLineageReader({ projectRoot, projectId, execute = execFile } = {}) {
|
|
15
|
+
if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot) ||
|
|
16
|
+
Buffer.byteLength(projectRoot) > 4096 || projectRoot.includes('\0') ||
|
|
17
|
+
typeof projectId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(projectId) ||
|
|
18
|
+
typeof execute !== 'function') throw new TypeError('invalid_lineage_options');
|
|
19
|
+
let cached, expiresAt = 0, pending;
|
|
20
|
+
const result = (status, branch, head) => Object.freeze({
|
|
21
|
+
id: createHash('sha256').update(JSON.stringify([projectId, status, branch ?? null, head ?? null])).digest('hex'),
|
|
22
|
+
status, ...(branch ? { branch } : {}), ...(head ? { head } : {}),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function git(args) {
|
|
26
|
+
return new Promise(resolve => {
|
|
27
|
+
let child, settled = false;
|
|
28
|
+
const finish = value => { if (!settled) { settled = true; clearTimeout(timer); resolve(value); } };
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
finish({ status: 'unavailable' });
|
|
31
|
+
try { child?.kill?.('SIGKILL'); } catch { /* No child details leave the reader. */ }
|
|
32
|
+
}, 800);
|
|
33
|
+
try {
|
|
34
|
+
child = execute('git', ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', ...args], {
|
|
35
|
+
cwd: projectRoot, shell: false, timeout: 750, killSignal: 'SIGKILL', maxBuffer: 4096, encoding: 'utf8',
|
|
36
|
+
env: { PATH: process.env.PATH || '/usr/bin:/bin', LC_ALL: 'C',
|
|
37
|
+
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
|
|
38
|
+
GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1' },
|
|
39
|
+
}, (error, stdout, stderr) => {
|
|
40
|
+
const text = typeof stdout === 'string' ? stdout.replace(/\r?\n$/, '') : '';
|
|
41
|
+
if (!error) return finish({ status: 'ok', text });
|
|
42
|
+
if (!error.killed && error.code === 1 && !text && args === BRANCH) return finish({ status: 'detached' });
|
|
43
|
+
finish({ status: !error.killed && error.code === 128 && typeof stderr === 'string' &&
|
|
44
|
+
stderr.includes('not a git repository') ? 'not_git' : 'unavailable' });
|
|
45
|
+
});
|
|
46
|
+
} catch { finish({ status: 'unavailable' }); }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readRefs() {
|
|
51
|
+
if (CONTROLS.test(projectRoot)) return result('unavailable');
|
|
52
|
+
const branch = await git(BRANCH);
|
|
53
|
+
if (branch.status === 'not_git') return result('not_git');
|
|
54
|
+
if (branch.status !== 'detached' && (branch.status !== 'ok' || !validBranch(branch.text))) return result('unavailable');
|
|
55
|
+
const head = await git(HEAD);
|
|
56
|
+
if (head.status !== 'ok' || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(head.text)) return result('unavailable');
|
|
57
|
+
// Do not combine a branch observed before checkout with another branch's HEAD.
|
|
58
|
+
const after = await git(BRANCH);
|
|
59
|
+
if (branch.status !== after.status || branch.text !== after.text) return result('unavailable');
|
|
60
|
+
return result('git', branch.status === 'ok' ? branch.text : undefined, head.text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return async function read() {
|
|
64
|
+
if (cached && Date.now() < expiresAt) return cached;
|
|
65
|
+
if (!pending) pending = readRefs().then(value => {
|
|
66
|
+
cached = value; expiresAt = Date.now() + TTL_MS; return value;
|
|
67
|
+
}).finally(() => { pending = undefined; });
|
|
68
|
+
return pending;
|
|
69
|
+
};
|
|
70
|
+
}
|