@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.
Files changed (67) hide show
  1. package/package.json +1 -1
  2. package/schemas/subsystem-model.schema.json +18 -16
  3. package/src/storage/topicStore.test.ts +0 -19
  4. package/src/storage/topicStore.ts +11 -63
  5. package/src/types/subsystem-model.ts +23 -23
  6. package/src/validation.test.ts +80 -7
  7. package/src/validation.ts +43 -12
  8. package/dist/agent-sessions/fixture.d.ts +0 -80
  9. package/dist/agent-sessions/fixture.d.ts.map +0 -1
  10. package/dist/agent-sessions/fixture.js +0 -130
  11. package/dist/agent-sessions/fixture.js.map +0 -1
  12. package/dist/agent-sessions/index.d.ts +0 -3
  13. package/dist/agent-sessions/index.d.ts.map +0 -1
  14. package/dist/agent-sessions/index.js +0 -6
  15. package/dist/agent-sessions/index.js.map +0 -1
  16. package/dist/index.d.ts +0 -16
  17. package/dist/index.d.ts.map +0 -1
  18. package/dist/index.js +0 -36
  19. package/dist/index.js.map +0 -1
  20. package/dist/node.d.ts +0 -22
  21. package/dist/node.d.ts.map +0 -1
  22. package/dist/node.js +0 -55
  23. package/dist/node.js.map +0 -1
  24. package/dist/opencode/OpenCodeEventStore.d.ts +0 -25
  25. package/dist/opencode/OpenCodeEventStore.d.ts.map +0 -1
  26. package/dist/opencode/OpenCodeEventStore.js +0 -182
  27. package/dist/opencode/OpenCodeEventStore.js.map +0 -1
  28. package/dist/opencode/agent-sessions.d.ts +0 -46
  29. package/dist/opencode/agent-sessions.d.ts.map +0 -1
  30. package/dist/opencode/agent-sessions.js +0 -302
  31. package/dist/opencode/agent-sessions.js.map +0 -1
  32. package/dist/opencode/index.d.ts +0 -6
  33. package/dist/opencode/index.d.ts.map +0 -1
  34. package/dist/opencode/index.js +0 -15
  35. package/dist/opencode/index.js.map +0 -1
  36. package/dist/opencode/node-path-adapter.d.ts +0 -25
  37. package/dist/opencode/node-path-adapter.d.ts.map +0 -1
  38. package/dist/opencode/node-path-adapter.js +0 -188
  39. package/dist/opencode/node-path-adapter.js.map +0 -1
  40. package/dist/opencode/pipeline.d.ts +0 -42
  41. package/dist/opencode/pipeline.d.ts.map +0 -1
  42. package/dist/opencode/pipeline.js +0 -90
  43. package/dist/opencode/pipeline.js.map +0 -1
  44. package/dist/opencode/types.d.ts +0 -39
  45. package/dist/opencode/types.d.ts.map +0 -1
  46. package/dist/opencode/types.js +0 -3
  47. package/dist/opencode/types.js.map +0 -1
  48. package/dist/storage/topic-types.d.ts +0 -203
  49. package/dist/storage/topic-types.d.ts.map +0 -1
  50. package/dist/storage/topic-types.js +0 -60
  51. package/dist/storage/topic-types.js.map +0 -1
  52. package/dist/storage/topicStore.d.ts +0 -135
  53. package/dist/storage/topicStore.d.ts.map +0 -1
  54. package/dist/storage/topicStore.js +0 -389
  55. package/dist/storage/topicStore.js.map +0 -1
  56. package/dist/types/index.d.ts +0 -7
  57. package/dist/types/index.d.ts.map +0 -1
  58. package/dist/types/index.js +0 -23
  59. package/dist/types/index.js.map +0 -1
  60. package/dist/types/subsystem-model.d.ts +0 -388
  61. package/dist/types/subsystem-model.d.ts.map +0 -1
  62. package/dist/types/subsystem-model.js +0 -87
  63. package/dist/types/subsystem-model.js.map +0 -1
  64. package/dist/validation.d.ts +0 -24
  65. package/dist/validation.d.ts.map +0 -1
  66. package/dist/validation.js +0 -82
  67. package/dist/validation.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/subsystems-core",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Subsystem model types and agent-session tooling for Principal AI",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -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 walkthroughs 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.",
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
- "walkthroughs": {
33
+ "trails": {
34
34
  "type": "array",
35
- "description": "Ordered runtime walkthroughs (one per named behavior). Each step names from/to/mechanism and the concrete file:line where that seam fires.",
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/walkthrough"
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": "Walkthrough hop 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.",
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 walkthrough `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."
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
- "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."
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
- "walkthroughStep": {
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
- "description": "Repo-root-relative path where the seam fires for this walkthrough."
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 hop — the function/method on the stack at the site. Required; the Walkthroughs list shows this instead of a bare mechanism + filename fallback."
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 hop's site line. Optional, informative only, never verified against source; viewers surface it in the codeview's annotation column."
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
- "walkthrough": {
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 walkthrough id."
275
+ "description": "Stable unique trail id."
274
276
  },
275
277
  "title": {
276
278
  "type": "string",
277
279
  "minLength": 1,
278
- "description": "Walkthrough name (e.g. save, load, refresh)."
280
+ "description": "Trail name (e.g. save, load, refresh)."
279
281
  },
280
282
  "steps": {
281
283
  "type": "array",
282
- "description": "Ordered hops; array order is execution order.",
284
+ "description": "Ordered steps; array order is execution order.",
283
285
  "items": {
284
- "$ref": "#/$defs/walkthroughStep"
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 (mirrors the trail store at `~/.principal/trails/`, so topics become
5
- * locally greppable and an agent can read one directly):
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 of trails spanning
12
- * repos), so — unlike trails, which bucket by repo Purl — they are stored flat
13
- * by id. No purl, no buckets, no `node:os`/Purl dependency beyond `homedir()`.
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. The trail store caps trails per repo
20
- * (`PER_REPO_CAP`); topics are few, user-curated, and must never be silently
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 not updatable here — use the trail-membership methods for
129
- * `trailIds`, and `id`/`createdAt` are immutable.
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 (mirrors the trail store's `migrateLegacyIfPresent`).
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
- * Walkthrough hop mechanism — runtime seams with a `file:line` site.
63
- * Belongs on walkthrough steps; graph edges for these are derived.
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 SubsystemWalkthroughMechanism =
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 walkthrough hops, so this is the walkthrough mechanism.
77
+ * derived from trail steps, so this is the trail mechanism.
78
78
  */
79
- export type SubsystemEdgeMechanism = SubsystemWalkthroughMechanism;
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 walkthrough `from` /
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 walkthrough
336
- * hops — not authored as its own document field.
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 SubsystemWalkthroughStep {
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: SubsystemWalkthroughMechanism;
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 hop — the function/method on the stack at the site.
364
- * Required: the Walkthroughs list shows this instead of a bare
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 hop's site line. Optional — informative
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 walkthrough (one named behavior story). */
377
- export interface SubsystemWalkthrough {
376
+ /** Ordered runtime trail (one named behavior story). */
377
+ export interface SubsystemTrail {
378
378
  id: string;
379
379
  title: string;
380
- steps: SubsystemWalkthroughStep[];
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 walkthroughs (ordered hops with sites). */
406
- walkthroughs?: SubsystemWalkthrough[];
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.walkthroughs) out.walkthroughs = doc.walkthroughs;
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 walkthrough hop. */
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 walkthrough hops
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
- walkthroughs?: SubsystemWalkthrough[];
483
+ trails?: SubsystemTrail[];
484
484
  }): SubsystemComponentEdge[] {
485
485
  const byId = new Map<string, SubsystemComponentEdge>();
486
- for (const w of doc.walkthroughs ?? []) {
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)) {
@@ -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
- walkthroughs: [
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 walkthrough step endpoints that reference no component', () => {
50
+ test('flags trail step endpoints that reference no component', () => {
51
51
  const problems = validateSubsystemModelCrossField(
52
52
  doc({
53
53
  components: [comp('a')],
54
- walkthroughs: [
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('/walkthroughs/0/steps/0/from');
64
+ expect(problems[0]!.path).toBe('/trails/0/steps/0/from');
65
65
  });
66
66
 
67
- test('flags walkthrough step purl fragments that mismatch the step file', () => {
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
- walkthroughs: [
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('/walkthroughs/0/steps/0/purl');
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 walkthroughs and components, and the
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
- SubsystemWalkthrough,
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 `/walkthroughs/0/steps/0/from`. */
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 walkthroughs = doc.walkthroughs ?? [];
58
+ const trails = doc.trails ?? [];
40
59
 
41
60
  // Component aliases must be unique, and the set is the referential target
42
- // for walkthrough steps.
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
- walkthroughs.forEach((w: SubsystemWalkthrough, wi) => {
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: `/walkthroughs/${wi}/steps/${si}/from`,
70
- message: `walkthrough ${JSON.stringify(w.id)}: step ${si} from ${JSON.stringify(step.from)} does not match any component alias`,
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: `/walkthroughs/${wi}/steps/${si}/to`,
76
- message: `walkthrough ${JSON.stringify(w.id)}: step ${si} to ${JSON.stringify(step.to)} does not match any component alias`,
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: `/walkthroughs/${wi}/steps/${si}/purl`,
87
- message: `walkthrough ${JSON.stringify(w.id)}: step ${si} purl fragment ${JSON.stringify(fragment)} does not match step file ${JSON.stringify(step.file)}`,
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