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
package/docs/usage.md
CHANGED
|
@@ -60,6 +60,13 @@ task's changes; discovery of existing code is not automatically creation.
|
|
|
60
60
|
Search with `/`, clear with Escape, and turn off **Follow agent** to explore
|
|
61
61
|
without camera movement.
|
|
62
62
|
|
|
63
|
+
After the source scan, Graphlin automatically discovers application and component
|
|
64
|
+
boundaries for C4. This requires source-transmission consent and a configured
|
|
65
|
+
decision provider. In the live C4 view, choose **Discover architecture** to run
|
|
66
|
+
discovery again. As source edits are observed, Graphlin updates affected boundaries
|
|
67
|
+
incrementally. Architecture documents are optional; unsupported or ambiguous
|
|
68
|
+
boundaries remain unknown.
|
|
69
|
+
|
|
63
70
|
[View controls and limitations](visualizer-views.md) ·
|
|
64
71
|
[Install or author a visualizer](extension-authoring.md) ·
|
|
65
72
|
[Local read API](model-api.md) · [Replace the decision provider](decision-service.md)
|
package/docs/visualizer-views.md
CHANGED
|
@@ -22,6 +22,21 @@ remain usable and the view reports that their responsibilities are unknown.
|
|
|
22
22
|
Missing interpretation does not trigger a classifier call from the browser.
|
|
23
23
|
The Blocks view does not invent responsibility labels.
|
|
24
24
|
|
|
25
|
+
**Discover architecture** in C4 asks the local service to discover application
|
|
26
|
+
and component boundaries from current project evidence. It needs the project's
|
|
27
|
+
existing source-transmission consent and a configured classification service;
|
|
28
|
+
it does not request an extension grant or change consent. The status line
|
|
29
|
+
explains missing consent, a missing key, unavailable source, paused
|
|
30
|
+
classification, partial coverage, or a failed analysis.
|
|
31
|
+
Automatic discovery and incremental rechecks are scheduled by the local service.
|
|
32
|
+
|
|
33
|
+
The browser reads discovery status every two seconds while C4 is open at Live.
|
|
34
|
+
Changing views or entering replay aborts pending status requests and stops
|
|
35
|
+
polling; replay disables the discovery button. Only an explicit button click
|
|
36
|
+
posts a discovery request. Opening C4, switching levels, filtering, and arranging
|
|
37
|
+
do not start analysis from the browser. New supported boundaries arrive through
|
|
38
|
+
the model stream and update the active drawing automatically.
|
|
39
|
+
|
|
25
40
|
**Set baseline now** creates a named checkpoint through the authenticated host
|
|
26
41
|
API, including the selected session. It is disabled during replay. Selecting a
|
|
27
42
|
retained checkpoint uses the core comparison implementation.
|
|
@@ -43,6 +58,21 @@ The inspector's **Open source scope** action and the breadcrumb buttons request
|
|
|
43
58
|
a bounded source scope. C4 interpretation groups can expand to their supported
|
|
44
59
|
source members.
|
|
45
60
|
|
|
61
|
+
Application groups start expanded one level, with component groups collapsed
|
|
62
|
+
and independently expandable. Built-in architecture records use canonical
|
|
63
|
+
source anchors. A current, accepted, supported, source-backed
|
|
64
|
+
`graphlin.architecture` interpretation of kind `architecture_membership` links
|
|
65
|
+
exactly two distinct anchors. The renderer identifies their roles from one
|
|
66
|
+
application and one component interpretation, independently of pair order.
|
|
67
|
+
Missing, stale, or ambiguous links do not create a visual parent.
|
|
68
|
+
|
|
69
|
+
Expanding a component reveals the parsed source subtree beneath its anchor.
|
|
70
|
+
These descendants are a bounded display projection; the viewer does not
|
|
71
|
+
rewrite the model into large unions of membership IDs or evidence references.
|
|
72
|
+
Other extension interpretations retain containment by a unique strict subset
|
|
73
|
+
of recorded members. Equal memberships and multiple possible parent applications
|
|
74
|
+
remain unnested. Source scopes without supported boundaries remain unknown.
|
|
75
|
+
|
|
46
76
|
Collapsed connections aggregate by source scope, target scope, relation type,
|
|
47
77
|
and validity. Calls and writes remain separate. Internal connections do not
|
|
48
78
|
become self-arrows. Each aggregate retains its relation IDs and count for the
|
|
@@ -95,6 +125,8 @@ No launch token is forwarded to a visualizer.
|
|
|
95
125
|
| `GET /api/model/v1/entities`, `/relations`, `/interpretations`, `/activity`, `/sessions`, `/history` | Revision-bound cursor pages; history supplies checkpoints |
|
|
96
126
|
| `GET /api/model/v1/events` | Snapshot envelopes with the same pages/cursors; optional scope/session |
|
|
97
127
|
| `POST /api/model/v1/checkpoints` | Explicit host action with `label` and optional `sessionId` |
|
|
128
|
+
| `GET /api/architecture` | Built-in architecture discovery status and bounded progress counts |
|
|
129
|
+
| `POST /api/architecture/discover` | Explicit Discover architecture action with `{}`; asynchronous `202` status response |
|
|
98
130
|
| `GET /api/extensions` | Catalog with manifest, digest, current grant, and declared profile descriptors |
|
|
99
131
|
| `POST /api/extensions/grant` | `{id, digest, fields, history, approved, profiles}` |
|
|
100
132
|
| `POST /api/extensions/analysis` | Explicit Run analysis action with `{id, digest, profileId, entityIds, revision}` |
|
|
@@ -197,3 +229,12 @@ This explicit check covers the reference views, narrow-screen keyboard use,
|
|
|
197
229
|
real sandbox frame mounting, profile approval/run, selection, and grant
|
|
198
230
|
revocation during replay. It does not access existing user browser profiles,
|
|
199
231
|
projects, or daemon instances, and is not a privacy certification.
|
|
232
|
+
|
|
233
|
+
`node tests/web/architecture-browser-check.mjs` uses the same browser environment
|
|
234
|
+
variables to check architecture discovery through the actual daemon. It creates
|
|
235
|
+
temporary source files and uses literal answers from a local fake provider with
|
|
236
|
+
the normal decision service. Boundary records are never seeded. The check covers
|
|
237
|
+
automatic scanning, the authenticated discovery button, an incremental source
|
|
238
|
+
edit delivered through the model stream, nested frame bounds, keyboard controls,
|
|
239
|
+
and replay. Screenshots and a JSON report go to `GRAPHLIN_BROWSER_ARTIFACTS`.
|
|
240
|
+
The generated application's source is parsed but never executed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphlin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Live architecture and activity diagrams from observable coding-agent work.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -70,6 +70,10 @@
|
|
|
70
70
|
"./docs/visualizer-views.md",
|
|
71
71
|
"./mcp.json",
|
|
72
72
|
"./plugin.json",
|
|
73
|
+
"./runtime/architecture/analysis.mjs",
|
|
74
|
+
"./runtime/architecture/controller.mjs",
|
|
75
|
+
"./runtime/architecture/evidence.mjs",
|
|
76
|
+
"./runtime/architecture/profile.mjs",
|
|
73
77
|
"./runtime/collector/index.mjs",
|
|
74
78
|
"./runtime/core/candidates.mjs",
|
|
75
79
|
"./runtime/core/common.mjs",
|
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.2.
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"description": "Local architecture and activity diagrams from observable coding-agent work.",
|
|
6
6
|
"extensions": {
|
|
7
7
|
"com.openai": {
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { freeze, hash, integer } from '../core/common.mjs';
|
|
3
|
+
import { createPolicy } from '../core/privacy.mjs';
|
|
4
|
+
import { validBundle } from '../core/candidates.mjs';
|
|
5
|
+
import { buildProfileQuestions } from '../decisions/profiles.mjs';
|
|
6
|
+
import { validateResult, isProbability } from '../decisions/contracts.mjs';
|
|
7
|
+
import { buildEvaluation } from '../decisions/evaluation.mjs';
|
|
8
|
+
import { withAbort } from '../decisions/faults.mjs';
|
|
9
|
+
import { DEFAULT_ADMISSION_POLICY as thresholds } from '../decisions/index.mjs';
|
|
10
|
+
import {
|
|
11
|
+
ARCHITECTURE_NAMESPACE, ARCHITECTURE_VERSION, ARCHITECTURE_PROFILES, ROLE_PROFILE_ID, MEMBERSHIP_PROFILE_ID,
|
|
12
|
+
} from './profile.mjs';
|
|
13
|
+
import {
|
|
14
|
+
ARCHITECTURE_LIMITS as L, requireValue, indexModel, moduleForArtifact, requestedArtifacts,
|
|
15
|
+
candidatesForCapture, unionRefs, analysisEvent, unchanged, lineageOf, recordId,
|
|
16
|
+
} from './evidence.mjs';
|
|
17
|
+
|
|
18
|
+
export { ARCHITECTURE_PROFILES, ARCHITECTURE_NAMESPACE, ARCHITECTURE_VERSION } from './profile.mjs';
|
|
19
|
+
export { ARCHITECTURE_LIMITS } from './evidence.mjs';
|
|
20
|
+
const capabilities = { boolean: { probability: true }, choice: { probabilities: true, confidence: true } };
|
|
21
|
+
const knownKind = value => ['application', 'component'].includes(value);
|
|
22
|
+
const currentPolicy = policy => createPolicy(typeof policy === 'function' ? policy() : policy);
|
|
23
|
+
const versionRef = artifact => ({ artifactId: artifact.id, hash: artifact.hash, generation: artifact.generation });
|
|
24
|
+
const supported = answer => isProbability(answer?.probability) && answer.probability >= thresholds.nodeSupportMin;
|
|
25
|
+
const sufficient = answer => isProbability(answer?.probability) && answer.probability <= thresholds.missingContextMax;
|
|
26
|
+
|
|
27
|
+
function roleAnswer(result, selection, event, policy) {
|
|
28
|
+
if (!validBundle(result?.bundle, policy) || result.bundle.candidates.some(candidate =>
|
|
29
|
+
!selection.candidates.some(input => isDeepStrictEqual(input, candidate)))) return undefined;
|
|
30
|
+
if (!result.bundle.candidates.length && result.diagnostics?.code === 'no_approved_candidates') return null;
|
|
31
|
+
const analysis = result.analysis;
|
|
32
|
+
if (analysis?.profileId !== ROLE_PROFILE_ID || analysis.profileVersion !== ARCHITECTURE_VERSION
|
|
33
|
+
|| analysis.status !== 'answered' || !result.bundle.candidates.length) return undefined;
|
|
34
|
+
const { request, subjects } = buildProfileQuestions(ARCHITECTURE_PROFILES[0], event, result.bundle);
|
|
35
|
+
if (!isDeepStrictEqual(analysis.subjects, subjects)) return undefined;
|
|
36
|
+
const answers = validateResult({ answers: analysis.answers }, request, capabilities).answers;
|
|
37
|
+
const role = answers.kind;
|
|
38
|
+
return knownKind(role.choice) && isProbability(role.probabilities?.[role.choice])
|
|
39
|
+
&& role.probabilities[role.choice] >= thresholds.roleProbabilityMin
|
|
40
|
+
&& isProbability(role.confidence) && role.confidence >= thresholds.roleConfidenceMin
|
|
41
|
+
&& supported(answers.supported) && sufficient(answers.missing_context) ? role.choice : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function boundary(projectId, anchor, kind, ref) {
|
|
45
|
+
return {
|
|
46
|
+
id: recordId(projectId, 'boundary', anchor.id), namespace: ARCHITECTURE_NAMESPACE,
|
|
47
|
+
kind, label: anchor.label, entityIds: [anchor.id], sourceRefs: [ref],
|
|
48
|
+
basis: 'decision', validity: 'current', support: 'supported', classification: 'accepted',
|
|
49
|
+
version: ARCHITECTURE_VERSION,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function priorRoles(model, index) {
|
|
54
|
+
const roles = new Map(), conflicting = new Set();
|
|
55
|
+
for (const record of model.interpretations) {
|
|
56
|
+
if (record.namespace !== ARCHITECTURE_NAMESPACE || record.version !== ARCHITECTURE_VERSION
|
|
57
|
+
|| !knownKind(record.kind) || record.validity !== 'current'
|
|
58
|
+
|| record.support !== 'supported' || record.classification !== 'accepted'
|
|
59
|
+
|| record.entityIds?.length !== 1 || !index.currentRefs(record.sourceRefs)) continue;
|
|
60
|
+
const entity = index.entities.get(record.entityIds[0]);
|
|
61
|
+
const anchor = moduleForArtifact(index, entity?.artifactId);
|
|
62
|
+
if (!anchor || entity.id !== anchor.id
|
|
63
|
+
|| record.sourceRefs.some(ref => ref.artifactId !== anchor.artifactId)) continue;
|
|
64
|
+
if (roles.has(anchor.id)) conflicting.add(anchor.id);
|
|
65
|
+
roles.set(anchor.id, { anchor, kind: record.kind, ref: versionRef(index.artifacts.get(anchor.artifactId)) });
|
|
66
|
+
}
|
|
67
|
+
for (const id of conflicting) roles.delete(id);
|
|
68
|
+
return roles;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function membershipPairs(index, roles, processed) {
|
|
72
|
+
const pairs = new Map();
|
|
73
|
+
for (const relation of index.relations) {
|
|
74
|
+
if (relation.kind === 'contains') continue;
|
|
75
|
+
const parent = moduleForArtifact(index, index.entities.get(relation.source).artifactId);
|
|
76
|
+
const child = moduleForArtifact(index, index.entities.get(relation.target).artifactId);
|
|
77
|
+
if (!parent || !child || parent.id === child.id || roles.get(parent.id)?.kind !== 'application'
|
|
78
|
+
|| roles.get(child.id)?.kind !== 'component'
|
|
79
|
+
|| ![parent.artifactId, child.artifactId].some(id => processed.has(id))
|
|
80
|
+
|| relation.sourceRefs.some(ref => ![parent.artifactId, child.artifactId].includes(ref.artifactId))
|
|
81
|
+
|| ![parent.artifactId, child.artifactId].every(id => relation.sourceRefs.some(ref => ref.artifactId === id))) continue;
|
|
82
|
+
const key = `${parent.id}:${child.id}`;
|
|
83
|
+
// Both canonical parsed anchors belong to this single-project index, and
|
|
84
|
+
// the resolved parsed relation carries current versions of both artifacts.
|
|
85
|
+
if (!pairs.has(key)) pairs.set(key, {
|
|
86
|
+
parent: roles.get(parent.id), child: roles.get(child.id), kinds: new Set(),
|
|
87
|
+
sameProject: index.entities.has(parent.id) && index.entities.has(child.id),
|
|
88
|
+
resolvedLocalDependency: [parent.artifactId, child.artifactId].every(id => index.artifacts.has(id)),
|
|
89
|
+
currentSourceVersions: Boolean(index.currentRefs(relation.sourceRefs)),
|
|
90
|
+
});
|
|
91
|
+
pairs.get(key).kinds.add(relation.kind);
|
|
92
|
+
}
|
|
93
|
+
return [...pairs].sort(([a], [b]) => a.localeCompare(b)).map(([, value]) => value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function priorMembership(model, index, pair) {
|
|
97
|
+
return model.interpretations.find(value => value.namespace === ARCHITECTURE_NAMESPACE
|
|
98
|
+
&& value.version === ARCHITECTURE_VERSION && value.kind === 'architecture_membership'
|
|
99
|
+
&& value.validity === 'current'
|
|
100
|
+
&& (value.support === 'supported' && value.classification === 'accepted'
|
|
101
|
+
|| value.support === 'unknown' && value.classification === 'unknown')
|
|
102
|
+
&& isDeepStrictEqual(value.entityIds, [pair.parent.anchor.id, pair.child.anchor.id])
|
|
103
|
+
&& index.currentRefs(value.sourceRefs)
|
|
104
|
+
&& value.sourceRefs.length === 2 && value.sourceRefs.every(ref =>
|
|
105
|
+
[pair.parent.ref, pair.child.ref].some(expected => isDeepStrictEqual(ref, expected))));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function membership(projectId, pair, accepted) {
|
|
109
|
+
return {
|
|
110
|
+
id: recordId(projectId, 'membership', pair.parent.anchor.id, pair.child.anchor.id),
|
|
111
|
+
namespace: ARCHITECTURE_NAMESPACE, kind: 'architecture_membership',
|
|
112
|
+
label: accepted ? 'Application component' : 'Membership unknown',
|
|
113
|
+
entityIds: [pair.parent.anchor.id, pair.child.anchor.id], sourceRefs: [pair.parent.ref, pair.child.ref],
|
|
114
|
+
basis: 'decision', validity: 'current',
|
|
115
|
+
support: accepted ? 'supported' : 'unknown', classification: accepted ? 'accepted' : 'unknown',
|
|
116
|
+
version: ARCHITECTURE_VERSION,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function membershipEvaluation(pairs, basis) {
|
|
121
|
+
const roles = new Map();
|
|
122
|
+
for (const pair of pairs) for (const role of [pair.parent, pair.child]) {
|
|
123
|
+
roles.set(role.anchor.id, {
|
|
124
|
+
id: role.anchor.id, kind: role.kind, label: role.anchor.label, support: 'supported', classification: 'accepted',
|
|
125
|
+
basis: 'decision', anchorBasis: role.anchor.basis, validity: role.anchor.validity,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const state = {
|
|
129
|
+
context: {
|
|
130
|
+
basis: 'current_source_backed_roles_and_parsed_dependencies',
|
|
131
|
+
membership: 'A source composition link means an accepted application source module directly declares a parsed '
|
|
132
|
+
+ 'import, call or depends_on dependency on an accepted component source module. This records a static source '
|
|
133
|
+
+ 'dependency between the roles, not exclusive ownership, active usage, deployment or runtime hosting.',
|
|
134
|
+
instruction: 'Roles were accepted from current source through privacy intake. Dependencies are parsed facts. '
|
|
135
|
+
+ 'sameProject, resolvedLocalDependency and currentSourceVersions are core-verified from canonical parsed '
|
|
136
|
+
+ 'anchors and exact current references for both endpoints, not inferred from their labels. '
|
|
137
|
+
+ 'Assess only the proposed finite pairs. Do not infer membership from folders or names, invent entities, '
|
|
138
|
+
+ 'or assert exclusive ownership, runtime deployment or successful execution.',
|
|
139
|
+
},
|
|
140
|
+
boundaries: [...roles.values()],
|
|
141
|
+
proposals: pairs.map(pair => ({
|
|
142
|
+
parentId: pair.parent.anchor.id, childId: pair.child.anchor.id, relationKinds: [...pair.kinds].sort(),
|
|
143
|
+
sameProject: pair.sameProject, resolvedLocalDependency: pair.resolvedLocalDependency,
|
|
144
|
+
currentSourceVersions: pair.currentSourceVersions,
|
|
145
|
+
})),
|
|
146
|
+
};
|
|
147
|
+
const questions = pairs.flatMap((_pair, i) => [
|
|
148
|
+
{
|
|
149
|
+
id: `member_${i}`, kind: 'boolean', requiredMetrics: ['probability'],
|
|
150
|
+
question: `Do the accepted roles in \`boundaries\` and directed parsed dependency in \`proposals[${i}]\` `
|
|
151
|
+
+ 'establish this exact source composition link as defined by `context.membership`?',
|
|
152
|
+
focus: 'Both endpoints must have accepted, supported application/component roles. A direct parsed import, call '
|
|
153
|
+
+ 'or depends_on relation between these endpoints suffices for the declared source dependency. A folder/name '
|
|
154
|
+
+ 'match, co-occurrence, unknown role or unrelated dependency does not. Imported implementation, proof of active '
|
|
155
|
+
+ 'use, exclusive ownership, upstream callers and runtime hosting are outside this static claim.',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: `missing_${i}`, kind: 'boolean', requiredMetrics: ['probability'],
|
|
159
|
+
question: `Is an accepted endpoint role or the exact directed parsed dependency missing from \`proposals[${i}]\` `
|
|
160
|
+
+ 'and `boundaries`, preventing determination of the source composition link defined by `context.membership`?',
|
|
161
|
+
focus: 'The role analysis already inspected approved source. Both accepted roles and an exact parsed import, call '
|
|
162
|
+
+ 'or depends_on relation are sufficient here. Do not require source bodies again, proof the import is actively '
|
|
163
|
+
+ 'used, imported dependency internals, callers, runtime observations, deployment facts or optional documents.',
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
return {
|
|
167
|
+
state, questions, profile: { id: MEMBERSHIP_PROFILE_ID, version: ARCHITECTURE_VERSION },
|
|
168
|
+
cacheContext: {
|
|
169
|
+
projectId: basis.projectId, worktreeId: basis.projectId, lineage: basis.lineageId,
|
|
170
|
+
policyVersion: basis.policyVersion,
|
|
171
|
+
evidenceVersion: hash([basis.revision, pairs.map(pair => [pair.parent.ref, pair.child.ref])]),
|
|
172
|
+
taskScope: ARCHITECTURE_NAMESPACE,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function membershipAnswers(result, evaluation) {
|
|
178
|
+
requireValue(result?.status === 'accepted' && Array.isArray(result.answers)
|
|
179
|
+
&& result.answers.length === evaluation.questions.length);
|
|
180
|
+
const answers = {};
|
|
181
|
+
for (const answer of result.answers) {
|
|
182
|
+
requireValue(answer.kind === 'boolean' && evaluation.questions.some(q => q.id === answer.id)
|
|
183
|
+
&& !Object.hasOwn(answers, answer.id));
|
|
184
|
+
answers[answer.id] = { type: 'boolean', value: answer.value, probability: answer.probability };
|
|
185
|
+
}
|
|
186
|
+
const { request } = buildEvaluation(evaluation, { maxQuestionsPerStage: 40, maxRequestBytes: 64 * 1024 });
|
|
187
|
+
return validateResult({ answers }, request, capabilities).answers;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* One bounded, stateless analysis job over authorized captures and a model
|
|
192
|
+
* snapshot. The controller owns capture, scheduling, freshness revalidation and
|
|
193
|
+
* replaceInterpretations(namespace, interpretations, { affectedEntityIds, sourceRefs }).
|
|
194
|
+
* Explicit empty scope means no replacement. Missing captures also return
|
|
195
|
+
* coverage.withdrawnEntityIds for a separate, controller-version-guarded clear;
|
|
196
|
+
* present-source guards cannot authorize absence. No source text is returned.
|
|
197
|
+
*/
|
|
198
|
+
export async function analyzeArchitecture({ model, artifacts = [], service, policy, signal, affectedArtifactIds } = {}) {
|
|
199
|
+
const coverage = {
|
|
200
|
+
requestedArtifactIds: [], analyzedArtifactIds: [], deferredArtifactIds: [], unavailableArtifactIds: [],
|
|
201
|
+
missingArtifactIds: [], withdrawnEntityIds: [], deferredMembershipArtifactIds: [],
|
|
202
|
+
unknownArtifactIds: [], omittedCandidates: 0, membershipProposals: 0, membershipChecks: 0,
|
|
203
|
+
deferredMemberships: 0, supportedBoundaries: 0, supportedMemberships: 0, unknownMemberships: 0, complete: false,
|
|
204
|
+
};
|
|
205
|
+
const diagnostics = { code: 'architecture_unavailable', profileCalls: 0, evaluationCalls: 0, providerRequests: 0 };
|
|
206
|
+
const empty = status => ({ status, interpretations: [], sourceRefs: [], affectedEntityIds: [], coverage, diagnostics });
|
|
207
|
+
try {
|
|
208
|
+
const consent = currentPolicy(policy);
|
|
209
|
+
requireValue(consent.transmitSource && typeof service?.analyze === 'function'
|
|
210
|
+
&& Array.isArray(artifacts) && artifacts.length <= 64);
|
|
211
|
+
const index = indexModel(model, consent);
|
|
212
|
+
const basis = { projectId: model.projectId, revision: model.revision,
|
|
213
|
+
lineageId: lineageOf(model), policyVersion: consent.version };
|
|
214
|
+
Object.assign(diagnostics, basis);
|
|
215
|
+
const check = () => requireValue(unchanged(model, basis, currentPolicy(policy), signal));
|
|
216
|
+
const wait = operation => signal ? withAbort(operation, signal) : operation();
|
|
217
|
+
const countRequests = result => {
|
|
218
|
+
if (integer(result?.diagnostics?.calls, 0, 2)) diagnostics.providerRequests += result.diagnostics.calls;
|
|
219
|
+
};
|
|
220
|
+
check();
|
|
221
|
+
const roles = priorRoles(model, index);
|
|
222
|
+
const priorArtifacts = new Set([...roles.values()].map(role => role.anchor.artifactId));
|
|
223
|
+
const seeds = new Set(affectedArtifactIds ?? []);
|
|
224
|
+
const requested = requestedArtifacts(model, index, artifacts, affectedArtifactIds)
|
|
225
|
+
.filter(id => affectedArtifactIds === undefined || seeds.has(id) || !priorArtifacts.has(id));
|
|
226
|
+
coverage.requestedArtifactIds = requested;
|
|
227
|
+
const captures = new Map(artifacts.map(capture => [capture.id, capture]));
|
|
228
|
+
const interpretations = [], affected = [], guards = new Map(), processed = new Set(), missing = new Map();
|
|
229
|
+
let sourceBytes = 0, partial = false;
|
|
230
|
+
const event = analysisEvent(model);
|
|
231
|
+
for (const artifactId of requested) {
|
|
232
|
+
check();
|
|
233
|
+
const capture = captures.get(artifactId);
|
|
234
|
+
if (!capture || coverage.analyzedArtifactIds.length + coverage.unavailableArtifactIds.length
|
|
235
|
+
+ coverage.missingArtifactIds.length >= L.artifacts) {
|
|
236
|
+
coverage.deferredArtifactIds.push(artifactId); partial = true; continue;
|
|
237
|
+
}
|
|
238
|
+
const observation = index.artifacts.get(artifactId);
|
|
239
|
+
if (capture.status === 'missing' && capture.exists === false && capture.complete === true
|
|
240
|
+
&& capture.hash === null && integer(capture.generation, 1)
|
|
241
|
+
&& observation?.status === 'missing' && observation.complete === true && observation.hash === null
|
|
242
|
+
&& observation.generation === capture.generation) {
|
|
243
|
+
const ids = new Set(model.interpretations.filter(value => value.namespace === ARCHITECTURE_NAMESPACE)
|
|
244
|
+
.flatMap(value => Array.isArray(value.entityIds) && value.entityIds.length <= 2 ? value.entityIds : [])
|
|
245
|
+
.filter(id => index.entities.get(id)?.artifactId === artifactId));
|
|
246
|
+
affected.push(...ids); coverage.withdrawnEntityIds.push(...ids);
|
|
247
|
+
coverage.missingArtifactIds.push(artifactId); missing.set(artifactId, capture.generation);
|
|
248
|
+
for (const id of ids) roles.delete(id);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const size = typeof capture.text === 'string' ? Buffer.byteLength(capture.text) : 0;
|
|
252
|
+
const selected = candidatesForCapture(capture, index, event, consent);
|
|
253
|
+
if (!selected) {
|
|
254
|
+
coverage.unavailableArtifactIds.push(artifactId); partial = true; continue;
|
|
255
|
+
}
|
|
256
|
+
if (sourceBytes + size > L.sourceBytes) {
|
|
257
|
+
coverage.deferredArtifactIds.push(artifactId); partial = true; continue;
|
|
258
|
+
}
|
|
259
|
+
sourceBytes += size;
|
|
260
|
+
coverage.omittedCandidates += selected.omitted;
|
|
261
|
+
if (selected.omitted) partial = true;
|
|
262
|
+
let kind = null;
|
|
263
|
+
if (selected.candidates.length) {
|
|
264
|
+
try {
|
|
265
|
+
diagnostics.profileCalls++;
|
|
266
|
+
const result = await wait(() => service.analyze({
|
|
267
|
+
event, candidates: selected.candidates, profileId: ROLE_PROFILE_ID, policy: consent,
|
|
268
|
+
...(signal ? { signal } : {}),
|
|
269
|
+
}));
|
|
270
|
+
check(); countRequests(result);
|
|
271
|
+
kind = roleAnswer(result, selected, event, consent);
|
|
272
|
+
} catch {
|
|
273
|
+
check(); kind = undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (kind === undefined) {
|
|
277
|
+
coverage.unavailableArtifactIds.push(artifactId); partial = true; continue;
|
|
278
|
+
}
|
|
279
|
+
coverage.analyzedArtifactIds.push(artifactId);
|
|
280
|
+
processed.add(artifactId); affected.push(selected.anchor.id); guards.set(artifactId, selected.sourceRef);
|
|
281
|
+
roles.delete(selected.anchor.id);
|
|
282
|
+
if (kind) {
|
|
283
|
+
roles.set(selected.anchor.id, { anchor: selected.anchor, kind, ref: selected.sourceRef });
|
|
284
|
+
interpretations.push(boundary(model.projectId, selected.anchor, kind, selected.sourceRef));
|
|
285
|
+
} else coverage.unknownArtifactIds.push(artifactId);
|
|
286
|
+
}
|
|
287
|
+
coverage.supportedBoundaries = interpretations.length;
|
|
288
|
+
const proposed = membershipPairs(index, roles, processed);
|
|
289
|
+
coverage.membershipProposals = proposed.length;
|
|
290
|
+
const pending = [];
|
|
291
|
+
for (const pair of proposed) {
|
|
292
|
+
const previous = priorMembership(model, index, pair);
|
|
293
|
+
if (!previous) { pending.push(pair); continue; }
|
|
294
|
+
interpretations.push(structuredClone(previous));
|
|
295
|
+
for (const role of [pair.parent, pair.child]) guards.set(role.ref.artifactId, role.ref);
|
|
296
|
+
coverage[previous.support === 'supported' ? 'supportedMemberships' : 'unknownMemberships']++;
|
|
297
|
+
}
|
|
298
|
+
const pairs = pending.slice(0, L.membershipChecks);
|
|
299
|
+
coverage.deferredMemberships = pending.length - pairs.length;
|
|
300
|
+
coverage.deferredMembershipArtifactIds = [...new Set(pending.slice(L.membershipChecks).map(pair => pair.child.ref.artifactId))];
|
|
301
|
+
if (coverage.deferredMemberships) partial = true;
|
|
302
|
+
if (pairs.length) {
|
|
303
|
+
for (const pair of pairs) for (const role of [pair.parent, pair.child]) guards.set(role.ref.artifactId, role.ref);
|
|
304
|
+
let answers;
|
|
305
|
+
try {
|
|
306
|
+
check(); requireValue(typeof service.evaluate === 'function');
|
|
307
|
+
const evaluation = membershipEvaluation(pairs, basis);
|
|
308
|
+
diagnostics.evaluationCalls++;
|
|
309
|
+
const result = await wait(() => service.evaluate({ ...evaluation, ...(signal ? { signal } : {}) }));
|
|
310
|
+
check(); countRequests(result);
|
|
311
|
+
answers = membershipAnswers(result, evaluation);
|
|
312
|
+
coverage.membershipChecks = pairs.length;
|
|
313
|
+
} catch { check(); partial = true; }
|
|
314
|
+
// Only validated answers establish a versioned observation. A transient
|
|
315
|
+
// failure leaves no pair record, so a later Discover can retry it.
|
|
316
|
+
if (answers) pairs.forEach((pair, i) => {
|
|
317
|
+
const accepted = supported(answers[`member_${i}`]) && sufficient(answers[`missing_${i}`]);
|
|
318
|
+
interpretations.push(membership(model.projectId, pair, accepted));
|
|
319
|
+
coverage[accepted ? 'supportedMemberships' : 'unknownMemberships']++;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
check();
|
|
323
|
+
const sourceRefs = unionRefs([...guards.values()]);
|
|
324
|
+
const fresh = indexModel(model, consent);
|
|
325
|
+
requireValue(sourceRefs && (!sourceRefs.length || fresh.currentRefs(sourceRefs, L.guardRefs))
|
|
326
|
+
&& [...missing].every(([id, generation]) => fresh.artifacts.get(id)?.status === 'missing'
|
|
327
|
+
&& fresh.artifacts.get(id)?.complete === true
|
|
328
|
+
&& fresh.artifacts.get(id)?.generation === generation && fresh.artifacts.get(id)?.hash === null)
|
|
329
|
+
&& affected.every(id => fresh.entities.get(id)?.validity === 'current' || coverage.withdrawnEntityIds.includes(id))
|
|
330
|
+
&& interpretations.every(value => value.entityIds.every(id => fresh.entities.get(id)?.validity === 'current')));
|
|
331
|
+
coverage.complete = !partial;
|
|
332
|
+
diagnostics.code = partial ? 'architecture_partial'
|
|
333
|
+
: interpretations.length ? 'architecture_complete' : 'architecture_unknown';
|
|
334
|
+
return freeze({
|
|
335
|
+
status: partial ? (affected.length ? 'partial' : 'unavailable') : 'complete',
|
|
336
|
+
interpretations, sourceRefs, affectedEntityIds: affected, coverage, diagnostics,
|
|
337
|
+
});
|
|
338
|
+
} catch {
|
|
339
|
+
diagnostics.code = signal?.aborted ? 'architecture_cancelled' : 'architecture_unavailable';
|
|
340
|
+
coverage.deferredArtifactIds = [];
|
|
341
|
+
coverage.deferredMembershipArtifactIds = [];
|
|
342
|
+
return freeze(empty(signal?.aborted ? 'cancelled' : 'unavailable'));
|
|
343
|
+
}
|
|
344
|
+
}
|