@principal-ai/subsystems-core 0.48.1 → 0.49.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.
@@ -113,13 +113,29 @@ export interface SubsystemDeclToken {
113
113
  color?: string;
114
114
  }
115
115
 
116
- /** Anchored declaration location (usually from verify tooling). */
117
- export interface SubsystemDeclarationRef {
118
- file: string;
116
+ /**
117
+ * A content-addressed range of source — the model's identity primitive.
118
+ *
119
+ * A span is `startLine` + `endLine` + the hash of the normalized content
120
+ * across that range. Every claim is *anchored* to a span: a component's
121
+ * construct declaration (`declarationSpan`) and a trail step's runtime seam
122
+ * (`span`) are the same shape. `file` / `purl` live on the owner, so a span is
123
+ * a pure range + fingerprint.
124
+ *
125
+ * The hash is the identity: if live content re-hashes to `contentHash`, the
126
+ * anchored code is byte-identical, which is what lets verification reason about
127
+ * constructs rather than line changes.
128
+ */
129
+ export interface SubsystemSourceSpan {
130
+ /** 1-based first line of the construct/seam within the owner's `file`. */
119
131
  startLine: number;
120
- lineHash: string;
121
- graphifyNodeId?: string;
122
- capturedAt: string;
132
+ /** 1-based last line, inclusive. Always `>= startLine`. */
133
+ endLine: number;
134
+ /**
135
+ * SHA-256 of the normalized content across `[startLine, endLine]`, truncated
136
+ * to 32 hex chars. Produced by `hashContentRange`.
137
+ */
138
+ contentHash: string;
123
139
  }
124
140
 
125
141
  export interface SubsystemParamInfo {
@@ -376,8 +392,12 @@ export interface SubsystemComponent {
376
392
  */
377
393
  declarationProvenance?: SubsystemDeclarationProvenance;
378
394
  tokens?: SubsystemDeclToken[];
379
- /** Location anchor (file/line/hash) — distinct from `declaration` (shape). */
380
- declarationRef?: SubsystemDeclarationRef;
395
+ /**
396
+ * The construct's source span (start/end/content hash) — its anchored
397
+ * identity. Distinct from `declaration`, which is the structured shape.
398
+ * Usually captured by verify tooling, not hand-authored.
399
+ */
400
+ declarationSpan?: SubsystemSourceSpan;
381
401
  }
382
402
 
383
403
  /**
@@ -399,8 +419,13 @@ export interface SubsystemTrailStep {
399
419
  /** Runtime seam label (Set B). */
400
420
  mechanism: SubsystemTrailMechanism;
401
421
  file: string;
402
- /** 1-based line within `file`. */
403
- line: number;
422
+ /**
423
+ * The seam's source span — the call-site range and its content hash. The
424
+ * trail-step counterpart of a component's `declarationSpan`, captured by
425
+ * verify tooling from the call site. `file` + `symbol` locate the frame;
426
+ * absent until verification anchors it.
427
+ */
428
+ span?: SubsystemSourceSpan;
404
429
  /**
405
430
  * File-anchored purl of the seam site (e.g.
406
431
  * `pkg:github/owner/name#path/to/file.ts`), mirroring component `purl`.
@@ -32,7 +32,7 @@ describe('validateSubsystemModelCrossField', () => {
32
32
  {
33
33
  id: 'w1',
34
34
  title: 'flow',
35
- steps: [{ from: 'a', to: 'b', mechanism: 'calls', file: 'src/a.ts', line: 1, purl: 'pkg:github/acme/app#src/a.ts', symbol: 'a' }],
35
+ steps: [{ from: 'a', to: 'b', mechanism: 'calls', file: 'src/a.ts', purl: 'pkg:github/acme/app#src/a.ts', symbol: 'a' }],
36
36
  },
37
37
  ],
38
38
  });
@@ -55,7 +55,7 @@ describe('validateSubsystemModelCrossField', () => {
55
55
  {
56
56
  id: 'w1',
57
57
  title: 'flow',
58
- steps: [{ from: 'ghost', to: 'a', mechanism: 'calls', file: 'src/a.ts', line: 1, purl: 'pkg:github/acme/app#src/a.ts', symbol: 'ghost' }],
58
+ steps: [{ from: 'ghost', to: 'a', mechanism: 'calls', file: 'src/a.ts', purl: 'pkg:github/acme/app#src/a.ts', symbol: 'ghost' }],
59
59
  },
60
60
  ],
61
61
  }),
@@ -72,7 +72,7 @@ describe('validateSubsystemModelCrossField', () => {
72
72
  {
73
73
  id: 'w1',
74
74
  title: 'flow',
75
- steps: [{ from: 'a', to: 'a', mechanism: 'calls', file: 'src/a.ts', line: 1, purl: 'pkg:github/acme/app#src/other.ts', symbol: 'a' }],
75
+ steps: [{ from: 'a', to: 'a', mechanism: 'calls', file: 'src/a.ts', purl: 'pkg:github/acme/app#src/other.ts', symbol: 'a' }],
76
76
  },
77
77
  ],
78
78
  }),
@@ -161,7 +161,6 @@ describe('validateSubsystemModelCrossField', () => {
161
161
  to: 'a',
162
162
  mechanism: 'calls',
163
163
  file: 'packages/react/node_modules/@pierre/diffs/dist/index.js',
164
- line: 1,
165
164
  purl: 'pkg:npm/@pierre/diffs#packages/react/node_modules/@pierre/diffs/dist/index.js',
166
165
  symbol: 'a',
167
166
  },
@@ -173,4 +172,68 @@ describe('validateSubsystemModelCrossField', () => {
173
172
  expect(problems).toHaveLength(1);
174
173
  expect(problems[0]!.path).toBe('/trails/0/steps/0/file');
175
174
  });
175
+
176
+ test('accepts an ordered span on a component and a step', () => {
177
+ expect(
178
+ validateSubsystemModelCrossField(
179
+ doc({
180
+ components: [
181
+ comp('a', {
182
+ declarationSpan: { startLine: 1, endLine: 9, contentHash: 'h' },
183
+ }),
184
+ ],
185
+ trails: [
186
+ {
187
+ id: 'w1',
188
+ title: 'flow',
189
+ steps: [
190
+ {
191
+ from: 'a',
192
+ to: 'a',
193
+ mechanism: 'calls',
194
+ file: 'src/a.ts',
195
+ purl: 'pkg:github/acme/app#src/a.ts',
196
+ symbol: 'a',
197
+ span: { startLine: 3, endLine: 3, contentHash: 'h' },
198
+ },
199
+ ],
200
+ },
201
+ ],
202
+ }),
203
+ ),
204
+ ).toEqual([]);
205
+ });
206
+
207
+ test('flags a span whose endLine precedes startLine', () => {
208
+ const problems = validateSubsystemModelCrossField(
209
+ doc({
210
+ components: [
211
+ comp('a', {
212
+ declarationSpan: { startLine: 10, endLine: 4, contentHash: 'h' },
213
+ }),
214
+ ],
215
+ trails: [
216
+ {
217
+ id: 'w1',
218
+ title: 'flow',
219
+ steps: [
220
+ {
221
+ from: 'a',
222
+ to: 'a',
223
+ mechanism: 'calls',
224
+ file: 'src/a.ts',
225
+ purl: 'pkg:github/acme/app#src/a.ts',
226
+ symbol: 'a',
227
+ span: { startLine: 8, endLine: 2, contentHash: 'h' },
228
+ },
229
+ ],
230
+ },
231
+ ],
232
+ }),
233
+ );
234
+ expect(problems).toHaveLength(2);
235
+ expect(problems[0]!.path).toBe('/components/0/declarationSpan');
236
+ expect(problems[0]!.message).toContain('precedes startLine');
237
+ expect(problems[1]!.path).toBe('/trails/0/steps/0/span');
238
+ });
176
239
  });
package/src/validation.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  import type {
15
15
  SubsystemModelDocument,
16
16
  SubsystemComponent,
17
+ SubsystemSourceSpan,
17
18
  SubsystemTrail,
18
19
  } from './types/subsystem-model';
19
20
 
@@ -53,6 +54,27 @@ function mentionsNodeModules(path: string): boolean {
53
54
  return /(^|\/)node_modules(\/|$)/.test(path);
54
55
  }
55
56
 
57
+ /**
58
+ * A span's range must be ordered: `endLine >= startLine`. The schema enforces
59
+ * that each line is a positive integer but cannot compare two fields, so the
60
+ * ordering rule lives here. `owner` prefixes the message (e.g.
61
+ * `component "save"`).
62
+ */
63
+ function spanRangeProblem(
64
+ span: SubsystemSourceSpan | undefined,
65
+ path: string,
66
+ owner: string,
67
+ ): SubsystemValidationProblem | null {
68
+ if (!span) return null;
69
+ if (span.endLine < span.startLine) {
70
+ return {
71
+ path,
72
+ message: `${owner}: span endLine ${span.endLine} precedes startLine ${span.startLine} — a span range must be ordered (endLine >= startLine).`,
73
+ };
74
+ }
75
+ return null;
76
+ }
77
+
56
78
  /**
57
79
  * Validate the cross-field rules of a subsystem model document. Returns an
58
80
  * empty array when the document is consistent.
@@ -97,6 +119,12 @@ export function validateSubsystemModelCrossField(
97
119
  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.`,
98
120
  });
99
121
  }
122
+ const spanProblem = spanRangeProblem(
123
+ c.declarationSpan,
124
+ `/components/${i}/declarationSpan`,
125
+ `component ${JSON.stringify(c.alias)}`,
126
+ );
127
+ if (spanProblem) problems.push(spanProblem);
100
128
  });
101
129
 
102
130
  trails.forEach((w: SubsystemTrail, ti) => {
@@ -131,6 +159,12 @@ export function validateSubsystemModelCrossField(
131
159
  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.`,
132
160
  });
133
161
  }
162
+ const spanProblem = spanRangeProblem(
163
+ step.span,
164
+ `/trails/${ti}/steps/${si}/span`,
165
+ `trail ${JSON.stringify(w.id)}: step ${si}`,
166
+ );
167
+ if (spanProblem) problems.push(spanProblem);
134
168
  });
135
169
  });
136
170