@principal-ai/subsystems-core 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schemas/subsystem-model.schema.json +18 -16
- package/src/storage/topicStore.test.ts +0 -19
- package/src/storage/topicStore.ts +11 -63
- package/src/types/subsystem-model.ts +23 -23
- package/src/validation.test.ts +80 -7
- package/src/validation.ts +43 -12
- package/dist/agent-sessions/fixture.d.ts +0 -80
- package/dist/agent-sessions/fixture.d.ts.map +0 -1
- package/dist/agent-sessions/fixture.js +0 -130
- package/dist/agent-sessions/fixture.js.map +0 -1
- package/dist/agent-sessions/index.d.ts +0 -3
- package/dist/agent-sessions/index.d.ts.map +0 -1
- package/dist/agent-sessions/index.js +0 -6
- package/dist/agent-sessions/index.js.map +0 -1
- package/dist/index.d.ts +0 -16
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -36
- package/dist/index.js.map +0 -1
- package/dist/node.d.ts +0 -22
- package/dist/node.d.ts.map +0 -1
- package/dist/node.js +0 -55
- package/dist/node.js.map +0 -1
- package/dist/opencode/OpenCodeEventStore.d.ts +0 -25
- package/dist/opencode/OpenCodeEventStore.d.ts.map +0 -1
- package/dist/opencode/OpenCodeEventStore.js +0 -182
- package/dist/opencode/OpenCodeEventStore.js.map +0 -1
- package/dist/opencode/agent-sessions.d.ts +0 -46
- package/dist/opencode/agent-sessions.d.ts.map +0 -1
- package/dist/opencode/agent-sessions.js +0 -302
- package/dist/opencode/agent-sessions.js.map +0 -1
- package/dist/opencode/index.d.ts +0 -6
- package/dist/opencode/index.d.ts.map +0 -1
- package/dist/opencode/index.js +0 -15
- package/dist/opencode/index.js.map +0 -1
- package/dist/opencode/node-path-adapter.d.ts +0 -25
- package/dist/opencode/node-path-adapter.d.ts.map +0 -1
- package/dist/opencode/node-path-adapter.js +0 -188
- package/dist/opencode/node-path-adapter.js.map +0 -1
- package/dist/opencode/pipeline.d.ts +0 -42
- package/dist/opencode/pipeline.d.ts.map +0 -1
- package/dist/opencode/pipeline.js +0 -90
- package/dist/opencode/pipeline.js.map +0 -1
- package/dist/opencode/types.d.ts +0 -39
- package/dist/opencode/types.d.ts.map +0 -1
- package/dist/opencode/types.js +0 -3
- package/dist/opencode/types.js.map +0 -1
- package/dist/storage/topic-types.d.ts +0 -203
- package/dist/storage/topic-types.d.ts.map +0 -1
- package/dist/storage/topic-types.js +0 -60
- package/dist/storage/topic-types.js.map +0 -1
- package/dist/storage/topicStore.d.ts +0 -135
- package/dist/storage/topicStore.d.ts.map +0 -1
- package/dist/storage/topicStore.js +0 -389
- package/dist/storage/topicStore.js.map +0 -1
- package/dist/types/index.d.ts +0 -7
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -23
- package/dist/types/index.js.map +0 -1
- package/dist/types/subsystem-model.d.ts +0 -388
- package/dist/types/subsystem-model.d.ts.map +0 -1
- package/dist/types/subsystem-model.js +0 -87
- package/dist/types/subsystem-model.js.map +0 -1
- package/dist/validation.d.ts +0 -24
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js +0 -82
- package/dist/validation.js.map +0 -1
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://principal-ai.dev/schemas/subsystem-model.schema.json",
|
|
4
4
|
"title": "Subsystem Model",
|
|
5
|
-
"description": "Portable subsystem model — the shareable standard. Construct-tagged components (nodes) plus runtime
|
|
5
|
+
"description": "Portable subsystem model — the shareable standard. Construct-tagged components (nodes) plus runtime trails describing one subsystem of a codebase. Ontology: construct = what a node is, framework + stereotype = which framework pattern it plays, role = where it sits, process = where it runs, module = which source file/module the export belongs to. Symbol is the code identity; name is the display label (often derived from symbol). Host-only fields (local path binding, provenance, store ids, verification) are NOT part of this document; they belong on a hydrated envelope around it.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"required": [
|
|
8
8
|
"title",
|
|
@@ -30,11 +30,11 @@
|
|
|
30
30
|
"$ref": "#/$defs/component"
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
|
-
"
|
|
33
|
+
"trails": {
|
|
34
34
|
"type": "array",
|
|
35
|
-
"description": "Ordered runtime
|
|
35
|
+
"description": "Ordered runtime trails (one per named behavior). Each step names from/to/mechanism and the concrete file:line where that seam fires.",
|
|
36
36
|
"items": {
|
|
37
|
-
"$ref": "#/$defs/
|
|
37
|
+
"$ref": "#/$defs/trail"
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
"createdAtCommits": {
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
},
|
|
101
101
|
"mechanism": {
|
|
102
102
|
"type": "string",
|
|
103
|
-
"description": "
|
|
103
|
+
"description": "Trail step mechanism — how `from` relates to `to` at a runtime site. `uses` = general dependency; `feeds` = data-flow into a processor; `produces` = emits an output; `writes`/`reads`/`watches` = retained-state interactions.",
|
|
104
104
|
"enum": [
|
|
105
105
|
"calls",
|
|
106
106
|
"uses",
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"alias": {
|
|
127
127
|
"type": "string",
|
|
128
128
|
"minLength": 1,
|
|
129
|
-
"description": "Model-local stable alias, unique per model. Referenced by
|
|
129
|
+
"description": "Model-local stable alias, unique per model. Referenced by trail `from` / `to`; edges point at the alias, not the location, so a file move or symbol rename leaves edges intact. Code identity (for composed multi-model views) lives on `purl` + `file` + `symbol`, not here."
|
|
130
130
|
},
|
|
131
131
|
"name": {
|
|
132
132
|
"type": "string",
|
|
@@ -138,7 +138,8 @@
|
|
|
138
138
|
},
|
|
139
139
|
"file": {
|
|
140
140
|
"type": "string",
|
|
141
|
-
"
|
|
141
|
+
"pattern": "^(?!.*(^|/)node_modules(/|$)).*$",
|
|
142
|
+
"description": "Repo-root-relative path of the source location this component lives in. Resolved against the checkout of the repo named by this component's `purl` (via the Alexandria registry). Empty string allowed for pure externals. `node_modules/` is rejected: installed artifacts are not part of the repo — model a third-party dependency as `construct: external` with `purl: pkg:npm/<package>` and no file."
|
|
142
143
|
},
|
|
143
144
|
"purl": {
|
|
144
145
|
"type": "string",
|
|
@@ -205,7 +206,7 @@
|
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
208
|
},
|
|
208
|
-
"
|
|
209
|
+
"trailStep": {
|
|
209
210
|
"type": "object",
|
|
210
211
|
"required": [
|
|
211
212
|
"from",
|
|
@@ -234,7 +235,8 @@
|
|
|
234
235
|
"file": {
|
|
235
236
|
"type": "string",
|
|
236
237
|
"minLength": 1,
|
|
237
|
-
"
|
|
238
|
+
"pattern": "^(?!.*(^|/)node_modules(/|$)).*$",
|
|
239
|
+
"description": "Repo-root-relative path where the seam fires for this trail."
|
|
238
240
|
},
|
|
239
241
|
"line": {
|
|
240
242
|
"type": "integer",
|
|
@@ -249,16 +251,16 @@
|
|
|
249
251
|
"symbol": {
|
|
250
252
|
"type": "string",
|
|
251
253
|
"minLength": 1,
|
|
252
|
-
"description": "Frame name for this
|
|
254
|
+
"description": "Frame name for this step — the function/method on the stack at the site. Required; the Trails list shows this instead of a bare mechanism + filename fallback."
|
|
253
255
|
},
|
|
254
256
|
"annotation": {
|
|
255
257
|
"type": "string",
|
|
256
258
|
"minLength": 1,
|
|
257
|
-
"description": "Free-text note anchored to this
|
|
259
|
+
"description": "Free-text note anchored to this step's site line. Optional, informative only, never verified against source; viewers surface it in the codeview's annotation column."
|
|
258
260
|
}
|
|
259
261
|
}
|
|
260
262
|
},
|
|
261
|
-
"
|
|
263
|
+
"trail": {
|
|
262
264
|
"type": "object",
|
|
263
265
|
"required": [
|
|
264
266
|
"id",
|
|
@@ -270,18 +272,18 @@
|
|
|
270
272
|
"id": {
|
|
271
273
|
"type": "string",
|
|
272
274
|
"minLength": 1,
|
|
273
|
-
"description": "Stable unique
|
|
275
|
+
"description": "Stable unique trail id."
|
|
274
276
|
},
|
|
275
277
|
"title": {
|
|
276
278
|
"type": "string",
|
|
277
279
|
"minLength": 1,
|
|
278
|
-
"description": "
|
|
280
|
+
"description": "Trail name (e.g. save, load, refresh)."
|
|
279
281
|
},
|
|
280
282
|
"steps": {
|
|
281
283
|
"type": "array",
|
|
282
|
-
"description": "Ordered
|
|
284
|
+
"description": "Ordered steps; array order is execution order.",
|
|
283
285
|
"items": {
|
|
284
|
-
"$ref": "#/$defs/
|
|
286
|
+
"$ref": "#/$defs/trailStep"
|
|
285
287
|
}
|
|
286
288
|
}
|
|
287
289
|
}
|
|
@@ -91,25 +91,6 @@ describe('CRUD', () => {
|
|
|
91
91
|
expect(published.repos).toEqual(['pkg:github/acme/web']);
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
test('trail membership add/remove/reorder', async () => {
|
|
95
|
-
const store = makeStore();
|
|
96
|
-
await store.createTopic({ id: 'topic-m', title: 'M', trailIds: [] });
|
|
97
|
-
await store.addTrailToTopic('topic-m', 'a');
|
|
98
|
-
await store.addTrailToTopic('topic-m', 'b');
|
|
99
|
-
await store.addTrailToTopic('topic-m', 'a'); // dup no-op
|
|
100
|
-
expect((await store.getTopic('topic-m'))?.trailIds).toEqual(['a', 'b']);
|
|
101
|
-
|
|
102
|
-
await store.reorderTopicTrails('topic-m', ['b', 'a']);
|
|
103
|
-
expect((await store.getTopic('topic-m'))?.trailIds).toEqual(['b', 'a']);
|
|
104
|
-
|
|
105
|
-
await expect(
|
|
106
|
-
store.reorderTopicTrails('topic-m', ['b', 'c']),
|
|
107
|
-
).rejects.toThrow(/permutation/);
|
|
108
|
-
|
|
109
|
-
await store.removeTrailFromTopic('topic-m', 'b');
|
|
110
|
-
expect((await store.getTopic('topic-m'))?.trailIds).toEqual(['a']);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
94
|
test('delete removes the file and the index entry', async () => {
|
|
114
95
|
const store = makeStore();
|
|
115
96
|
await store.createTopic({ id: 'topic-d', title: 'D', trailIds: [] });
|
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File-per-topic store under `~/.principal/topics/`.
|
|
3
3
|
*
|
|
4
|
-
* Layout (
|
|
5
|
-
*
|
|
4
|
+
* Layout (file-per-entity, so topics become locally greppable and an agent can
|
|
5
|
+
* read one directly):
|
|
6
6
|
*
|
|
7
7
|
* ~/.principal/topics/
|
|
8
8
|
* _index.json private, rebuildable manifest (entries[])
|
|
9
9
|
* <id>.json one pretty-printed DraftTopic per file
|
|
10
10
|
*
|
|
11
|
-
* Topics are the *least* repo-bound artifact (a bundle
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Topics are the *least* repo-bound artifact (a bundle spanning many repos), so
|
|
12
|
+
* they are stored flat by id rather than bucketed by repo Purl. No purl, no
|
|
13
|
+
* buckets, no `node:os`/Purl dependency beyond `homedir()`.
|
|
14
14
|
*
|
|
15
15
|
* The `_index.json` manifest is store-private and rebuildable: it is rebuilt by
|
|
16
16
|
* scanning the directory whenever it is missing or unparseable. It exists only
|
|
17
17
|
* to make `getTopics()` / `list()` cheap (no per-file read for listing).
|
|
18
18
|
*
|
|
19
|
-
* There is intentionally NO eviction cap.
|
|
20
|
-
*
|
|
21
|
-
* dropped, so the cap is deliberately not carried over.
|
|
19
|
+
* There is intentionally NO eviction cap. Topics are few, user-curated, and must
|
|
20
|
+
* never be silently dropped.
|
|
22
21
|
*
|
|
23
22
|
* Migration from the legacy single-blob `~/.alexandria/topics.json` is explicit
|
|
24
23
|
* (`migrateFromLegacyBlob`) — it is never run automatically on load; the desktop
|
|
@@ -125,8 +124,9 @@ export interface MigrationResult {
|
|
|
125
124
|
|
|
126
125
|
/**
|
|
127
126
|
* Fields a caller may set when updating a topic. `id`, `createdAt`, and
|
|
128
|
-
* `trailIds` are
|
|
129
|
-
* `trailIds
|
|
127
|
+
* `trailIds` are immutable here — `id`/`createdAt` are set once at creation, and
|
|
128
|
+
* `trailIds` is foreign-keyed membership owned by the desktop's trail store, so
|
|
129
|
+
* a topic update here can never rewrite it.
|
|
130
130
|
*/
|
|
131
131
|
export type TopicUpdate = Partial<
|
|
132
132
|
Pick<DraftTopic, 'title' | 'description' | 'status' | 'createdBy' | 'assets' | 'repos'>
|
|
@@ -241,65 +241,13 @@ export class TopicStore {
|
|
|
241
241
|
return true;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
// ===== Trail membership =====
|
|
245
|
-
|
|
246
|
-
async addTrailToTopic(topicId: string, trailId: string): Promise<DraftTopic> {
|
|
247
|
-
const idx = await this.getIndex();
|
|
248
|
-
const topic = await this.requireTopic(idx, topicId);
|
|
249
|
-
if (topic.trailIds.includes(trailId)) return topic;
|
|
250
|
-
const next: DraftTopic = {
|
|
251
|
-
...topic,
|
|
252
|
-
trailIds: [...topic.trailIds, trailId],
|
|
253
|
-
updatedAt: nowIso(),
|
|
254
|
-
};
|
|
255
|
-
await this.writeTopic(next, idx);
|
|
256
|
-
return next;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
async removeTrailFromTopic(topicId: string, trailId: string): Promise<DraftTopic> {
|
|
260
|
-
const idx = await this.getIndex();
|
|
261
|
-
const topic = await this.requireTopic(idx, topicId);
|
|
262
|
-
if (!topic.trailIds.includes(trailId)) return topic;
|
|
263
|
-
const next: DraftTopic = {
|
|
264
|
-
...topic,
|
|
265
|
-
trailIds: topic.trailIds.filter((t) => t !== trailId),
|
|
266
|
-
updatedAt: nowIso(),
|
|
267
|
-
};
|
|
268
|
-
await this.writeTopic(next, idx);
|
|
269
|
-
return next;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
async reorderTopicTrails(topicId: string, trailIds: string[]): Promise<DraftTopic> {
|
|
273
|
-
const idx = await this.getIndex();
|
|
274
|
-
const topic = await this.requireTopic(idx, topicId);
|
|
275
|
-
const current = new Set(topic.trailIds);
|
|
276
|
-
const next = new Set(trailIds);
|
|
277
|
-
if (current.size !== next.size || [...current].some((t) => !next.has(t))) {
|
|
278
|
-
throw new Error(
|
|
279
|
-
'reorderTopicTrails expects a permutation of the existing trail list',
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
const updated: DraftTopic = {
|
|
283
|
-
...topic,
|
|
284
|
-
trailIds: [...trailIds],
|
|
285
|
-
updatedAt: nowIso(),
|
|
286
|
-
};
|
|
287
|
-
await this.writeTopic(updated, idx);
|
|
288
|
-
return updated;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async getTopicsForTrail(trailId: string): Promise<DraftTopic[]> {
|
|
292
|
-
const topics = await this.getTopics();
|
|
293
|
-
return topics.filter((t) => t.trailIds.includes(trailId));
|
|
294
|
-
}
|
|
295
|
-
|
|
296
244
|
// ===== Migration =====
|
|
297
245
|
|
|
298
246
|
/**
|
|
299
247
|
* One-shot migration from the legacy single-blob `~/.alexandria/topics.json`
|
|
300
248
|
* to file-per-topic. Reads the blob's `topics[]`, writes each as
|
|
301
249
|
* `<id>.json`, rebuilds the index, then renames the blob to `<blob>.bak` so
|
|
302
|
-
* a re-run is a no-op
|
|
250
|
+
* a re-run is a no-op.
|
|
303
251
|
*
|
|
304
252
|
* Explicit by design — the desktop calls this from a Settings action, never
|
|
305
253
|
* on load. Idempotent: once the blob is `.bak`'d, subsequent calls report
|
|
@@ -59,10 +59,10 @@ export type SubsystemFramework = string;
|
|
|
59
59
|
export type SubsystemStereotype = string;
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
* Belongs on
|
|
62
|
+
* Trail step mechanism — runtime seams with a `file:line` site.
|
|
63
|
+
* Belongs on trail steps; graph edges for these are derived.
|
|
64
64
|
*/
|
|
65
|
-
export type
|
|
65
|
+
export type SubsystemTrailMechanism =
|
|
66
66
|
| 'calls'
|
|
67
67
|
| 'uses'
|
|
68
68
|
| 'feeds'
|
|
@@ -74,9 +74,9 @@ export type SubsystemWalkthroughMechanism =
|
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* Edge mechanism used by derived display edges / styling. Display edges are
|
|
77
|
-
* derived from
|
|
77
|
+
* derived from trail steps, so this is the trail mechanism.
|
|
78
78
|
*/
|
|
79
|
-
export type SubsystemEdgeMechanism =
|
|
79
|
+
export type SubsystemEdgeMechanism = SubsystemTrailMechanism;
|
|
80
80
|
|
|
81
81
|
export type SubsystemDeclarationProvenance = 'verified' | 'authored';
|
|
82
82
|
|
|
@@ -267,7 +267,7 @@ export type SubsystemConstructDeclaration =
|
|
|
267
267
|
/** A component node — the named unit, construct-tagged. */
|
|
268
268
|
export interface SubsystemComponent {
|
|
269
269
|
/**
|
|
270
|
-
* Model-local stable alias. Referenced by
|
|
270
|
+
* Model-local stable alias. Referenced by trail `from` /
|
|
271
271
|
* `to`; unique per model. Edges point at the alias, not the location — a
|
|
272
272
|
* file move or symbol rename leaves edges intact. Code identity lives on
|
|
273
273
|
* `purl` + `file` + `symbol` and is what composed (multi-model) views
|
|
@@ -332,8 +332,8 @@ export interface SubsystemComponent {
|
|
|
332
332
|
}
|
|
333
333
|
|
|
334
334
|
/**
|
|
335
|
-
* Derived / display graph edge used by renderers. Built from
|
|
336
|
-
*
|
|
335
|
+
* Derived / display graph edge used by renderers. Built from trail
|
|
336
|
+
* steps — not authored as its own document field.
|
|
337
337
|
*/
|
|
338
338
|
export interface SubsystemComponentEdge {
|
|
339
339
|
id: string;
|
|
@@ -342,13 +342,13 @@ export interface SubsystemComponentEdge {
|
|
|
342
342
|
mechanism: SubsystemEdgeMechanism;
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
export interface
|
|
345
|
+
export interface SubsystemTrailStep {
|
|
346
346
|
/** Source component alias. */
|
|
347
347
|
from: string;
|
|
348
348
|
/** Target component alias. */
|
|
349
349
|
to: string;
|
|
350
350
|
/** Runtime seam label (Set B). */
|
|
351
|
-
mechanism:
|
|
351
|
+
mechanism: SubsystemTrailMechanism;
|
|
352
352
|
file: string;
|
|
353
353
|
/** 1-based line within `file`. */
|
|
354
354
|
line: number;
|
|
@@ -360,24 +360,24 @@ export interface SubsystemWalkthroughStep {
|
|
|
360
360
|
*/
|
|
361
361
|
purl: string;
|
|
362
362
|
/**
|
|
363
|
-
* Frame name for this
|
|
364
|
-
* Required: the
|
|
363
|
+
* Frame name for this step — the function/method on the stack at the site.
|
|
364
|
+
* Required: the Trails list shows this instead of a bare
|
|
365
365
|
* mechanism + filename fallback.
|
|
366
366
|
*/
|
|
367
367
|
symbol: string;
|
|
368
368
|
/**
|
|
369
|
-
* Free-text note anchored to this
|
|
369
|
+
* Free-text note anchored to this step's site line. Optional — informative
|
|
370
370
|
* only, never verified against source; viewers surface it via the codeview's
|
|
371
371
|
* annotation column.
|
|
372
372
|
*/
|
|
373
373
|
annotation?: string;
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
-
/** Ordered runtime
|
|
377
|
-
export interface
|
|
376
|
+
/** Ordered runtime trail (one named behavior story). */
|
|
377
|
+
export interface SubsystemTrail {
|
|
378
378
|
id: string;
|
|
379
379
|
title: string;
|
|
380
|
-
steps:
|
|
380
|
+
steps: SubsystemTrailStep[];
|
|
381
381
|
}
|
|
382
382
|
|
|
383
383
|
/**
|
|
@@ -402,8 +402,8 @@ export interface SubsystemModelDocument {
|
|
|
402
402
|
title: string;
|
|
403
403
|
description?: string;
|
|
404
404
|
components: SubsystemComponent[];
|
|
405
|
-
/** Runtime
|
|
406
|
-
|
|
405
|
+
/** Runtime trails (ordered steps with sites). */
|
|
406
|
+
trails?: SubsystemTrail[];
|
|
407
407
|
/**
|
|
408
408
|
* The commit each referenced repo was at when the model was created. The
|
|
409
409
|
* coordinate system for every `file:line` in the document: without it, a
|
|
@@ -460,13 +460,13 @@ export function toPortableDocument(
|
|
|
460
460
|
};
|
|
461
461
|
if (doc.$schema) out.$schema = doc.$schema;
|
|
462
462
|
if (doc.description) out.description = doc.description;
|
|
463
|
-
if (doc.
|
|
463
|
+
if (doc.trails) out.trails = doc.trails;
|
|
464
464
|
if (doc.createdAtCommits) out.createdAtCommits = doc.createdAtCommits;
|
|
465
465
|
if (doc.verifiedAtCommits) out.verifiedAtCommits = doc.verifiedAtCommits;
|
|
466
466
|
return out;
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
-
/** Stable id for a derived graph edge from a
|
|
469
|
+
/** Stable id for a derived graph edge from a trail step. */
|
|
470
470
|
export function derivedGraphEdgeId(
|
|
471
471
|
from: string,
|
|
472
472
|
to: string,
|
|
@@ -476,14 +476,14 @@ export function derivedGraphEdgeId(
|
|
|
476
476
|
}
|
|
477
477
|
|
|
478
478
|
/**
|
|
479
|
-
* Build display edges for the graph canvas from
|
|
479
|
+
* Build display edges for the graph canvas from trail steps
|
|
480
480
|
* (deduped by from/to/mechanism).
|
|
481
481
|
*/
|
|
482
482
|
export function deriveGraphEdges(doc: {
|
|
483
|
-
|
|
483
|
+
trails?: SubsystemTrail[];
|
|
484
484
|
}): SubsystemComponentEdge[] {
|
|
485
485
|
const byId = new Map<string, SubsystemComponentEdge>();
|
|
486
|
-
for (const w of doc.
|
|
486
|
+
for (const w of doc.trails ?? []) {
|
|
487
487
|
for (const step of w.steps) {
|
|
488
488
|
const id = derivedGraphEdgeId(step.from, step.to, step.mechanism);
|
|
489
489
|
if (!byId.has(id)) {
|
package/src/validation.test.ts
CHANGED
|
@@ -28,7 +28,7 @@ describe('validateSubsystemModelCrossField', () => {
|
|
|
28
28
|
test('accepts a consistent document', () => {
|
|
29
29
|
const d = doc({
|
|
30
30
|
components: [comp('a'), comp('b')],
|
|
31
|
-
|
|
31
|
+
trails: [
|
|
32
32
|
{
|
|
33
33
|
id: 'w1',
|
|
34
34
|
title: 'flow',
|
|
@@ -47,11 +47,11 @@ describe('validateSubsystemModelCrossField', () => {
|
|
|
47
47
|
expect(problems[0]!.message).toContain('duplicate alias');
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
test('flags
|
|
50
|
+
test('flags trail step endpoints that reference no component', () => {
|
|
51
51
|
const problems = validateSubsystemModelCrossField(
|
|
52
52
|
doc({
|
|
53
53
|
components: [comp('a')],
|
|
54
|
-
|
|
54
|
+
trails: [
|
|
55
55
|
{
|
|
56
56
|
id: 'w1',
|
|
57
57
|
title: 'flow',
|
|
@@ -61,14 +61,14 @@ describe('validateSubsystemModelCrossField', () => {
|
|
|
61
61
|
}),
|
|
62
62
|
);
|
|
63
63
|
expect(problems).toHaveLength(1);
|
|
64
|
-
expect(problems[0]!.path).toBe('/
|
|
64
|
+
expect(problems[0]!.path).toBe('/trails/0/steps/0/from');
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
test('flags
|
|
67
|
+
test('flags trail step purl fragments that mismatch the step file', () => {
|
|
68
68
|
const problems = validateSubsystemModelCrossField(
|
|
69
69
|
doc({
|
|
70
70
|
components: [comp('a')],
|
|
71
|
-
|
|
71
|
+
trails: [
|
|
72
72
|
{
|
|
73
73
|
id: 'w1',
|
|
74
74
|
title: 'flow',
|
|
@@ -78,7 +78,7 @@ describe('validateSubsystemModelCrossField', () => {
|
|
|
78
78
|
}),
|
|
79
79
|
);
|
|
80
80
|
expect(problems).toHaveLength(1);
|
|
81
|
-
expect(problems[0]!.path).toBe('/
|
|
81
|
+
expect(problems[0]!.path).toBe('/trails/0/steps/0/purl');
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
test('module implies file, exempting external/proposed', () => {
|
|
@@ -100,4 +100,77 @@ describe('validateSubsystemModelCrossField', () => {
|
|
|
100
100
|
),
|
|
101
101
|
).toEqual([]);
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
test('flags a component file pointing into node_modules', () => {
|
|
105
|
+
const problems = validateSubsystemModelCrossField(
|
|
106
|
+
doc({
|
|
107
|
+
components: [
|
|
108
|
+
comp('dep', {
|
|
109
|
+
construct: 'store',
|
|
110
|
+
file: 'packages/react/node_modules/@pierre/diffs/dist/highlighter/shared_highlighter.js',
|
|
111
|
+
}),
|
|
112
|
+
comp('bare', { file: 'node_modules/left-pad/index.js' }),
|
|
113
|
+
// An external may carry such a path too — the path is the defect
|
|
114
|
+
// whichever construct claims it.
|
|
115
|
+
comp('ext', {
|
|
116
|
+
construct: 'external',
|
|
117
|
+
file: 'packages/react/node_modules/@pierre/diffs/dist/components/CodeView.js',
|
|
118
|
+
}),
|
|
119
|
+
],
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
expect(problems).toHaveLength(3);
|
|
123
|
+
expect(problems[0]!.path).toBe('/components/0/file');
|
|
124
|
+
expect(problems[0]!.message).toContain('node_modules');
|
|
125
|
+
expect(problems[1]!.path).toBe('/components/1/file');
|
|
126
|
+
expect(problems[2]!.path).toBe('/components/2/file');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('accepts node_modules only as an external/proposed component identity', () => {
|
|
130
|
+
expect(
|
|
131
|
+
validateSubsystemModelCrossField(
|
|
132
|
+
doc({
|
|
133
|
+
components: [
|
|
134
|
+
// A dependency modeled as a package: no file, npm purl.
|
|
135
|
+
comp('dep', {
|
|
136
|
+
construct: 'external',
|
|
137
|
+
file: '',
|
|
138
|
+
purl: 'pkg:npm/@pierre/diffs',
|
|
139
|
+
}),
|
|
140
|
+
// A planned in-repo component may sit where it will live.
|
|
141
|
+
comp('plan', { file: 'packages/x/node_modules/y/z.ts', proposed: true }),
|
|
142
|
+
// `node_modulesx` is an ordinary directory, not the install root.
|
|
143
|
+
comp('ok', { file: 'packages/node_modulesx/z.ts' }),
|
|
144
|
+
],
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('flags a trail step anchored in node_modules', () => {
|
|
151
|
+
const problems = validateSubsystemModelCrossField(
|
|
152
|
+
doc({
|
|
153
|
+
components: [comp('a')],
|
|
154
|
+
trails: [
|
|
155
|
+
{
|
|
156
|
+
id: 'w1',
|
|
157
|
+
title: 'flow',
|
|
158
|
+
steps: [
|
|
159
|
+
{
|
|
160
|
+
from: 'a',
|
|
161
|
+
to: 'a',
|
|
162
|
+
mechanism: 'calls',
|
|
163
|
+
file: 'packages/react/node_modules/@pierre/diffs/dist/index.js',
|
|
164
|
+
line: 1,
|
|
165
|
+
purl: 'pkg:npm/@pierre/diffs#packages/react/node_modules/@pierre/diffs/dist/index.js',
|
|
166
|
+
symbol: 'a',
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
expect(problems).toHaveLength(1);
|
|
174
|
+
expect(problems[0]!.path).toBe('/trails/0/steps/0/file');
|
|
175
|
+
});
|
|
103
176
|
});
|
package/src/validation.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* These are the rules the JSON Schema (`schemas/subsystem-model.schema.json`)
|
|
5
5
|
* cannot express — anything that spans fields or arrays: alias uniqueness,
|
|
6
|
-
* referential integrity between
|
|
6
|
+
* referential integrity between trails and components, and the
|
|
7
7
|
* `module` implies `file` invariant. Structural checks (types, `required`,
|
|
8
8
|
* enums, ranges, closed objects) belong to the schema and are enforced per
|
|
9
9
|
* surface; this module owns only what the schema can't.
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
import type {
|
|
15
15
|
SubsystemModelDocument,
|
|
16
16
|
SubsystemComponent,
|
|
17
|
-
|
|
17
|
+
SubsystemTrail,
|
|
18
18
|
} from './types/subsystem-model';
|
|
19
19
|
|
|
20
20
|
export interface SubsystemValidationProblem {
|
|
21
|
-
/** JSON-pointer-ish location, e.g. `/components/2` or `/
|
|
21
|
+
/** JSON-pointer-ish location, e.g. `/components/2` or `/trails/0/steps/0/from`. */
|
|
22
22
|
path: string;
|
|
23
23
|
message: string;
|
|
24
24
|
}
|
|
@@ -27,6 +27,25 @@ function isUngrounded(c: SubsystemComponent): boolean {
|
|
|
27
27
|
return c.construct === 'external' || c.proposed === true;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* `file` is a path *inside the repo named by `purl`* — it resolves against that
|
|
32
|
+
* checkout, never against an installed artifact. `node_modules/` is installed,
|
|
33
|
+
* gitignored, and its layout depends on hoisting, so a claim anchored there can
|
|
34
|
+
* never resolve and is unverifiable by construction.
|
|
35
|
+
*
|
|
36
|
+
* Such a component is a third-party dependency: model it as `construct:
|
|
37
|
+
* 'external'` with `purl: 'pkg:npm/<package>'` and no file. Applies to externals
|
|
38
|
+
* too — they carry no file by design, so an install path there is dead weight
|
|
39
|
+
* that draws a link nothing can open. `proposed` is exempt like every other
|
|
40
|
+
* grounding rule: its file is a placeholder for something not placed yet.
|
|
41
|
+
*
|
|
42
|
+
* The same rule covers trail step files — a seam into an external belongs
|
|
43
|
+
* at the call site in the caller, which *is* in the repo.
|
|
44
|
+
*/
|
|
45
|
+
function mentionsNodeModules(path: string): boolean {
|
|
46
|
+
return /(^|\/)node_modules(\/|$)/.test(path);
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
/**
|
|
31
50
|
* Validate the cross-field rules of a subsystem model document. Returns an
|
|
32
51
|
* empty array when the document is consistent.
|
|
@@ -36,10 +55,10 @@ export function validateSubsystemModelCrossField(
|
|
|
36
55
|
): SubsystemValidationProblem[] {
|
|
37
56
|
const problems: SubsystemValidationProblem[] = [];
|
|
38
57
|
const components = doc.components ?? [];
|
|
39
|
-
const
|
|
58
|
+
const trails = doc.trails ?? [];
|
|
40
59
|
|
|
41
60
|
// Component aliases must be unique, and the set is the referential target
|
|
42
|
-
// for
|
|
61
|
+
// for trail steps.
|
|
43
62
|
const ids = new Set<string>();
|
|
44
63
|
components.forEach((c, i) => {
|
|
45
64
|
if (ids.has(c.alias)) {
|
|
@@ -60,20 +79,26 @@ export function validateSubsystemModelCrossField(
|
|
|
60
79
|
message: `component ${JSON.stringify(c.alias)}: module ${JSON.stringify(module)} is set but file is empty — a module frame needs a file to ground it (mark the component proposed if it is not placed yet).`,
|
|
61
80
|
});
|
|
62
81
|
}
|
|
82
|
+
if (file && c.proposed !== true && mentionsNodeModules(file)) {
|
|
83
|
+
problems.push({
|
|
84
|
+
path: `/components/${i}/file`,
|
|
85
|
+
message: `component ${JSON.stringify(c.alias)}: file ${JSON.stringify(file)} points into node_modules — installed artifacts are not part of the repo and cannot be verified. Model the dependency as construct "external" with purl "pkg:npm/<package>" and no file, or anchor the claim to the package's real source.`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
63
88
|
});
|
|
64
89
|
|
|
65
|
-
|
|
90
|
+
trails.forEach((w: SubsystemTrail, ti) => {
|
|
66
91
|
(w.steps ?? []).forEach((step, si) => {
|
|
67
92
|
if (!ids.has(step.from)) {
|
|
68
93
|
problems.push({
|
|
69
|
-
path: `/
|
|
70
|
-
message: `
|
|
94
|
+
path: `/trails/${ti}/steps/${si}/from`,
|
|
95
|
+
message: `trail ${JSON.stringify(w.id)}: step ${si} from ${JSON.stringify(step.from)} does not match any component alias`,
|
|
71
96
|
});
|
|
72
97
|
}
|
|
73
98
|
if (!ids.has(step.to)) {
|
|
74
99
|
problems.push({
|
|
75
|
-
path: `/
|
|
76
|
-
message: `
|
|
100
|
+
path: `/trails/${ti}/steps/${si}/to`,
|
|
101
|
+
message: `trail ${JSON.stringify(w.id)}: step ${si} to ${JSON.stringify(step.to)} does not match any component alias`,
|
|
77
102
|
});
|
|
78
103
|
}
|
|
79
104
|
// A file-anchored step purl names its own site: the fragment must be
|
|
@@ -83,11 +108,17 @@ export function validateSubsystemModelCrossField(
|
|
|
83
108
|
const fragment = step.purl.split('#').slice(1).join('#');
|
|
84
109
|
if (fragment !== step.file) {
|
|
85
110
|
problems.push({
|
|
86
|
-
path: `/
|
|
87
|
-
message: `
|
|
111
|
+
path: `/trails/${ti}/steps/${si}/purl`,
|
|
112
|
+
message: `trail ${JSON.stringify(w.id)}: step ${si} purl fragment ${JSON.stringify(fragment)} does not match step file ${JSON.stringify(step.file)}`,
|
|
88
113
|
});
|
|
89
114
|
}
|
|
90
115
|
}
|
|
116
|
+
if (mentionsNodeModules(step.file)) {
|
|
117
|
+
problems.push({
|
|
118
|
+
path: `/trails/${ti}/steps/${si}/file`,
|
|
119
|
+
message: `trail ${JSON.stringify(w.id)}: step ${si} file ${JSON.stringify(step.file)} points into node_modules — anchor the seam at the call site inside the repo instead.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
91
122
|
});
|
|
92
123
|
});
|
|
93
124
|
|