@transitrix/diagrams 1.15.0 → 1.16.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.
Files changed (44) hide show
  1. package/dist/compliance/catalogue.d.ts +17 -0
  2. package/dist/compliance/catalogue.js +68 -0
  3. package/dist/compliance/index.d.ts +4 -2
  4. package/dist/compliance/index.js +2 -1
  5. package/dist/compliance/requirement-chain.d.ts +13 -2
  6. package/dist/compliance/requirement-chain.js +60 -33
  7. package/dist/goals/resolver.d.ts +1 -0
  8. package/dist/goals/resolver.js +44 -2
  9. package/dist/products/types.d.ts +1 -1
  10. package/dist/products/validate.js +1 -1
  11. package/dist/repo-validate/check-strategy-chain.js +58 -23
  12. package/dist/standalone/validate.js +64 -3
  13. package/dist/webview/render-activities.d.ts +6 -0
  14. package/dist/webview/render-activities.js +2 -2
  15. package/dist/webview/render-activity-card.d.ts +6 -0
  16. package/dist/webview/render-activity-card.js +2 -2
  17. package/dist/webview/render-blocks.d.ts +10 -0
  18. package/dist/webview/render-blocks.js +4 -4
  19. package/dist/webview/render-fgca.d.ts +6 -0
  20. package/dist/webview/render-fgca.js +2 -2
  21. package/dist/webview/render-goals.d.ts +5 -0
  22. package/dist/webview/render-goals.js +9 -2
  23. package/dist/webview/render-process-blueprint.d.ts +6 -0
  24. package/dist/webview/render-process-blueprint.js +2 -2
  25. package/dist/webview/render-products.js +1 -0
  26. package/dist/yaml-normalize.d.ts +3 -1
  27. package/dist/yaml-normalize.js +11 -3
  28. package/package.json +1 -1
  29. package/src/compliance/catalogue.ts +49 -0
  30. package/src/compliance/index.ts +5 -2
  31. package/src/compliance/requirement-chain.ts +52 -27
  32. package/src/goals/resolver.ts +46 -2
  33. package/src/products/types.ts +1 -1
  34. package/src/products/validate.ts +1 -1
  35. package/src/repo-validate/check-strategy-chain.ts +49 -23
  36. package/src/standalone/validate.ts +38 -2
  37. package/src/webview/render-activities.ts +8 -2
  38. package/src/webview/render-activity-card.ts +8 -3
  39. package/src/webview/render-blocks.ts +14 -4
  40. package/src/webview/render-fgca.ts +8 -2
  41. package/src/webview/render-goals.ts +21 -2
  42. package/src/webview/render-process-blueprint.ts +8 -3
  43. package/src/webview/render-products.ts +1 -0
  44. package/src/yaml-normalize.ts +12 -3
@@ -0,0 +1,17 @@
1
+ /** Relative POSIX paths keep scanning policy and content identity independent of host APIs. */
2
+ export interface RequirementCatalogueIO {
3
+ read(path: string): Promise<string>;
4
+ list(path: string): Promise<Array<{
5
+ name: string;
6
+ kind: 'file' | 'directory' | 'symlink' | 'other';
7
+ }>>;
8
+ missing(error: unknown): boolean;
9
+ parse(content: string): unknown;
10
+ digest(content: string): string;
11
+ sourcePath?(path: string): string;
12
+ }
13
+ /** Shared disk/editor policy: one manifest boundary, all three zones, lossless failures. */
14
+ export declare function scanRequirementCatalogue(io: RequirementCatalogueIO): Promise<{
15
+ canon: import("./classify.js").ComplianceCanon;
16
+ snapshotId: string;
17
+ }>;
@@ -0,0 +1,68 @@
1
+ import { emptyCanon, ingestComplianceDoc } from './classify.js';
2
+ /** Shared disk/editor policy: one manifest boundary, all three zones, lossless failures. */
3
+ export async function scanRequirementCatalogue(io) {
4
+ const canon = emptyCanon();
5
+ const transcript = [];
6
+ const manifest = await io.read('transitrix.yaml');
7
+ const parsed = io.parse(manifest);
8
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
9
+ throw new Error('Invalid transitrix.yaml manifest: expected a mapping');
10
+ transcript.push(['transitrix.yaml', manifest]);
11
+ const fail = (path, message) => {
12
+ transcript.push(['failure', path, message]);
13
+ canon.findings.push({ id: `${path}:load`, owner: path, field: '', code: 'LOAD', message, reference: false, severity: 'error' });
14
+ };
15
+ const walk = async (dir, top = false) => {
16
+ let entries;
17
+ try {
18
+ entries = await io.list(dir);
19
+ }
20
+ catch (error) {
21
+ if (top && io.missing(error)) {
22
+ transcript.push(['absent-zone', dir]);
23
+ return;
24
+ }
25
+ fail(dir, 'Directory could not be enumerated');
26
+ return;
27
+ }
28
+ if (!top && entries.some(e => e.name === 'transitrix.yaml')) {
29
+ transcript.push(['catalogue-boundary', dir]);
30
+ return;
31
+ }
32
+ for (const { name, kind } of entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
33
+ const path = `${dir}/${name}`;
34
+ if (kind === 'symlink') {
35
+ fail(path, 'Symbolic link was not enumerated');
36
+ continue;
37
+ }
38
+ if (kind === 'directory') {
39
+ await walk(path);
40
+ continue;
41
+ }
42
+ if (!/\.ya?ml$/i.test(name))
43
+ continue;
44
+ if (kind !== 'file') {
45
+ fail(path, 'Unsupported file type');
46
+ continue;
47
+ }
48
+ let content;
49
+ try {
50
+ content = await io.read(path);
51
+ }
52
+ catch {
53
+ fail(path, 'Unreadable YAML record');
54
+ continue;
55
+ }
56
+ transcript.push([path, content]);
57
+ try {
58
+ ingestComplianceDoc(canon, io.parse(content), io.sourcePath?.(path) ?? path);
59
+ }
60
+ catch {
61
+ fail(path, 'Malformed YAML record');
62
+ }
63
+ }
64
+ };
65
+ for (const zone of ['canon', 'codex', 'field'])
66
+ await walk(zone, true);
67
+ return { canon, snapshotId: `sha256:${io.digest(JSON.stringify(transcript))}` };
68
+ }
@@ -19,6 +19,8 @@ export { assertionToScoringElement, complianceConfidenceHeader, requirementToSco
19
19
  export { parseCoverageMetricConfig, buildCoverageMatrix, collectCoverageViewResolutionFindings, } from './coverage-metric.js';
20
20
  export type { CoverageMetricConfig, CoverageMetricRegimes, CoverageMetricRegimesFilter, CoverageMetricSubjects, CoverageMetricThresholds, CoverageMatrix, CoverageRow, CoverageViewFinding, ParseCoverageMetricResult, RagStatus, } from './coverage-metric.js';
21
21
  export type { ComplianceIndex, ComplianceIndexInput, DeadlineStatus, IndexRequirement, IndexAssertion, IndexVerification, IndexNeed, IndexValidation, LawTree, LawTreeRequirement, ProductView, ProductRequirementStatus, ObjectDetailDef, ObjectDetailInput, RequirementTrace, TraceAssertionRow, TraceVerificationRow, TraceElementCatalog, TraceElementRef, TraceSourceRef, } from './types.js';
22
- export { buildRequirementChain, requirementReleaseCounts, selectRequirementChain, RequirementChainSnapshot, CHAIN_STAGES, chainDate } from './requirement-chain.js';
23
- export type { ChainInput, ChainScope, ChainSet, RequirementChainNode, RequirementChainEdge, RequirementChainProjection } from './requirement-chain.js';
22
+ export { buildSubjectRequirementChain, buildRequirementChain, requirementReleaseCounts, selectRequirementChain, RequirementChainSnapshot, CHAIN_STAGES, chainDate } from './requirement-chain.js';
23
+ export type { ChainSubject, ChainInput, ChainScope, ChainSet, RequirementChainNode, RequirementChainEdge, RequirementChainProjection } from './requirement-chain.js';
24
24
  export type { ChainRecord, ChainFinding } from './types.js';
25
+ export { scanRequirementCatalogue } from './catalogue.js';
26
+ export type { RequirementCatalogueIO } from './catalogue.js';
@@ -11,4 +11,5 @@ export { renderComplianceHtml, renderImpactMatrixHtml } from './html.js';
11
11
  export { buildImpactMatrix, renderImpactMarkdown, parseImpactViewConfig, collectImpactViewResolutionFindings, COMPLIANCE_IMPACT_DEFAULTS, extractObjectDetails, extractProcessFlowTasks, mergeStageTaskDetails, computeDeadlineStatus } from './impact.js';
12
12
  export { assertionToScoringElement, complianceConfidenceHeader, requirementToScoringElement, scoreComplianceView, } from './confidence.js';
13
13
  export { parseCoverageMetricConfig, buildCoverageMatrix, collectCoverageViewResolutionFindings, } from './coverage-metric.js';
14
- export { buildRequirementChain, requirementReleaseCounts, selectRequirementChain, RequirementChainSnapshot, CHAIN_STAGES, chainDate } from './requirement-chain.js';
14
+ export { buildSubjectRequirementChain, buildRequirementChain, requirementReleaseCounts, selectRequirementChain, RequirementChainSnapshot, CHAIN_STAGES, chainDate } from './requirement-chain.js';
15
+ export { scanRequirementCatalogue } from './catalogue.js';
@@ -1,7 +1,12 @@
1
1
  import type { ChainRecord, ChainFinding, ComplianceIndex } from './types.js';
2
2
  export declare const CHAIN_STAGES: readonly ["Source document", "Driver", "Need", "Stakeholder requirement", "System requirement", "Software requirement", "Unclassified requirement", "Verification definition", "Result"];
3
+ export interface ChainSubject {
4
+ type: 'PRODUCT' | 'APPLICATION';
5
+ id: string;
6
+ }
3
7
  export interface ChainScope {
4
8
  catalogue: string;
9
+ subject?: ChainSubject;
5
10
  product?: string;
6
11
  release?: string;
7
12
  project?: string;
@@ -46,7 +51,7 @@ export interface ChainAssignment {
46
51
  relations: string[];
47
52
  }
48
53
  export interface RequirementChainProjection {
49
- contract: 'requirement-chain/0.2';
54
+ contract: 'requirement-chain/0.2' | 'requirement-chain/0.3';
50
55
  contractRevision: string;
51
56
  asAt: string;
52
57
  snapshotId: string;
@@ -89,8 +94,14 @@ export interface RequirementChainProjection {
89
94
  executions: Record<string, string[]>;
90
95
  }
91
96
  export declare function chainDate(v: unknown): v is string;
92
- /** Pure, lossless release projection; no legacy coverage verdict is reinterpreted. */
97
+ /** Explicit PRODUCT compatibility adapter for requirement-chain/0.2. */
93
98
  export declare function buildRequirementChain(input: ChainInput): RequirementChainProjection;
99
+ /** Subject-aware 0.3 entry point; both versions use the same deterministic projection. */
100
+ export declare function buildSubjectRequirementChain(input: ChainInput & {
101
+ scope: ChainScope & {
102
+ subject: ChainSubject;
103
+ };
104
+ }): RequirementChainProjection;
94
105
  /** Both is two independent walks, never an expansion from the union frontier. */
95
106
  export declare function selectRequirementChain(p: RequirementChainProjection, focus?: string, direction?: 'upstream' | 'downstream' | 'both'): {
96
107
  nodes: RequirementChainNode[];
@@ -26,10 +26,28 @@ function freeze(value) {
26
26
  }
27
27
  const CODEX = ['LAW', 'REGULATION', 'STANDARD', 'POLICY', 'INTERNAL_STANDARD', 'PRINCIPLE'];
28
28
  const FIELD = ['INTERVIEW', 'SURVEY', 'OBSERVATION', 'DRAFT'];
29
- /** Pure, lossless release projection; no legacy coverage verdict is reinterpreted. */
29
+ /** Explicit PRODUCT compatibility adapter for requirement-chain/0.2. */
30
30
  export function buildRequirementChain(input) {
31
+ if (input.scope.subject)
32
+ throw new Error('Use buildSubjectRequirementChain for explicit subjects');
33
+ return projectRequirementChain(input);
34
+ }
35
+ /** Subject-aware 0.3 entry point; both versions use the same deterministic projection. */
36
+ export function buildSubjectRequirementChain(input) {
37
+ if (input.scope.product !== undefined)
38
+ throw new Error('Conflicting product and subject selectors');
39
+ if (!['PRODUCT', 'APPLICATION'].includes(input.scope.subject.type))
40
+ throw new Error('Unsupported subject type');
41
+ return projectRequirementChain(input);
42
+ }
43
+ /** Pure, lossless release projection; no legacy coverage verdict is reinterpreted. */
44
+ function projectRequirementChain(input) {
31
45
  var _a;
32
46
  const { index, asAt, scope } = input;
47
+ const subject = scope.subject ?? { type: 'PRODUCT', id: scope.product };
48
+ const membershipKind = subject.type === 'APPLICATION' ? 'application_scope' : 'product_scope';
49
+ const pairKind = subject.type === 'APPLICATION' ? 'project_application' : 'project_product';
50
+ const subjectWord = scope.subject ? 'subject' : 'product';
33
51
  const findings = structuredClone(index.findings);
34
52
  const scopeFindings = [];
35
53
  const records = new Map();
@@ -39,7 +57,7 @@ export function buildRequirementChain(input) {
39
57
  if (!findings.some(f => f.id === id && f.code === code))
40
58
  findings.push({ id, owner, field, code, message, reference, severity: warning ? 'warning' : 'error' });
41
59
  };
42
- for (const [id, versions] of index.recordsById) {
60
+ for (const [id, versions] of [...index.recordsById].sort(([a], [b]) => a.localeCompare(b))) {
43
61
  if (versions.length !== 1) {
44
62
  finding(id, 'id', 'DUPLICATE', 'Ambiguous catalogue identity');
45
63
  complete = false;
@@ -109,7 +127,7 @@ export function buildRequirementChain(input) {
109
127
  node(r, 2);
110
128
  else if (r.type === 'DRIVER')
111
129
  node(r, 1);
112
- else if (['PRODUCT', 'RELEASE', 'ACTION'].includes(r.type))
130
+ else if (['PRODUCT', 'APPLICATION', 'RELEASE', 'ACTION'].includes(r.type))
113
131
  node(r, -1);
114
132
  else if (CODEX.includes(r.type) || FIELD.includes(r.type)) {
115
133
  node(r, 0);
@@ -165,13 +183,15 @@ export function buildRequirementChain(input) {
165
183
  const src = records.get(from), dst = records.get(to);
166
184
  const endpoints = kind === 'requirement_parent' ? is(from, 'REQUIREMENT') && is(to, 'REQUIREMENT') :
167
185
  kind === 'serves' ? is(from, 'REQUIREMENT') && is(to, 'NEED') :
168
- kind === 'product_scope' ? is(from, 'REQUIREMENT') && is(to, 'PRODUCT') :
169
- kind === 'project_scope' ? is(from, 'REQUIREMENT') && isProject(to) :
170
- kind === 'project_product' ? isProject(from) && is(to, 'PRODUCT') :
171
- kind === 'required_for' ? is(from, 'REQUIREMENT') && is(to, 'RELEASE') :
172
- kind === 'source_trace' ? !!src && !!dst && envelope.get(from) === true && envelope.get(to) === true &&
173
- (['REQUIREMENT', 'NEED'].includes(src.type) && (dst.type === 'DRIVER' || FIELD.includes(dst.type)) ||
174
- src.type === 'DRIVER' && FIELD.includes(dst.type)) : !!src && !!dst;
186
+ kind === 'application_scope' ? is(from, 'REQUIREMENT') && is(to, 'APPLICATION') :
187
+ kind === 'project_application' ? isProject(from) && is(to, 'APPLICATION') :
188
+ kind === 'product_scope' ? is(from, 'REQUIREMENT') && is(to, 'PRODUCT') :
189
+ kind === 'project_scope' ? is(from, 'REQUIREMENT') && isProject(to) :
190
+ kind === 'project_product' ? isProject(from) && is(to, 'PRODUCT') :
191
+ kind === 'required_for' ? is(from, 'REQUIREMENT') && is(to, 'RELEASE') :
192
+ kind === 'source_trace' ? !!src && !!dst && envelope.get(from) === true && envelope.get(to) === true &&
193
+ (['REQUIREMENT', 'NEED'].includes(src.type) && (dst.type === 'DRIVER' || FIELD.includes(dst.type)) ||
194
+ src.type === 'DRIVER' && FIELD.includes(dst.type)) : !!src && !!dst;
175
195
  const temporal = contains(r, src) && contains(r, dst);
176
196
  if (!temporal)
177
197
  finding(r.id, 'to', 'REL-003', 'Relation window must be contained in both endpoints', true);
@@ -207,38 +227,38 @@ export function buildRequirementChain(input) {
207
227
  }
208
228
  }
209
229
  const memberships = (id, kind) => rels.filter(r => usable.has(r.id) && r.raw.type === kind && r.raw.from === id).map(r => String(r.raw.to));
210
- const unresolved = requirements.filter(r => active.get(r.id) && memberships(r.id, 'product_scope').length === 0).map(r => r.id);
211
- const P = requirements.filter(r => active.get(r.id) && memberships(r.id, 'product_scope').includes(scope.product ?? '')).map(r => r.id);
230
+ const unresolved = requirements.filter(r => active.get(r.id) && memberships(r.id, 'product_scope').length === 0 && (!scope.subject || memberships(r.id, 'application_scope').length === 0)).map(r => r.id);
231
+ const P = requirements.filter(r => active.get(r.id) && memberships(r.id, membershipKind).includes(subject.id ?? '')).map(r => r.id);
212
232
  let productComplete = complete && unresolved.length === 0;
213
233
  if (unresolved.length)
214
- scopeFindings.push('Unresolved product membership');
215
- if (requirements.some(r => !envelope.get(r.id)) || rels.some(r => r.raw.type === 'product_scope' && active.get(r.id) && !usable.has(r.id)))
234
+ scopeFindings.push(`Unresolved ${subjectWord} membership`);
235
+ if (requirements.some(r => !envelope.get(r.id)) || rels.some(r => r.raw.type === membershipKind && active.get(r.id) && !usable.has(r.id)))
216
236
  productComplete = false;
217
237
  const invalidAssignments = new Set();
218
238
  for (const r of rels.filter(r => r.raw.type === 'required_for')) {
219
239
  const from = String(r.raw.from), to = records.get(String(r.raw.to));
220
240
  const historical = chainDate(r.raw.valid_to) && r.raw.valid_to < asAt;
221
241
  const future = chainDate(r.raw.valid_from) && r.raw.valid_from > asAt;
222
- if (!historical && !future && (!usable.has(r.id) || !memberships(from, 'product_scope').includes(String(to?.raw.of)))) {
242
+ if (!historical && !future && (!usable.has(r.id) || ![...memberships(from, 'product_scope'), ...(scope.subject ? memberships(from, 'application_scope') : [])].includes(String(to?.raw.of)))) {
223
243
  invalidAssignments.add(from);
224
- finding(r.id, 'to', 'ASSIGNMENT', 'Invalid, dangling or wrong-product assignment', true);
244
+ finding(r.id, 'to', 'ASSIGNMENT', `Invalid, dangling or wrong-${subjectWord} assignment`, true);
225
245
  if (!envelope.get(r.id))
226
246
  productComplete = false;
227
247
  }
228
248
  }
229
249
  let selectedComplete = complete;
230
- if (!is(scope.product, 'PRODUCT')) {
250
+ if (!is(subject.id, subject.type)) {
231
251
  productComplete = false;
232
252
  selectedComplete = false;
233
- scopeFindings.push('Product unselected or unresolved');
253
+ scopeFindings.push(scope.subject ? 'Subject unselected or unresolved' : 'Product unselected or unresolved');
234
254
  }
235
- if (!is(scope.release, 'RELEASE') || records.get(scope.release ?? '')?.raw.of !== scope.product) {
255
+ if (!is(scope.release, 'RELEASE') || records.get(scope.release ?? '')?.raw.of !== subject.id) {
236
256
  selectedComplete = false;
237
- scopeFindings.push('Release unselected, unresolved or wrong product');
257
+ scopeFindings.push(`Release unselected, unresolved or wrong ${subjectWord}`);
238
258
  }
239
- if (scope.project && (!isProject(scope.project) || !memberships(scope.project, 'project_product').includes(scope.product ?? ''))) {
259
+ if (scope.project && (!isProject(scope.project) || !memberships(scope.project, pairKind).includes(subject.id ?? ''))) {
240
260
  selectedComplete = false;
241
- scopeFindings.push('Invalid or unmodelled project/product pair');
261
+ scopeFindings.push(`Invalid or unmodelled project/${subjectWord} pair`);
242
262
  }
243
263
  function query(release) {
244
264
  const chain = [];
@@ -246,9 +266,9 @@ export function buildRequirementChain(input) {
246
266
  let valid = true;
247
267
  while (cursor) {
248
268
  const r = records.get(cursor);
249
- if (chain.includes(cursor) || !is(cursor, 'RELEASE') || r?.raw.of !== scope.product) {
269
+ if (chain.includes(cursor) || !is(cursor, 'RELEASE') || r?.raw.of !== subject.id) {
250
270
  valid = false;
251
- finding(chain[chain.length - 1] ?? release, 'predecessor', 'RELEASE-SCOPE', chain.includes(cursor) ? 'Release predecessor cycle' : 'Missing, invalid or cross-product predecessor');
271
+ finding(chain[chain.length - 1] ?? release, 'predecessor', 'RELEASE-SCOPE', chain.includes(cursor) ? 'Release predecessor cycle' : `Missing, invalid or cross-${subjectWord} predecessor`);
252
272
  break;
253
273
  }
254
274
  chain.push(cursor);
@@ -275,16 +295,16 @@ export function buildRequirementChain(input) {
275
295
  selectedComplete = false;
276
296
  scopeFindings.push('Unresolved project membership');
277
297
  }
278
- if (rels.some(r => ['product_scope', 'project_scope', 'required_for'].includes(String(r.raw.type)) && !envelope.get(r.id)))
298
+ if (rels.some(r => [membershipKind, 'project_scope', 'required_for'].includes(String(r.raw.type)) && !envelope.get(r.id)))
279
299
  selectedComplete = false;
280
- if (scope.project && L.some(id => memberships(id, 'project_scope').some(project => !memberships(project, 'project_product').includes(scope.product ?? '')))) {
300
+ if (scope.project && L.some(id => memberships(id, 'project_scope').some(project => !memberships(project, pairKind).includes(subject.id ?? '')))) {
281
301
  selectedComplete = false;
282
- scopeFindings.push('Inconsistent project/product membership');
302
+ scopeFindings.push(`Inconsistent project/${subjectWord} membership`);
283
303
  }
284
304
  const S = L.filter(id => !scope.project || memberships(id, 'project_scope').includes(scope.project));
285
305
  const elsewhere = new Set();
286
306
  for (const release of records.values())
287
- if (release.type === 'RELEASE' && release.raw.of === scope.product) {
307
+ if (release.type === 'RELEASE' && release.raw.of === subject.id) {
288
308
  const q = query(release.id);
289
309
  productComplete && (productComplete = q.complete);
290
310
  q.ids.forEach(id => elsewhere.add(id));
@@ -352,7 +372,7 @@ export function buildRequirementChain(input) {
352
372
  if (v.raw.verifies === r.id)
353
373
  owners.add(v.id);
354
374
  for (const rel of rels) {
355
- const ownScope = ['product_scope', 'project_scope', 'required_for'].includes(String(rel.raw.type));
375
+ const ownScope = [membershipKind, 'project_scope', 'required_for'].includes(String(rel.raw.type));
356
376
  if (ownScope ? rel.raw.from === r.id : owners.has(String(rel.raw.from)))
357
377
  owners.add(rel.id);
358
378
  }
@@ -379,15 +399,17 @@ export function buildRequirementChain(input) {
379
399
  const broken = S.filter(id => selectedRefs.some(f => affectedRequirements[f.id]?.includes(id)));
380
400
  for (const n of nodes)
381
401
  if (records.get(n.recordId)?.type === 'REQUIREMENT' && !S.includes(n.recordId)) {
382
- n.context.push(!active.get(n.recordId) ? 'inactive' : !P.includes(n.recordId) ? 'unresolved or other product' :
402
+ n.context.push(!active.get(n.recordId) ? 'inactive' : !P.includes(n.recordId) ? `unresolved or other ${subjectWord}` :
383
403
  !L.includes(n.recordId) ? 'other release or unassigned' : 'other project');
384
404
  }
405
+ if (scope.subject && (!is(subject.id, subject.type) || !is(scope.release, 'RELEASE') || records.get(scope.release ?? '')?.raw.of !== subject.id))
406
+ productComplete = false;
385
407
  const unassignedSet = set(unassigned, productComplete);
386
408
  const result = {
387
- contract: 'requirement-chain/0.2', contractRevision: '97c9d41819011ead8fe192c266cba32707eae82f', asAt, snapshotId: input.snapshotId, sourceRevision: input.sourceRevision,
409
+ contract: scope.subject ? 'requirement-chain/0.3' : 'requirement-chain/0.2', contractRevision: scope.subject ? 'a131b1862d86f34be8feb729e1cc6ebc994c3891' : '97c9d41819011ead8fe192c266cba32707eae82f', asAt, snapshotId: input.snapshotId, sourceRevision: input.sourceRevision,
388
410
  scope: structuredClone(scope), completeness: productComplete && selectedComplete ? 'complete' : 'incomplete', scopeFindings: sorted(scopeFindings),
389
411
  populations: { product: set(P, productComplete), release: set(L, selectedComplete), selected: set(S, selectedComplete),
390
- inactive: set(requirements.filter(r => envelope.get(r.id) && !active.get(r.id) && rels.some(rel => rel.raw.type === 'product_scope' && rel.raw.from === r.id && rel.raw.to === scope.product && envelope.get(rel.id))).map(r => r.id), complete),
412
+ inactive: set(requirements.filter(r => envelope.get(r.id) && !active.get(r.id) && rels.some(rel => rel.raw.type === membershipKind && rel.raw.from === r.id && rel.raw.to === subject.id && envelope.get(rel.id))).map(r => r.id), complete),
391
413
  unresolved: set(unresolved, complete) },
392
414
  assignments: { here: set(L, selectedComplete), otherReleaseOnly: set(P.filter(id => elsewhere.has(id) && !L.includes(id)), productComplete),
393
415
  unassigned: unassignedSet, invalid: set(P.filter(id => invalidAssignments.has(id)), complete), provenance: selected.provenance.sort((a, b) => a.requirement.localeCompare(b.requirement)) },
@@ -474,6 +496,9 @@ export function requirementReleaseCounts(p) {
474
496
  const contextUnits = { 0: 'source documents', 1: 'drivers', 2: 'needs', 7: 'definition parts', 8: 'result parts' };
475
497
  for (const stage of [0, 1, 2, 7, 8])
476
498
  add('context-' + stage, CHAIN_STAGES[stage], set(view.nodes.filter(n => n.stage === stage).map(n => n.id), p.populations.selected.completeness === 'complete'), contextUnits[stage]);
499
+ if (p.scope.subject)
500
+ for (const count of Object.values(counts))
501
+ count.label = count.label.replace(/whole product/g, 'whole subject').replace('Product requirements', 'Subject requirements');
477
502
  return freeze(counts);
478
503
  }
479
504
  /** Monotonic refresh gate shared by reports; late successes and failures cannot replace newer data. */
@@ -488,7 +513,9 @@ export class RequirementChainSnapshot {
488
513
  const input = await load();
489
514
  if (generation !== this.generation)
490
515
  return false;
491
- this.current = buildRequirementChain(input);
516
+ this.current = input.scope.subject
517
+ ? buildSubjectRequirementChain({ ...input, scope: { ...input.scope, subject: input.scope.subject } })
518
+ : buildRequirementChain(input);
492
519
  this.stale = false;
493
520
  return true;
494
521
  }
@@ -47,6 +47,7 @@ export interface GoalsViewConfig {
47
47
  }
48
48
  export interface GoalsCanonSources {
49
49
  elements: unknown[];
50
+ relations?: unknown[];
50
51
  }
51
52
  /**
52
53
  * Returns true when the parsed view document uses the canon-projection form
@@ -42,10 +42,51 @@ function collectGoalElements(docs) {
42
42
  if (!id)
43
43
  continue;
44
44
  if (!out.has(id))
45
- out.set(id, doc); // first definition wins
45
+ out.set(id, { ...doc }); // never mutate the caller's catalogue
46
46
  }
47
47
  return out;
48
48
  }
49
+ /** Materialize canonical child -> parent links for a single-parent tree.
50
+ * Never mutate canon or silently linearize an ambiguous hierarchy.
51
+ */
52
+ function applyGoalParentRelations(goals, relations, validAt) {
53
+ const declaredChildren = new Set();
54
+ const parents = new Map();
55
+ for (const doc of relations) {
56
+ if (!isObject(doc) || doc['notation'] !== 'relation' || doc['type'] !== 'goal_parent')
57
+ continue;
58
+ const child = str(doc['from']);
59
+ const parent = str(doc['to']);
60
+ if (!child || !parent || !goals.has(child) || !goals.has(parent)) {
61
+ throw new Error(`goal_parent relation ${str(doc['id']) ?? '(unnamed)'} must reference two existing goals`);
62
+ }
63
+ declaredChildren.add(child);
64
+ const from = str(doc['valid_from']);
65
+ const to = str(doc['valid_to']);
66
+ if (validAt && ((from && validAt < from) || (to && validAt > to)))
67
+ continue;
68
+ const choices = parents.get(child) ?? new Set();
69
+ choices.add(parent);
70
+ parents.set(child, choices);
71
+ }
72
+ for (const child of declaredChildren) {
73
+ const goal = goals.get(child);
74
+ const choices = parents.get(child) ?? new Set();
75
+ if (choices.size > 1) {
76
+ throw new Error(`goal_parent for ${child} has multiple parents in this scope; narrow valid_at or use a graph view`);
77
+ }
78
+ const parent = [...choices][0];
79
+ const legacyParent = str(goal['parent']);
80
+ if (parent && legacyParent && parent !== legacyParent) {
81
+ throw new Error(`goal_parent for ${child} conflicts with its inline parent`);
82
+ }
83
+ // Declared but inactive links must not fall back to an undated parent.
84
+ if (parent)
85
+ goal['parent'] = parent;
86
+ else
87
+ delete goal['parent'];
88
+ }
89
+ }
49
90
  /**
50
91
  * Returns true when the parsed view document uses the canon-projection form
51
92
  * (`view_config` key present, no inline `goals[]`). Used by the preview and
@@ -69,6 +110,8 @@ export function resolveGoals(viewDoc, sources) {
69
110
  const vc = isObject(viewDoc['view_config']) ? viewDoc['view_config'] : {};
70
111
  const scope = isObject(vc['scope']) ? vc['scope'] : {};
71
112
  const allGoals = collectGoalElements(sources.elements);
113
+ const validAt = str(scope['valid_at']);
114
+ applyGoalParentRelations(allGoals, sources.relations ?? [], validAt);
72
115
  // 1. Scope by root_goal (this goal + its descendants), or every goal when
73
116
  // omitted (§5.1: "Omit to show all root goals").
74
117
  const rootGoal = str(scope['root_goal']);
@@ -97,7 +140,6 @@ export function resolveGoals(viewDoc, sources) {
97
140
  // 4. Narrow by valid_at — only goals in effect on that date. Goals with no
98
141
  // valid_from are not lifecycle-tracked and are kept (permissive default,
99
142
  // matching the action/dgca resolvers' stance).
100
- const validAt = str(scope['valid_at']);
101
143
  if (validAt) {
102
144
  candidateIds = new Set([...candidateIds].filter((id) => {
103
145
  const el = allGoals.get(id);
@@ -1,4 +1,4 @@
1
- export type ProductType = 'digital_product' | 'service' | 'platform' | 'bundle';
1
+ export type ProductType = 'physical_product' | 'digital_product' | 'service' | 'platform' | 'bundle';
2
2
  export type ProductStatus = 'Draft' | 'Active' | 'Deprecated';
3
3
  export interface Product {
4
4
  product_id: string;
@@ -1,5 +1,5 @@
1
1
  import { schemaFinding } from '../schema-finding.js';
2
- const VALID_TYPES = new Set(['digital_product', 'service', 'platform', 'bundle']);
2
+ const VALID_TYPES = new Set(['physical_product', 'digital_product', 'service', 'platform', 'bundle']);
3
3
  const VALID_STATUSES = new Set(['Draft', 'Active', 'Deprecated']);
4
4
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
5
5
  export function validateProductsCatalogue(input) {
@@ -17,16 +17,11 @@ import { validateActionFields } from '../activities/action-fields.js';
17
17
  // classification (`Issue.Severity` in the Go source) and its "import
18
18
  // anyway, record the warning" policy.
19
19
  //
20
- // GOALS-009/011 (orphan / missing parent) are ported at warning severity even
21
- // though most standalone GOAL elements legitimately have no `parent` field —
22
- // GOAL.parent is "v0.x transitional" (ELEMENT_PRIMITIVES.md §7.2; canonical
23
- // home is a `goal_parent` REL file or the goals-tree view's inline `parent`).
24
- // `organizations/acme_corp`'s GOAL-CUST-1/GOAL-OPS-1/GOAL-EU-1 are exactly
25
- // this shape and do surface GOALS-011 warnings — that noise is the accepted
26
- // cost of a *warning* level rule (non-blocking, advisory), unlike the ERROR
27
- // tier this repo held the line on. This is a deliberate call, not an
28
- // oversight: keep the coverage rather than drop it, now that it can be
29
- // non-blocking.
20
+ // GOALS-010 checks canonical and legacy edges in overlapping lifecycle slices.
21
+ // GOALS-009/011 respect the canonical goal_parent records already loaded
22
+ // from canon/relations. Static catalogue coverage considers those links;
23
+ // date-scoped hierarchy selection and ambiguity are checked by Goals views.
24
+ // Legacy inline parents remain supported for transitional catalogues.
30
25
  //
31
26
  // GOALS-008 (type/level mismatch) is still NOT ported, at either severity.
32
27
  // Both of DSM's cases ("type not declared in goal_types" / "level doesn't
@@ -107,17 +102,15 @@ function collectByNotation(elements, matches) {
107
102
  }
108
103
  return out;
109
104
  }
110
- /** DFS cycle detection over a single-parent edge (GOAL.parent), matching DSM's
105
+ /** DFS cycle detection over resolved parent edges, matching DSM's
111
106
  * `findCycle` (goals.go): only walks an edge to a parent that itself resolves
112
107
  * within the set — an edge into an unresolved (orphan) parent is not walked,
113
108
  * so an orphan can never be mistaken for a cycle. Returns the id where a
114
109
  * back-edge was hit, or `undefined` if the graph is acyclic. */
115
- function findParentCycle(goals) {
116
- const parentOf = new Map();
110
+ function findParentCycle(goals, parents) {
117
111
  const known = new Set();
118
112
  for (const g of goals) {
119
113
  known.add(g.id);
120
- parentOf.set(g.id, readString(g.data, 'parent'));
121
114
  }
122
115
  const UNVISITED = 0;
123
116
  const VISITING = 1;
@@ -130,8 +123,9 @@ function findParentCycle(goals) {
130
123
  if (s === VISITING)
131
124
  return id;
132
125
  state.set(id, VISITING);
133
- const parent = parentOf.get(id);
134
- if (parent && known.has(parent)) {
126
+ for (const parent of parents.get(id) ?? []) {
127
+ if (!known.has(parent))
128
+ continue;
135
129
  const hit = walk(parent);
136
130
  if (hit)
137
131
  return hit;
@@ -184,27 +178,68 @@ function findPredecessorCycle(actions) {
184
178
  }
185
179
  return undefined;
186
180
  }
187
- /** GOALS-010 — the GOAL `parent` chain must not contain a cycle. */
188
- function checkGoalParentCycle(goals, findings) {
189
- const cyc = findParentCycle(goals);
190
- if (cyc) {
181
+ /** GOALS-010 — parent links must be acyclic in every effective date slice.
182
+ * A cycle can first appear only when an edge starts. Inspect those boundaries
183
+ * and the unbounded past; do not union disjoint historical parent relations.
184
+ */
185
+ function checkGoalParentCycle(goals, relations, findings) {
186
+ const known = new Set(goals.map(g => g.id));
187
+ const links = relations.flatMap(rel => {
188
+ const d = rel.data;
189
+ if (!d || d['notation'] !== 'relation' || d['type'] !== 'goal_parent')
190
+ return [];
191
+ const child = readString(d, 'from'), parent = readString(d, 'to');
192
+ if (!child || !parent || !known.has(child) || !known.has(parent))
193
+ return [];
194
+ return [{ child, parent, from: readString(d, 'valid_from'), to: readString(d, 'valid_to') }];
195
+ });
196
+ const declared = new Set(links.map(link => link.child));
197
+ const dates = [...new Set(['0000-01-01', ...links.flatMap(link => link.from ? [link.from] : [])])].sort();
198
+ for (const date of dates) {
199
+ const parents = new Map();
200
+ for (const g of goals) {
201
+ const legacy = readString(g.data, 'parent');
202
+ if (!declared.has(g.id) && legacy)
203
+ parents.set(g.id, [legacy]);
204
+ }
205
+ for (const link of links) {
206
+ if ((link.from && date < link.from) || (link.to && date > link.to))
207
+ continue;
208
+ parents.set(link.child, [...(parents.get(link.child) ?? []), link.parent]);
209
+ }
210
+ const cyc = findParentCycle(goals, parents);
211
+ if (!cyc)
212
+ continue;
191
213
  findings.push({
192
214
  scope: PScope,
193
215
  id: cyc,
194
216
  ruleId: 'GOALS-010',
195
217
  message: `GOALS-010: parent chain contains a cycle involving goal '${cyc}'.`,
196
218
  });
219
+ return;
197
220
  }
198
221
  }
199
222
  /** GOALS-009 / GOALS-011 — GOAL `parent` resolution (warning). Mirrors DSM's
200
223
  * goals.go: the two are mutually exclusive per goal — a goal with no
201
224
  * `parent` is checked for GOALS-011 (backlog, level >= 1); a goal with a
202
225
  * `parent` is checked for GOALS-009 (orphan, parent unresolved) instead. */
203
- function checkGoalParentResolution(goals, findings) {
226
+ function checkGoalParentResolution(goals, relations, findings) {
204
227
  const knownIds = new Set(goals.map((g) => g.id));
228
+ const linkedChildren = new Set();
229
+ for (const rel of relations) {
230
+ const data = rel.data;
231
+ if (!data || data['notation'] !== 'relation' || data['type'] !== 'goal_parent')
232
+ continue;
233
+ const child = readString(data, 'from');
234
+ const parent = readString(data, 'to');
235
+ if (child && parent && knownIds.has(child) && knownIds.has(parent))
236
+ linkedChildren.add(child);
237
+ }
205
238
  for (const g of goals) {
206
239
  const parent = readString(g.data, 'parent');
207
240
  if (!parent) {
241
+ if (linkedChildren.has(g.id))
242
+ continue;
208
243
  const level = readFiniteNumber(g.data, 'level');
209
244
  if (level !== undefined && level >= 1) {
210
245
  findings.push({
@@ -467,8 +502,8 @@ export function checkStrategyChainSemantics(input, findings) {
467
502
  const actions = collectByNotation(input.elements, isActionNotation);
468
503
  const drivers = collectByNotation(input.elements, isDriverNotation);
469
504
  const changes = collectByNotation(input.elements, isChangeNotation);
470
- checkGoalParentCycle(goals, findings);
471
- checkGoalParentResolution(goals, findings);
505
+ checkGoalParentCycle(goals, input.relations, findings);
506
+ checkGoalParentResolution(goals, input.relations, findings);
472
507
  checkActionSelfPredecessor(actions, findings);
473
508
  checkActionPredecessorCycle(actions, findings);
474
509
  checkActionOrphanReferences(actions, findings);