graphlin 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +12 -3
  4. package/docs/decision-service.md +393 -0
  5. package/docs/extension-authoring.md +553 -0
  6. package/docs/model-api.md +293 -0
  7. package/docs/usage.md +472 -0
  8. package/docs/visualizer-views.md +240 -0
  9. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  10. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  11. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  12. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  13. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  14. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  15. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  16. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  17. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  18. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  19. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  20. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  21. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  22. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  23. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  24. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  25. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  26. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  27. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  28. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  29. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  30. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  31. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  32. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  33. package/package.json +78 -9
  34. package/plugin.json +4 -2
  35. package/runtime/architecture/analysis.mjs +344 -0
  36. package/runtime/architecture/controller.mjs +209 -0
  37. package/runtime/architecture/evidence.mjs +108 -0
  38. package/runtime/architecture/profile.mjs +56 -0
  39. package/runtime/core/evidence.mjs +43 -9
  40. package/runtime/core/graph.mjs +11 -6
  41. package/runtime/core/privacy.mjs +1 -0
  42. package/runtime/daemon/auth.mjs +7 -3
  43. package/runtime/daemon/diagnostics.mjs +1 -1
  44. package/runtime/daemon/extension-api.mjs +203 -0
  45. package/runtime/daemon/lineage.mjs +70 -0
  46. package/runtime/daemon/manager.mjs +9 -6
  47. package/runtime/daemon/model-api.mjs +728 -0
  48. package/runtime/daemon/model-persistence.mjs +220 -0
  49. package/runtime/daemon/server.mjs +81 -14
  50. package/runtime/daemon/settings.mjs +11 -3
  51. package/runtime/decisions/broker.mjs +349 -0
  52. package/runtime/decisions/contracts.mjs +179 -0
  53. package/runtime/decisions/evaluation.mjs +305 -0
  54. package/runtime/decisions/faults.mjs +32 -0
  55. package/runtime/decisions/index.mjs +818 -0
  56. package/runtime/decisions/profiles.mjs +93 -0
  57. package/runtime/decisions/questions.mjs +268 -0
  58. package/runtime/discovery/index.mjs +2 -0
  59. package/runtime/discovery/inventory.mjs +160 -0
  60. package/runtime/discovery/parser.mjs +40 -0
  61. package/runtime/discovery/structure.mjs +232 -0
  62. package/runtime/extensions/contracts.mjs +59 -0
  63. package/runtime/extensions/frame.mjs +64 -0
  64. package/runtime/extensions/index.mjs +9 -0
  65. package/runtime/extensions/manifest.mjs +95 -0
  66. package/runtime/extensions/packages.mjs +222 -0
  67. package/runtime/extensions/profiles.mjs +36 -0
  68. package/runtime/extensions/projection.mjs +130 -0
  69. package/runtime/extensions/registry.mjs +285 -0
  70. package/runtime/extensions/scene.mjs +105 -0
  71. package/runtime/extensions/sdk.d.ts +205 -0
  72. package/runtime/extensions/sdk.mjs +88 -0
  73. package/runtime/jev/index.mjs +13 -777
  74. package/runtime/jev/provider.mjs +101 -0
  75. package/runtime/jev/questions.mjs +16 -258
  76. package/runtime/jev/wire.mjs +17 -25
  77. package/runtime/model/changes.mjs +42 -0
  78. package/runtime/model/history.mjs +124 -0
  79. package/runtime/model/index.mjs +2 -0
  80. package/runtime/model/project-model.mjs +1020 -0
  81. package/runtime/model/records.mjs +240 -0
  82. package/runtime/pipeline.mjs +267 -55
  83. package/runtime/platform.mjs +254 -0
  84. package/runtime/visualizers/blocks.mjs +5 -0
  85. package/runtime/visualizers/c4.mjs +154 -0
  86. package/runtime/visualizers/changes.mjs +24 -0
  87. package/runtime/visualizers/code.mjs +5 -0
  88. package/runtime/visualizers/index.mjs +23 -0
  89. package/runtime/visualizers/structure.mjs +120 -0
  90. package/runtime/visualizers/timeline.mjs +66 -0
  91. package/runtime/web/app.js +225 -63
  92. package/runtime/web/extension-frame.js +128 -0
  93. package/runtime/web/index.html +38 -1
  94. package/runtime/web/model-client.js +162 -0
  95. package/runtime/web/platform.js +445 -0
  96. package/runtime/web/scene.js +111 -0
  97. package/runtime/web/style.css +51 -0
  98. package/schemas/graph.schema.json +4 -1
  99. package/scripts/arguments.mjs +5 -1
  100. package/scripts/build-packages.mjs +6 -2
  101. package/scripts/control.mjs +1 -1
  102. package/scripts/daemon.mjs +2 -1
  103. package/scripts/extensions.mjs +44 -0
  104. package/scripts/graphlin.mjs +23 -3
  105. package/scripts/onboarding.mjs +10 -3
  106. package/scripts/validate-packages.mjs +54 -8
@@ -0,0 +1,209 @@
1
+ const MAX_PENDING = 10_000;
2
+ const BATCH_SIZE = 6;
3
+ const STATES = new Set(['waiting', 'queued', 'running', 'complete', 'partial', 'unavailable']);
4
+ const version = value => `${value.generation}:${value.hash}:${value.status}`;
5
+ const metadata = value => ({
6
+ artifactId: value.id, generation: value.generation, hash: value.hash, status: value.status,
7
+ });
8
+
9
+ /** Coalesces observations. Captured source lives only for the active analysis job. */
10
+ export function createArchitectureController({
11
+ snapshot, capture, commit, analyze, available = () => null, ready = () => true,
12
+ onChange = () => {}, onDiagnostic = () => {}, now = Date.now, settleMs = 300,
13
+ }) {
14
+ const known = new Map(), pending = new Map(), completed = new Map();
15
+ const retries = new Map();
16
+ let state = 'waiting', reason = 'no_source', timer, active, abort, activeVersions;
17
+ let closed = false, epoch = 0, manual = false, omitted = 0, failures = 0, lastRunAt = null;
18
+
19
+ function status() {
20
+ const model = snapshot();
21
+ const supported = (model.interpretations ?? []).filter(value =>
22
+ value.namespace === 'graphlin.architecture' && value.validity === 'current' &&
23
+ value.support === 'supported' && value.classification === 'accepted');
24
+ const blocked = available();
25
+ return {
26
+ status: blocked ? 'unavailable' : state,
27
+ ...(blocked || reason ? { reason: blocked || reason } : {}),
28
+ applications: supported.filter(value => value.kind === 'application').length,
29
+ components: supported.filter(value => value.kind === 'component').length,
30
+ pending: pending.size, inspected: completed.size, total: known.size,
31
+ omitted, failures, ...(lastRunAt === null ? {} : { lastRunAt }),
32
+ };
33
+ }
34
+ function publish(next, why = null) {
35
+ state = STATES.has(next) ? next : 'unavailable';
36
+ reason = why;
37
+ try { onChange(); } catch { /* Observation must remain fail-open. */ }
38
+ }
39
+ function diagnostic(result) {
40
+ try {
41
+ onDiagnostic({
42
+ status: result.status, code: result.diagnostics?.code ?? 'architecture_unavailable',
43
+ analyzed: result.coverage?.analyzedArtifactIds?.length ?? 0,
44
+ deferred: result.coverage?.deferredArtifactIds?.length ?? 0,
45
+ providerRequests: result.diagnostics?.providerRequests ?? 0,
46
+ });
47
+ } catch { /* Diagnostics cannot prevent admission or shutdown. */ }
48
+ }
49
+ function enqueue(id, { force = false } = {}) {
50
+ const item = known.get(id);
51
+ if (!item || (!force && completed.get(id) === version(item))) return;
52
+ if (!pending.has(id) && pending.size >= MAX_PENDING) { omitted++; return; }
53
+ pending.set(id, item);
54
+ }
55
+ function wake() {
56
+ if (closed) return;
57
+ const blocked = available();
58
+ if (blocked) {
59
+ clearTimeout(timer); timer = null;
60
+ abort?.abort();
61
+ return;
62
+ }
63
+ if (active || timer || (!pending.size && !manual)) return;
64
+ timer = setTimeout(() => {
65
+ timer = null;
66
+ if (!ready()) { wake(); return; }
67
+ void run();
68
+ }, settleMs);
69
+ timer.unref?.();
70
+ }
71
+ function observe(artifacts) {
72
+ if (closed) return;
73
+ for (const artifact of artifacts) {
74
+ const item = metadata(artifact), old = known.get(artifact.id);
75
+ if (old && version(old) === version(item)) continue;
76
+ if (!old && known.size >= MAX_PENDING) { omitted++; continue; }
77
+ known.set(artifact.id, item);
78
+ completed.delete(artifact.id);
79
+ retries.delete(artifact.id);
80
+ enqueue(artifact.id);
81
+ if (activeVersions?.has(artifact.id) && activeVersions.get(artifact.id) !== version(item)) abort?.abort();
82
+ }
83
+ if (!active && pending.size) { state = 'queued'; reason = null; }
84
+ wake();
85
+ }
86
+ async function run() {
87
+ if (active || closed || available()) return active;
88
+ clearTimeout(timer); timer = null;
89
+ if (manual) {
90
+ retries.clear();
91
+ completed.clear();
92
+ omitted = 0;
93
+ for (const id of known.keys()) enqueue(id, { force: true });
94
+ manual = false;
95
+ }
96
+ if (!pending.size) { publish('waiting', 'no_source'); return; }
97
+ const ids = [...pending.keys()].slice(0, BATCH_SIZE), generation = epoch;
98
+ abort = new AbortController();
99
+ const signal = abort.signal;
100
+ publish('running');
101
+ active = (async () => {
102
+ const artifacts = await capture(ids);
103
+ if (closed || signal.aborted || generation !== epoch) return;
104
+ const captured = new Map(artifacts.map(value => [value.id, metadata(value)]));
105
+ activeVersions = new Map(ids.flatMap(id => known.has(id)
106
+ ? [[id, version(captured.get(id) ?? known.get(id))]] : []));
107
+ // Parsing can settle after a newer capture was observed. Never mark that
108
+ // newer version inspected using an older capture.
109
+ if (ids.some(id => activeVersions.get(id) !== version(known.get(id) ?? {}))) return;
110
+ const result = await analyze({ model: snapshot(), artifacts, affectedArtifactIds: ids, signal });
111
+ if (closed || signal.aborted || generation !== epoch) return;
112
+ diagnostic(result);
113
+ const applied = result.affectedEntityIds?.length || result.coverage?.missingArtifactIds?.length
114
+ ? await commit(result, { artifacts: artifacts.map(metadata), signal, epoch: generation })
115
+ : true;
116
+ if (closed || signal.aborted || generation !== epoch) return;
117
+ if (!applied || applied.accepted === false) {
118
+ // A source race may be retried, but a persistently rejected admission
119
+ // must not become an endless background classification loop.
120
+ for (const id of ids) {
121
+ if (activeVersions.get(id) !== version(known.get(id) ?? {})) continue;
122
+ const count = (retries.get(id) ?? 0) + 1;
123
+ retries.set(id, count);
124
+ if (count >= 2) { pending.delete(id); completed.set(id, activeVersions.get(id)); failures++; }
125
+ }
126
+ publish(pending.size ? 'queued' : 'partial', 'source_changed');
127
+ return;
128
+ }
129
+ if (Number.isSafeInteger(applied.omitted) && applied.omitted > 0) omitted += applied.omitted;
130
+ const deferred = new Set(result.coverage?.deferredArtifactIds ?? []);
131
+ const progressed = ids.some(id => !deferred.has(id));
132
+ for (const id of ids) {
133
+ if (activeVersions.get(id) !== version(known.get(id) ?? {})) continue;
134
+ pending.delete(id);
135
+ // Move budget-deferred captures to the back, so the next batch can
136
+ // spend its source budget on them. Stop if a whole batch made no
137
+ // progress (for example, no capture is available).
138
+ if (deferred.has(id) && progressed) continue;
139
+ completed.set(id, activeVersions.get(id));
140
+ if (deferred.has(id)) failures++;
141
+ }
142
+ for (const id of deferred) enqueue(id);
143
+ for (const id of result.coverage?.deferredMembershipArtifactIds ?? []) {
144
+ const count = retries.get(id) ?? 0;
145
+ if (count < 2) { retries.set(id, count + 1); enqueue(id, { force: true }); }
146
+ else if (count === 2) { retries.set(id, 3); omitted++; }
147
+ }
148
+ lastRunAt = now();
149
+ if (result.status === 'unavailable') { failures++; publish('unavailable', 'analysis_failed'); }
150
+ else if (result.status === 'partial' || omitted) publish('partial', 'partial_coverage');
151
+ else {
152
+ const totals = status();
153
+ publish('complete', totals.applications + totals.components ? null : 'none_supported');
154
+ }
155
+ })().catch(() => {
156
+ if (!closed && !signal.aborted) {
157
+ failures++;
158
+ for (const id of ids) {
159
+ if (activeVersions && activeVersions.get(id) !== version(known.get(id) ?? {})) continue;
160
+ pending.delete(id);
161
+ if (known.has(id)) completed.set(id, version(known.get(id)));
162
+ }
163
+ publish('unavailable', 'analysis_failed');
164
+ }
165
+ }).finally(() => {
166
+ active = null; abort = null; activeVersions = null;
167
+ if (!closed) {
168
+ if (pending.size || manual) { state = 'queued'; wake(); }
169
+ else if (state === 'running') publish(available() ? 'unavailable' : 'partial', available() || 'source_changed');
170
+ }
171
+ });
172
+ return active;
173
+ }
174
+ function request() {
175
+ if (!closed) {
176
+ manual = true;
177
+ publish('queued', available());
178
+ wake();
179
+ }
180
+ return status();
181
+ }
182
+ function invalidate() {
183
+ epoch++;
184
+ abort?.abort();
185
+ completed.clear();
186
+ retries.clear();
187
+ for (const id of known.keys()) enqueue(id, { force: true });
188
+ wake();
189
+ }
190
+ return {
191
+ observe, wake, request, status, invalidate,
192
+ async whenIdle() {
193
+ clearTimeout(timer); timer = null;
194
+ while (!closed && !available() && (active || pending.size || manual)) {
195
+ if (!ready()) break;
196
+ await (active ?? run());
197
+ }
198
+ clearTimeout(timer); timer = null;
199
+ },
200
+ async close() {
201
+ closed = true;
202
+ clearTimeout(timer); timer = null;
203
+ abort?.abort();
204
+ pending.clear();
205
+ await active;
206
+ known.clear(); completed.clear(); retries.clear();
207
+ },
208
+ };
209
+ }
@@ -0,0 +1,108 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { LIMITS, hash, isHash, isId, integer, opaque } from '../core/common.mjs';
3
+ import { createPolicy, metadataEvent, safeLabel, safeText } from '../core/privacy.mjs';
4
+ import { buildCandidates } from '../core/candidates.mjs';
5
+ import { id, references, relativePath } from '../model/records.mjs';
6
+ import { ARCHITECTURE_NAMESPACE } from './profile.mjs';
7
+
8
+ export const ARCHITECTURE_LIMITS = Object.freeze({
9
+ artifacts: 6, candidatesPerArtifact: 8, membershipChecks: 6,
10
+ sourceRefs: 16, guardRefs: 128, sourceBytes: 512 * 1024,
11
+ });
12
+ export const requireValue = value => { if (!value) throw new Error('architecture_input_invalid'); };
13
+ export const lineageOf = model => model.coverage?.lineage?.id ?? model.projectId;
14
+ export const recordId = (projectId, ...parts) => opaque('architecture', projectId, ...parts);
15
+
16
+ export function indexModel(model, policy) {
17
+ requireValue(model?.schemaVersion === 2 && id(model.projectId) && integer(model.revision)
18
+ && !model.replay && model.checkpointId === undefined && id(lineageOf(model))
19
+ && Array.isArray(model.entities) && model.entities.length <= 20_000
20
+ && Array.isArray(model.relations) && model.relations.length <= 40_000
21
+ && Array.isArray(model.interpretations) && model.interpretations.length <= 512
22
+ && Array.isArray(model.coverage?.artifacts) && model.coverage.artifacts.length <= 10_000);
23
+ const entities = new Map(), artifacts = new Map(), byArtifact = new Map();
24
+ for (const artifact of model.coverage.artifacts) {
25
+ requireValue(id(artifact?.id) && !artifacts.has(artifact.id));
26
+ artifacts.set(artifact.id, artifact);
27
+ }
28
+ function currentRefs(value, max = ARCHITECTURE_LIMITS.sourceRefs) {
29
+ const refs = references(value, max);
30
+ if (!refs?.length || !isDeepStrictEqual(refs, value)) return null;
31
+ return refs.every(ref => {
32
+ const artifact = artifacts.get(ref.artifactId);
33
+ return ref.sourceClass !== 'public_intent' && artifact?.status === 'present'
34
+ && artifact.fresh === true && artifact.hash === ref.hash && artifact.generation === ref.generation
35
+ && relativePath(artifact.relativePath, policy);
36
+ }) ? refs : null;
37
+ }
38
+ for (const entity of model.entities) {
39
+ requireValue(id(entity?.id) && !entities.has(entity.id));
40
+ entities.set(entity.id, entity);
41
+ if (!byArtifact.has(entity.artifactId)) byArtifact.set(entity.artifactId, []);
42
+ if (entity.basis === 'parsed' && entity.validity === 'current' && entity.classification === 'accepted'
43
+ && safeLabel(entity.label) && currentRefs(entity.sourceRefs)) byArtifact.get(entity.artifactId).push(entity);
44
+ }
45
+ const relations = model.relations.filter(value => value?.validity === 'current'
46
+ && value.basis === 'parsed' && entities.has(value.source) && entities.has(value.target)
47
+ && ['contains', 'imports', 'calls', 'depends_on'].includes(value.kind) && currentRefs(value.sourceRefs));
48
+ return { entities, artifacts, byArtifact, relations, currentRefs };
49
+ }
50
+
51
+ export function moduleForArtifact(index, artifactId) {
52
+ const modules = (index.byArtifact.get(artifactId) ?? []).filter(entity => ['module', 'file'].includes(entity.kind));
53
+ return modules.length === 1 ? modules[0] : null;
54
+ }
55
+
56
+ export function requestedArtifacts(model, index, captures, affectedArtifactIds) {
57
+ const supplied = captures.map(value => value?.id);
58
+ requireValue(supplied.every(isId) && new Set(supplied).size === supplied.length);
59
+ if (affectedArtifactIds === undefined) return supplied;
60
+ requireValue(Array.isArray(affectedArtifactIds) && affectedArtifactIds.length <= 128
61
+ && affectedArtifactIds.every(isId) && new Set(affectedArtifactIds).size === affectedArtifactIds.length);
62
+ const seeds = new Set(affectedArtifactIds), requested = new Set(seeds);
63
+ // One-hop dependencies nominate source for reconsideration, never membership.
64
+ for (const relation of index.relations) {
65
+ const source = index.entities.get(relation.source).artifactId;
66
+ const target = index.entities.get(relation.target).artifactId;
67
+ if (seeds.has(source) && isId(target)) requested.add(target);
68
+ if (seeds.has(target) && isId(source)) requested.add(source);
69
+ }
70
+ // Existing groups can depend on a changed file even if an import disappeared.
71
+ for (const value of model.interpretations) {
72
+ if (value.namespace !== ARCHITECTURE_NAMESPACE || !Array.isArray(value.sourceRefs)
73
+ || value.sourceRefs.length > ARCHITECTURE_LIMITS.sourceRefs
74
+ || !value.sourceRefs.some(ref => seeds.has(ref.artifactId))) continue;
75
+ for (const ref of value.sourceRefs) if (isId(ref.artifactId)) requested.add(ref.artifactId);
76
+ }
77
+ return [...seeds, ...[...requested].filter(value => !seeds.has(value)).sort()];
78
+ }
79
+
80
+ export function candidatesForCapture(capture, index, event, policy) {
81
+ const current = index.artifacts.get(capture.id);
82
+ const anchor = moduleForArtifact(index, capture.id);
83
+ if (!anchor || !current || current.status !== 'present' || current.fresh !== true
84
+ || capture.status !== 'present' || capture.exists !== true || capture.complete !== true
85
+ || !isHash(capture.hash) || !integer(capture.generation, 1)
86
+ || current.hash !== capture.hash || current.generation !== capture.generation
87
+ || !relativePath(capture.relativePath, policy) || current.relativePath !== capture.relativePath
88
+ || typeof capture.text !== 'string' || Buffer.byteLength(capture.text) > LIMITS.fileBytes
89
+ || !safeText(capture.text, LIMITS.fileBytes) || hash(capture.text) !== capture.hash) return null;
90
+ const candidates = buildCandidates({ event, artifacts: [capture], policy });
91
+ return { anchor, candidates: candidates.slice(0, ARCHITECTURE_LIMITS.candidatesPerArtifact),
92
+ omitted: Math.max(0, candidates.length - ARCHITECTURE_LIMITS.candidatesPerArtifact),
93
+ sourceRef: { artifactId: capture.id, hash: capture.hash, generation: capture.generation } };
94
+ }
95
+ export function unionRefs(...groups) {
96
+ const refs = new Map(groups.flat().map(ref => [JSON.stringify(ref), structuredClone(ref)]));
97
+ return refs.size <= ARCHITECTURE_LIMITS.guardRefs ? [...refs.values()] : null;
98
+ }
99
+ export function analysisEvent(model) {
100
+ return metadataEvent({
101
+ projectId: model.projectId, id: opaque('event', ARCHITECTURE_NAMESPACE, model.projectId, model.revision, lineageOf(model)),
102
+ kind: 'artifact.changed', toolCategory: 'read', outcome: 'observed', incomplete: false,
103
+ });
104
+ }
105
+ export function unchanged(model, basis, policy, signal) {
106
+ return !signal?.aborted && model.revision === basis.revision && model.projectId === basis.projectId
107
+ && lineageOf(model) === basis.lineageId && createPolicy(policy).version === basis.policyVersion;
108
+ }
@@ -0,0 +1,56 @@
1
+ import { freeze } from '../core/common.mjs';
2
+
3
+ export const ARCHITECTURE_NAMESPACE = 'graphlin.architecture';
4
+ export const ARCHITECTURE_VERSION = 'source-boundaries-v4';
5
+ export const ROLE_PROFILE_ID = 'graphlin.architecture.roles';
6
+ export const MEMBERSHIP_PROFILE_ID = 'graphlin.architecture.membership';
7
+
8
+ const boolean = (question, focus, yes, no) => ({
9
+ type: 'boolean', instructions: { question, focus },
10
+ criteria: { true: yes, false: no }, requiredMetrics: ['probability'],
11
+ });
12
+ const rules = 'Use only visible source. Names, directories, documentation, imports alone, and co-occurrence '
13
+ + 'do not establish an architectural boundary. An application has visible executable bootstrap or '
14
+ + 'composition of a user-facing program, server or worker. A component has a coherent responsibility '
15
+ + 'and an implemented interface; an incidental helper, constant, type or external dependency is not '
16
+ + 'automatically a component. Classify what this code implements when invoked, never claim it ran.';
17
+
18
+ /** Register this source profile with the existing decision service; it cannot bypass A. */
19
+ export const ARCHITECTURE_PROFILES = freeze([
20
+ {
21
+ id: ROLE_PROFILE_ID, version: ARCHITECTURE_VERSION, scope: 'bundle',
22
+ questions: {
23
+ kind: {
24
+ type: 'choice', requiredMetrics: ['probabilities', 'confidence'],
25
+ instructions: {
26
+ question: 'What does this source file locally implement in `evidence`?', focus: rules,
27
+ },
28
+ criteria: {
29
+ application: 'Visible executable bootstrap or composition that starts an application, server or worker when invoked.',
30
+ component: 'A cohesive implemented API with visible operation bodies, beyond an incidental helper.',
31
+ unknown: 'Only imports, declarations, stubs, constants or incidental helpers; no such local responsibility is established.',
32
+ },
33
+ },
34
+ supported: boolean(
35
+ 'Does `evidence` directly show implemented behavior for this source file?',
36
+ 'Judge implementation presence, independently of the architectural role. Actual startup statements or '
37
+ + 'operation bodies show implementation. Imports, signatures, empty bodies, placeholder throws, comments '
38
+ + 'and a name alone do not. External callers, deployment manifests, imported collaborator internals '
39
+ + 'and runtime execution are not prerequisites for recognizing visible local implementation.',
40
+ 'Executable statements directly implement startup or operations in this file.',
41
+ 'Only imports, declarations, scalar constants, empty bodies or placeholders are visible.',
42
+ ),
43
+ missing_context: boolean(
44
+ 'Is a local statement or definition absent from `evidence` that prevents identifying what this file implements?',
45
+ 'This is only about classifying the visible file as application, component or unknown. '
46
+ + 'Missing context requires an omitted/truncated body or unresolved local operation that prevents that decision. '
47
+ + 'A visible server construction and listen call suffice for application startup. Visible class or function '
48
+ + 'operation bodies can establish a component. Standard library internals, implementation of imported collaborators, '
49
+ + 'upstream callers, deployment configuration and runtime execution are not required to identify the file’s own role. '
50
+ + 'A fully visible constant or helper can conclusively be unknown without missing context.',
51
+ 'A necessary local body or statement is absent, so the file’s implemented responsibility cannot be identified.',
52
+ 'Visible code suffices to support or reject an application/component role for this file.',
53
+ ),
54
+ },
55
+ },
56
+ ]);
@@ -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
- constructor({ projectRoot, policy } = {}) {
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: this.#policy.transmitSource && !privateText(text) ? text : null, stamp,
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 >= LIMITS.trackedPaths) continue;
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
- async reconcile() {
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
- for (const record of this.#records.values()) results.push(await this.#captureOne(record));
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.hash === ref.hash && current.generation === ref.generation;
180
+ return current?.status === 'present' && current.lineage === this.#lineage &&
181
+ current.hash === ref.hash && current.generation === ref.generation;
148
182
  });
149
183
  }
150
184
  }
@@ -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) || ref.basis !== 'jev_interpretation' ||
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: 'jev_interpretation', sourceRef: clone(candidate.sourceRef),
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' &&
@@ -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,
@@ -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
- const hosts = req.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'host');
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 '../jev/questions.mjs';
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({