graphlin 0.2.0 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/docs/usage.md +7 -0
- package/docs/visualizer-views.md +41 -0
- package/package.json +5 -1
- package/plugin.json +1 -1
- package/runtime/architecture/analysis.mjs +344 -0
- package/runtime/architecture/controller.mjs +209 -0
- package/runtime/architecture/evidence.mjs +108 -0
- package/runtime/architecture/profile.mjs +56 -0
- package/runtime/daemon/server.mjs +11 -2
- package/runtime/model/project-model.mjs +145 -14
- package/runtime/model/records.mjs +3 -2
- package/runtime/pipeline.mjs +143 -10
- package/runtime/visualizers/c4.mjs +125 -23
- package/runtime/web/app.js +1 -1
- package/runtime/web/index.html +2 -0
- package/runtime/web/platform.js +111 -3
- package/runtime/web/style.css +2 -0
- package/scripts/control.mjs +1 -1
|
@@ -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
|
+
]);
|
|
@@ -19,6 +19,7 @@ import { exportSnapshot } from './export.mjs';
|
|
|
19
19
|
import { createDiagnostics } from './diagnostics.mjs';
|
|
20
20
|
import { createDashboardInfoProvider } from './dashboard-info.mjs';
|
|
21
21
|
import { createLineageReader } from './lineage.mjs';
|
|
22
|
+
import { ARCHITECTURE_PROFILES } from '../architecture/profile.mjs';
|
|
22
23
|
|
|
23
24
|
const WEB = new URL('../web/', import.meta.url);
|
|
24
25
|
const assets = new Map([
|
|
@@ -164,11 +165,11 @@ export async function startServer({ projectRoot, dataDir, policy: policyOptions,
|
|
|
164
165
|
// Test/demo services are injected; this module never logs request bodies.
|
|
165
166
|
const service = decisionService ?? createDecisionService({
|
|
166
167
|
provider: decisionProvider ?? createJevProvider({ apiKey }),
|
|
167
|
-
materializeBundle, buildRelationProposals,
|
|
168
|
+
materializeBundle, buildRelationProposals, profiles: ARCHITECTURE_PROFILES,
|
|
168
169
|
limits: { eventDeadlineMs: 5000 },
|
|
169
170
|
});
|
|
170
171
|
pipeline = createPipeline({ projectRoot: paths.projectRoot, policy, decisionService: service,
|
|
171
|
-
classificationDeadlineMs: 5000,
|
|
172
|
+
classificationDeadlineMs: 5000, missingKey,
|
|
172
173
|
mode, restoredState: await persistence.load(), restoredModel: await modelPersistence.load(),
|
|
173
174
|
onChange: notify, onDiagnostic: diagnostics.record });
|
|
174
175
|
modelAPI = createModelAPI({ projectId: paths.projectId, getSnapshot: pipeline.getModelState,
|
|
@@ -218,6 +219,14 @@ export async function startServer({ projectRoot, dataDir, policy: policyOptions,
|
|
|
218
219
|
return;
|
|
219
220
|
}
|
|
220
221
|
if (!auth.authorized(req)) return json(res, 401, { error: 'authentication_required' });
|
|
222
|
+
if (req.method === 'GET' && req.url === '/api/architecture') {
|
|
223
|
+
return json(res, 200, pipeline.getArchitectureStatus());
|
|
224
|
+
}
|
|
225
|
+
if (req.method === 'POST' && req.url === '/api/architecture/discover') {
|
|
226
|
+
const input = await bodyJSON(req);
|
|
227
|
+
if (Object.keys(input).length) return json(res, 400, { error: 'invalid_input' });
|
|
228
|
+
return json(res, 202, pipeline.discoverArchitecture());
|
|
229
|
+
}
|
|
221
230
|
if (req.method === 'GET' && req.url === '/api/about') {
|
|
222
231
|
try { return json(res, 200, await dashboardInfo()); }
|
|
223
232
|
catch { return json(res, 503, { error: 'dashboard_info_unavailable' }); }
|
|
@@ -3,7 +3,7 @@ import { isDeepStrictEqual as equal } from 'node:util';
|
|
|
3
3
|
import { integer, plain } from '../core/common.mjs';
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_LIMITS, id, token, label, key, byteSize, relativePath, currentPolicy,
|
|
6
|
-
references, classification, entityRecord, relationRecord, interpretationRecord, certificate,
|
|
6
|
+
references, classification, entityRecord, relationRecord, interpretationRecord, interpretationNamespace, certificate,
|
|
7
7
|
activityRecord, lineageRecord, projectSnapshot, time,
|
|
8
8
|
} from './records.mjs';
|
|
9
9
|
import { compareCheckpoint } from './changes.mjs';
|
|
@@ -159,6 +159,31 @@ export function createProjectModel({ projectId, policy = {}, restoredState, limi
|
|
|
159
159
|
if (!accepted) deferred.relations++;
|
|
160
160
|
return accepted;
|
|
161
161
|
}
|
|
162
|
+
function admitInterpretation(record) {
|
|
163
|
+
const old = interpretations.get(record.id), victims = [];
|
|
164
|
+
const delta = byteSize(record) - (weights.get(old) ?? 0);
|
|
165
|
+
let reclaimed = 0;
|
|
166
|
+
const fits = () => interpretations.size + (old ? 0 : 1) - victims.length <= limits.interpretations &&
|
|
167
|
+
bytes + historyBytes + delta - reclaimed <= limits.bytes;
|
|
168
|
+
if (record.namespace === 'graphlin.architecture') {
|
|
169
|
+
// Make room only when requested. Map order preserves first admission, so
|
|
170
|
+
// legacy eviction is deterministic and never displaces other boundaries.
|
|
171
|
+
for (const candidate of interpretations.values()) {
|
|
172
|
+
if (fits()) break;
|
|
173
|
+
if (candidate.namespace !== 'graphlin.legacy-role' || candidate.id === record.id) continue;
|
|
174
|
+
victims.push(candidate.id);
|
|
175
|
+
reclaimed += weights.get(candidate) ?? 0;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!fits()) { deferred.interpretations++; return false; }
|
|
179
|
+
for (const victim of victims) {
|
|
180
|
+
remove(interpretations, victim, 'interpretations');
|
|
181
|
+
deferred.interpretations++;
|
|
182
|
+
}
|
|
183
|
+
const accepted = put(interpretations, record.id, record, limits.interpretations, 'interpretations');
|
|
184
|
+
if (!accepted) deferred.interpretations++;
|
|
185
|
+
return accepted;
|
|
186
|
+
}
|
|
162
187
|
function appendActivity(event, nextSequence = sequence) {
|
|
163
188
|
const record = activityRecord(event, nextSequence, clock());
|
|
164
189
|
if (!record) return;
|
|
@@ -534,21 +559,129 @@ export function createProjectModel({ projectId, policy = {}, restoredState, limi
|
|
|
534
559
|
}
|
|
535
560
|
}
|
|
536
561
|
|
|
562
|
+
function currentInterpretationRefs(refs) {
|
|
563
|
+
if (!currentRefs(refs)) return false;
|
|
564
|
+
const effective = currentPolicy(policy);
|
|
565
|
+
return refs.every(ref => {
|
|
566
|
+
const artifact = artifacts.get(ref.artifactId), cert = artifact.enumeration;
|
|
567
|
+
return (!artifact.relativePath || relativePath(artifact.relativePath, effective)) &&
|
|
568
|
+
(!ref.extractor || ref.extractor === cert?.extractor) &&
|
|
569
|
+
(!ref.extractorVersion || ref.extractorVersion === cert?.version) &&
|
|
570
|
+
(!ref.identityVersion || ref.identityVersion === cert?.identityVersion);
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
function prepareInterpretation(value) {
|
|
574
|
+
const record = interpretationRecord(value, limits.refs);
|
|
575
|
+
if (!record || record.entityIds.some(entityId => !entities.has(entityId))) return null;
|
|
576
|
+
const current = currentInterpretationRefs(record.sourceRefs) &&
|
|
577
|
+
record.entityIds.every(entityId => entities.get(entityId).validity === 'current');
|
|
578
|
+
if (record.support === 'supported' && !current) return null;
|
|
579
|
+
const storageId = interpretations.get(record.id)?.namespace === record.namespace
|
|
580
|
+
? record.id : key('interpretation', projectId, record.namespace, record.id);
|
|
581
|
+
return { ...record, id: storageId, ...(record.sourceRefs.length && !current
|
|
582
|
+
? { validity: 'stale', classification: 'stale' } : {}) };
|
|
583
|
+
}
|
|
584
|
+
|
|
537
585
|
function observeInterpretations(values, { event } = {}) {
|
|
538
586
|
if (!Array.isArray(values) || !currentPolicy(policy).readSource) return stats();
|
|
587
|
+
const before = mutations, oldDeferred = deferred.interpretations;
|
|
539
588
|
for (const value of values.slice(0, limits.interpretations)) {
|
|
540
|
-
const record =
|
|
541
|
-
if (
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (!put(interpretations, storageId, normalized, limits.interpretations, 'interpretations')) deferred.interpretations++;
|
|
547
|
-
}
|
|
548
|
-
commit('interpretations.observed', { sessionId: event?.sessionId });
|
|
589
|
+
const record = prepareInterpretation(value);
|
|
590
|
+
if (record) admitInterpretation(record);
|
|
591
|
+
}
|
|
592
|
+
if (mutations !== before || oldDeferred !== deferred.interpretations) {
|
|
593
|
+
commit('interpretations.observed', { sessionId: event?.sessionId });
|
|
594
|
+
}
|
|
549
595
|
return stats();
|
|
550
596
|
}
|
|
551
597
|
|
|
598
|
+
/**
|
|
599
|
+
* Replace a namespace, or only records touching the supplied entity/artifact
|
|
600
|
+
* scope. An explicit empty scope matches nothing; [] withdraws old boundaries.
|
|
601
|
+
* Batch guards allow up to 256 refs; each stored record still allows 16.
|
|
602
|
+
* Async callers should supply exact sourceRefs even for an empty answer.
|
|
603
|
+
* For confirmed missing artifacts, omit present-source refs only after the
|
|
604
|
+
* caller's serialized epoch/version check; an unguarded clear carries no version.
|
|
605
|
+
*/
|
|
606
|
+
function replaceInterpretations(namespace, values, options = {}) {
|
|
607
|
+
const result = (accepted, changed = false, removed = 0, retained = 0) => ({
|
|
608
|
+
...stats(), accepted, changed, removed, retained,
|
|
609
|
+
});
|
|
610
|
+
if (!interpretationNamespace(namespace) || !Array.isArray(values) || values.length > DEFAULT_LIMITS.interpretations ||
|
|
611
|
+
!plain(options) || !currentPolicy(policy).readSource) return result(false);
|
|
612
|
+
const scoped = options.affectedEntityIds !== undefined || options.artifactIds !== undefined;
|
|
613
|
+
const validIds = (values, limit) => Array.isArray(values) && values.length <= limit && values.every(value => id(value));
|
|
614
|
+
if (!validIds(options.affectedEntityIds ?? [], limits.entities) ||
|
|
615
|
+
!validIds(options.artifactIds ?? [], limits.artifacts)) return result(false);
|
|
616
|
+
const affected = new Set(options.affectedEntityIds ?? []), affectedArtifacts = new Set(options.artifactIds ?? []);
|
|
617
|
+
const matches = record => !scoped || record.entityIds.some(entityId =>
|
|
618
|
+
affected.has(entityId) || affectedArtifacts.has(entities.get(entityId)?.artifactId)) ||
|
|
619
|
+
record.sourceRefs.some(ref => affectedArtifacts.has(ref.artifactId));
|
|
620
|
+
if (options.sourceRefs !== undefined) {
|
|
621
|
+
const guards = references(options.sourceRefs, 256);
|
|
622
|
+
if (!guards || !currentInterpretationRefs(guards)) return result(false);
|
|
623
|
+
const guarded = new Set(guards.map(ref => ref.artifactId));
|
|
624
|
+
if ([...affectedArtifacts].some(artifactId => !guarded.has(artifactId)) ||
|
|
625
|
+
[...affected].some(entityId => entities.get(entityId)?.artifactId &&
|
|
626
|
+
!guarded.has(entities.get(entityId).artifactId))) return result(false);
|
|
627
|
+
}
|
|
628
|
+
// Validate the complete bounded answer before treating any omission as a
|
|
629
|
+
// withdrawal. A malformed or stale answer cannot erase a newer boundary.
|
|
630
|
+
const incoming = new Map();
|
|
631
|
+
for (const value of values) {
|
|
632
|
+
if (!plain(value) || value.namespace !== undefined && value.namespace !== namespace) return result(false);
|
|
633
|
+
const record = prepareInterpretation({ ...value, namespace });
|
|
634
|
+
if (!record || record.validity !== 'current' || !matches(record) || incoming.has(record.id) ||
|
|
635
|
+
record.entityIds.some(entityId => entities.get(entityId).validity !== 'current') ||
|
|
636
|
+
record.sourceRefs.length && !currentInterpretationRefs(record.sourceRefs)) return result(false);
|
|
637
|
+
const old = interpretations.get(record.id);
|
|
638
|
+
if (old && !matches(old)) return result(false);
|
|
639
|
+
incoming.set(record.id, record);
|
|
640
|
+
}
|
|
641
|
+
const obsolete = new Set([...interpretations.values()].filter(record =>
|
|
642
|
+
record.namespace === namespace && matches(record) && !incoming.has(record.id)).map(record => record.id));
|
|
643
|
+
const updates = [...incoming.values()].map(record => ({
|
|
644
|
+
record, delta: byteSize(record) - (weights.get(interpretations.get(record.id)) ?? 0),
|
|
645
|
+
}));
|
|
646
|
+
let plannedCount = interpretations.size - obsolete.size +
|
|
647
|
+
updates.filter(({ record }) => !interpretations.has(record.id)).length;
|
|
648
|
+
let plannedBytes = bytes + historyBytes + updates.reduce((sum, update) => sum + update.delta, 0);
|
|
649
|
+
for (const recordId of obsolete) plannedBytes -= weights.get(interpretations.get(recordId)) ?? 0;
|
|
650
|
+
const victims = [];
|
|
651
|
+
const fits = () => plannedCount <= limits.interpretations && plannedBytes <= limits.bytes;
|
|
652
|
+
if (namespace === 'graphlin.architecture') {
|
|
653
|
+
for (const record of interpretations.values()) {
|
|
654
|
+
if (fits()) break;
|
|
655
|
+
if (record.namespace !== 'graphlin.legacy-role' || obsolete.has(record.id) || incoming.has(record.id)) continue;
|
|
656
|
+
victims.push(record.id);
|
|
657
|
+
plannedCount--;
|
|
658
|
+
plannedBytes -= weights.get(record) ?? 0;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
// Admission is all-or-nothing, including prospective legacy evictions.
|
|
662
|
+
// Capacity defers a valid answer; it must not withdraw supported old roles.
|
|
663
|
+
if (!fits()) {
|
|
664
|
+
deferred.interpretations += incoming.size;
|
|
665
|
+
commit('interpretations.replaced', { sessionId: options.event?.sessionId });
|
|
666
|
+
return result(true, true);
|
|
667
|
+
}
|
|
668
|
+
const before = mutations, oldDeferred = deferred.interpretations;
|
|
669
|
+
for (const recordId of obsolete) remove(interpretations, recordId, 'interpretations');
|
|
670
|
+
for (const recordId of victims) {
|
|
671
|
+
remove(interpretations, recordId, 'interpretations');
|
|
672
|
+
deferred.interpretations++;
|
|
673
|
+
}
|
|
674
|
+
// Shrink existing records first so intermediate writes also fit the plan.
|
|
675
|
+
updates.sort((a, b) => Number(a.delta > 0) - Number(b.delta > 0) ||
|
|
676
|
+
(a.record.id < b.record.id ? -1 : a.record.id > b.record.id ? 1 : 0));
|
|
677
|
+
for (const { record } of updates) {
|
|
678
|
+
put(interpretations, record.id, record, limits.interpretations, 'interpretations');
|
|
679
|
+
}
|
|
680
|
+
const changed = mutations !== before || oldDeferred !== deferred.interpretations;
|
|
681
|
+
if (changed) commit('interpretations.replaced', { sessionId: options.event?.sessionId });
|
|
682
|
+
return result(true, changed, obsolete.size, incoming.size);
|
|
683
|
+
}
|
|
684
|
+
|
|
552
685
|
function decisionFreshness(value, sourceRefs, fresh) {
|
|
553
686
|
if (!fresh) return 'stale';
|
|
554
687
|
if (value.validity === 'retracted') return 'retracted';
|
|
@@ -618,9 +751,7 @@ export function createProjectModel({ projectId, policy = {}, restoredState, limi
|
|
|
618
751
|
classification: entity.classification, version: '1', sessionId,
|
|
619
752
|
support: validity !== 'current' ? 'unknown' : entity.classification === 'accepted' ? 'supported' : 'tentative',
|
|
620
753
|
}, limits.refs);
|
|
621
|
-
if (interpretation
|
|
622
|
-
deferred.interpretations++;
|
|
623
|
-
}
|
|
754
|
+
if (interpretation) admitInterpretation(interpretation);
|
|
624
755
|
}
|
|
625
756
|
}
|
|
626
757
|
for (const edge of (Array.isArray(graph.edges) ? graph.edges : []).slice(0, limits.relations)) {
|
|
@@ -883,7 +1014,7 @@ export function createProjectModel({ projectId, policy = {}, restoredState, limi
|
|
|
883
1014
|
}
|
|
884
1015
|
restore(restoredState);
|
|
885
1016
|
return Object.freeze({
|
|
886
|
-
observeInventory, observeStructure, invalidateArtifacts, observeLegacy, observeInterpretations,
|
|
1017
|
+
observeInventory, observeStructure, invalidateArtifacts, observeLegacy, observeInterpretations, replaceInterpretations,
|
|
887
1018
|
recordActivity, setSessions, observeLineage, snapshot, checkpoint, changes, stats,
|
|
888
1019
|
});
|
|
889
1020
|
}
|
|
@@ -17,6 +17,8 @@ export const validity = value => ['current', 'stale', 'retracted'].includes(valu
|
|
|
17
17
|
export const classification = value => ['accepted', 'tentative', 'unknown', 'stale', 'pending', 'abstained'].includes(value)
|
|
18
18
|
? value : 'unknown';
|
|
19
19
|
export const basis = value => ['metadata', 'parsed', 'lexical', 'decision', 'legacy'].includes(value) ? value : 'legacy';
|
|
20
|
+
export const interpretationNamespace = value => typeof value === 'string' &&
|
|
21
|
+
/^[a-z][a-z0-9_-]*(?:[.:][a-z0-9_-]+)+$/.test(value) && safeText(value, 80) ? value : null;
|
|
20
22
|
export const byteSize = value => Buffer.byteLength(JSON.stringify(value));
|
|
21
23
|
export const key = (prefix, ...parts) => opaque(prefix, ...parts);
|
|
22
24
|
const version = value => typeof value === 'string' && value.length <= 256 &&
|
|
@@ -102,8 +104,7 @@ export function relationRecord(value, maxRefs) {
|
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
export function interpretationRecord(value, maxRefs) {
|
|
105
|
-
if (!plain(value) || !id(value.id) ||
|
|
106
|
-
!/^[a-z][a-z0-9_-]*(?:[.:][a-z0-9_-]+)+$/.test(value.namespace) || !safeText(value.namespace, 80)) return null;
|
|
107
|
+
if (!plain(value) || !id(value.id) || !interpretationNamespace(value.namespace)) return null;
|
|
107
108
|
const sourceRefs = references(value.sourceRefs ?? [], maxRefs);
|
|
108
109
|
if (!sourceRefs || !Array.isArray(value.entityIds) || value.entityIds.length > 256 ||
|
|
109
110
|
value.entityIds.some(value => !id(value))) return null;
|