@transitrix/diagrams 1.10.0 → 1.11.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 (68) hide show
  1. package/dist/activities/resolver.d.ts +3 -0
  2. package/dist/activities/resolver.js +39 -18
  3. package/dist/activities/validate.js +59 -0
  4. package/dist/canon-resolver-utils.d.ts +3 -0
  5. package/dist/canon-resolver-utils.js +16 -0
  6. package/dist/compliance/classify.js +1 -0
  7. package/dist/compliance/impact.d.ts +3 -1
  8. package/dist/compliance/impact.js +41 -13
  9. package/dist/compliance/types.d.ts +6 -0
  10. package/dist/compliance-verification-matrix/build.d.ts +17 -0
  11. package/dist/compliance-verification-matrix/build.js +65 -0
  12. package/dist/compliance-verification-matrix/csv.d.ts +6 -0
  13. package/dist/compliance-verification-matrix/csv.js +41 -0
  14. package/dist/compliance-verification-matrix/index.d.ts +3 -0
  15. package/dist/compliance-verification-matrix/index.js +2 -0
  16. package/dist/compliance-verification-matrix/types.d.ts +75 -0
  17. package/dist/compliance-verification-matrix/types.js +4 -0
  18. package/dist/fgca/action-change-ids.d.ts +7 -0
  19. package/dist/fgca/action-change-ids.js +11 -0
  20. package/dist/fgca/parse-canonical.d.ts +3 -1
  21. package/dist/fgca/parse-canonical.js +13 -8
  22. package/dist/fgca/resolver.d.ts +6 -2
  23. package/dist/fgca/resolver.js +12 -6
  24. package/dist/fgca/validate.d.ts +2 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/process-blueprint/index.d.ts +3 -1
  28. package/dist/process-blueprint/index.js +1 -0
  29. package/dist/process-blueprint/layout.js +33 -29
  30. package/dist/process-blueprint/resolve-columns.d.ts +52 -0
  31. package/dist/process-blueprint/resolve-columns.js +138 -0
  32. package/dist/process-blueprint/types.d.ts +23 -5
  33. package/dist/process-blueprint/validate.d.ts +19 -1
  34. package/dist/process-blueprint/validate.js +73 -21
  35. package/dist/repo-validate/check-process-parent.d.ts +8 -0
  36. package/dist/repo-validate/check-process-parent.js +172 -0
  37. package/dist/repo-validate/index.d.ts +1 -0
  38. package/dist/repo-validate/index.js +1 -0
  39. package/dist/repo-validate/validate-repo.js +3 -0
  40. package/dist/webview/render-fgca.js +1 -1
  41. package/dist/webview/render-process.d.ts +19 -1
  42. package/dist/webview/render-process.js +75 -42
  43. package/package.json +1 -1
  44. package/src/activities/resolver.ts +40 -20
  45. package/src/activities/validate.ts +58 -0
  46. package/src/canon-resolver-utils.ts +19 -0
  47. package/src/compliance/classify.ts +1 -0
  48. package/src/compliance/impact.ts +41 -12
  49. package/src/compliance/types.ts +6 -0
  50. package/src/compliance-verification-matrix/build.ts +84 -0
  51. package/src/compliance-verification-matrix/csv.ts +47 -0
  52. package/src/compliance-verification-matrix/index.ts +7 -0
  53. package/src/compliance-verification-matrix/types.ts +85 -0
  54. package/src/fgca/action-change-ids.ts +13 -0
  55. package/src/fgca/parse-canonical.ts +12 -8
  56. package/src/fgca/resolver.ts +15 -6
  57. package/src/fgca/validate.ts +2 -0
  58. package/src/index.ts +1 -0
  59. package/src/process-blueprint/index.ts +11 -0
  60. package/src/process-blueprint/layout.ts +48 -29
  61. package/src/process-blueprint/resolve-columns.ts +147 -0
  62. package/src/process-blueprint/types.ts +19 -5
  63. package/src/process-blueprint/validate.ts +107 -21
  64. package/src/repo-validate/check-process-parent.ts +184 -0
  65. package/src/repo-validate/index.ts +5 -0
  66. package/src/repo-validate/validate-repo.ts +3 -0
  67. package/src/webview/render-fgca.ts +1 -1
  68. package/src/webview/render-process.ts +94 -49
@@ -12,7 +12,7 @@
12
12
  * §2). Filesystem-free — callable from unit tests without vscode.
13
13
  */
14
14
 
15
- import { isObject, str, strArray, descendantsOf, stripEnvelope } from '../canon-resolver-utils.js';
15
+ import { isObject, str, strArray, stripEnvelope, reachesRootViaParent } from '../canon-resolver-utils.js';
16
16
 
17
17
  export interface ActionViewConfig {
18
18
  scope?: {
@@ -44,6 +44,10 @@ export interface ActionCanonSources {
44
44
  relations?: unknown[];
45
45
  }
46
46
 
47
+ /** In-memory bag of ACTION ids dropped by `root_action` scoping. Consumed by
48
+ * `validateActivities` as `ACT-021`. Not part of the notation. */
49
+ export const ACT_021_OMITTED_KEY = '__act021_omitted';
50
+
47
51
  /** ACTION elements by id. `notation: action` is canonical; `notation: activity`
48
52
  * is the deprecated pre-2026-06-25 alias (elements/24-action.md §"Deprecated
49
53
  * alias") — accepted the same way `collectByNotation` callers elsewhere do. */
@@ -123,32 +127,26 @@ export function resolveAction(
123
127
  return relGoals.length > 0 ? relGoals : strArray(el['goals']);
124
128
  }
125
129
 
126
- // 1. Scope by root_action (this action + its descendants), or the full
127
- // catalogue when omitted (§5.1: "Omit to include elements from the full
128
- // catalogue (or use goals/type_filter for other scoping)").
129
- const rootAction = str(scope['root_action']);
130
- let candidateIds: Set<string>;
131
- if (rootAction) {
132
- candidateIds = allActions.has(rootAction) ? descendantsOf(rootAction, allActions) : new Set();
133
- } else {
134
- candidateIds = new Set(allActions.keys());
135
- }
130
+ // 1. Other scope fields first — the set the view would otherwise select
131
+ // (07-action.md §5.1). `root_action` then omits (and warns) rather than
132
+ // silently intersecting first.
133
+ let otherwiseIds: Set<string> = new Set(allActions.keys());
136
134
 
137
- // 2. Narrow by goals — only actions linked to at least one listed GOAL
135
+ // Narrow by goals — only actions linked to at least one listed GOAL
138
136
  // (via an active action_goal relation, or inline goals[] when no relation
139
137
  // exists for that action).
140
138
  const goalsFilter = strArray(scope['goals']);
141
139
  if (goalsFilter.length > 0) {
142
- candidateIds = new Set(
143
- [...candidateIds].filter((id) => effectiveGoals(id, allActions.get(id)!).some((g) => goalsFilter.includes(g))),
140
+ otherwiseIds = new Set(
141
+ [...otherwiseIds].filter((id) => effectiveGoals(id, allActions.get(id)!).some((g) => goalsFilter.includes(g))),
144
142
  );
145
143
  }
146
144
 
147
- // 3. Narrow by type_filter — only actions of a listed type.
145
+ // Narrow by type_filter — only actions of a listed type.
148
146
  const typeFilter = strArray(scope['type_filter']);
149
147
  if (typeFilter.length > 0) {
150
- candidateIds = new Set(
151
- [...candidateIds].filter((id) => {
148
+ otherwiseIds = new Set(
149
+ [...otherwiseIds].filter((id) => {
152
150
  const el = allActions.get(id);
153
151
  const t = str(el?.['type']) ?? str(el?.['activity_type']);
154
152
  return t !== undefined && typeFilter.includes(t);
@@ -156,13 +154,13 @@ export function resolveAction(
156
154
  );
157
155
  }
158
156
 
159
- // 4. Narrow by valid_at — only actions in effect on that date. Actions with
157
+ // Narrow by valid_at — only actions in effect on that date. Actions with
160
158
  // no valid_from are not lifecycle-tracked and are kept (permissive default,
161
159
  // matching the resolver's general "missing = not excluded" stance).
162
160
  const validAt = str(scope['valid_at']);
163
161
  if (validAt) {
164
- candidateIds = new Set(
165
- [...candidateIds].filter((id) => {
162
+ otherwiseIds = new Set(
163
+ [...otherwiseIds].filter((id) => {
166
164
  const el = allActions.get(id);
167
165
  const from = str(el?.['valid_from']);
168
166
  const to = str(el?.['valid_to']);
@@ -173,6 +171,27 @@ export function resolveAction(
173
171
  );
174
172
  }
175
173
 
174
+ const rootAction = str(scope['root_action']);
175
+ let candidateIds: Set<string>;
176
+ const omittedOutsideRoot: string[] = [];
177
+ if (rootAction) {
178
+ if (!allActions.has(rootAction)) {
179
+ candidateIds = new Set();
180
+ } else {
181
+ candidateIds = new Set();
182
+ for (const id of otherwiseIds) {
183
+ if (reachesRootViaParent(id, rootAction, allActions)) {
184
+ candidateIds.add(id);
185
+ } else {
186
+ omittedOutsideRoot.push(id);
187
+ }
188
+ }
189
+ omittedOutsideRoot.sort();
190
+ }
191
+ } else {
192
+ candidateIds = otherwiseIds;
193
+ }
194
+
176
195
  // Element fields map onto Activity fields unchanged, except the canonical
177
196
  // `type` (elements/24-action.md §2) which becomes the internal `activity_type`
178
197
  // (types.ts `Activity.activity_type`), and `goals` which is resolved via
@@ -204,5 +223,6 @@ export function resolveAction(
204
223
  actions: selectedActions,
205
224
  };
206
225
  if (Object.keys(project).length > 0) out['project'] = project;
226
+ if (omittedOutsideRoot.length > 0) out[ACT_021_OMITTED_KEY] = { root: rootAction, ids: omittedOutsideRoot };
207
227
  return out;
208
228
  }
@@ -5,10 +5,62 @@ import type {
5
5
  ActivityValidationError,
6
6
  ActivityValidationWarning,
7
7
  } from './types.js';
8
+ import { isObject, str, reachesRootViaParent } from '../canon-resolver-utils.js';
9
+ import { ACT_021_OMITTED_KEY } from './resolver.js';
8
10
 
9
11
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
10
12
  const VALID_WEEKDAYS = new Set(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
11
13
 
14
+ function act021Message(actionId: string, rootId: string): string {
15
+ return `ACTION "${actionId}" is not "${rootId}" and is not reachable from it via parent`;
16
+ }
17
+
18
+ function collectAct021FromProjection(raw: Record<string, unknown>): ActivityValidationWarning[] {
19
+ const bag = raw[ACT_021_OMITTED_KEY];
20
+ delete raw[ACT_021_OMITTED_KEY];
21
+ if (!isObject(bag)) return [];
22
+ const root = str(bag['root']);
23
+ const ids = Array.isArray(bag['ids']) ? bag['ids'] : [];
24
+ if (!root) return [];
25
+ const out: ActivityValidationWarning[] = [];
26
+ for (const id of ids) {
27
+ if (typeof id !== 'string' || !id.trim()) continue;
28
+ out.push({
29
+ code: 'ACT-021',
30
+ message: act021Message(id, root),
31
+ path: 'view_config.scope.root_action',
32
+ });
33
+ }
34
+ return out;
35
+ }
36
+
37
+ function collectAct021FromAuthored(
38
+ raw: Record<string, unknown>,
39
+ actions: unknown[],
40
+ ): ActivityValidationWarning[] {
41
+ const vc = isObject(raw['view_config']) ? raw['view_config'] : undefined;
42
+ const scope = vc && isObject(vc['scope']) ? vc['scope'] : undefined;
43
+ const root = scope ? str(scope['root_action']) : undefined;
44
+ if (!root) return [];
45
+ const byId = new Map<string, Record<string, unknown>>();
46
+ for (const el of actions) {
47
+ if (!isObject(el)) continue;
48
+ const id = str(el['id']);
49
+ if (id) byId.set(id, el);
50
+ }
51
+ if (!byId.has(root)) return [];
52
+ const out: ActivityValidationWarning[] = [];
53
+ for (const id of byId.keys()) {
54
+ if (reachesRootViaParent(id, root, byId)) continue;
55
+ out.push({
56
+ code: 'ACT-021',
57
+ message: act021Message(id, root),
58
+ path: 'view_config.scope.root_action',
59
+ });
60
+ }
61
+ return out;
62
+ }
63
+
12
64
  export function validateActivities(input: unknown): ActivityValidationResult {
13
65
  const errors: ActivityValidationError[] = [];
14
66
  const warnings: ActivityValidationWarning[] = [];
@@ -46,6 +98,12 @@ export function validateActivities(input: unknown): ActivityValidationResult {
46
98
  // Normalise onto raw.activities for the rest of this function (avoids touching downstream code)
47
99
  if (!Array.isArray(raw.activities)) raw.activities = raw.actions;
48
100
 
101
+ const fromProjection = collectAct021FromProjection(raw);
102
+ warnings.push(...fromProjection);
103
+ if (fromProjection.length === 0) {
104
+ warnings.push(...collectAct021FromAuthored(raw, rawArray));
105
+ }
106
+
49
107
  // ACT-014 / ACT-015: project.calendar validation (run before per-activity loop;
50
108
  // surfaces clearly even when activities also have issues).
51
109
  if (raw.project && typeof raw.project === 'object') {
@@ -48,6 +48,25 @@ export function descendantsOf(
48
48
  return result;
49
49
  }
50
50
 
51
+ /** Walk `parent` from `id` toward its ancestors until `rootId` or the chain
52
+ * ends. Cycle-safe: a cycle is not a path to the root. */
53
+ export function reachesRootViaParent(
54
+ id: string,
55
+ rootId: string,
56
+ all: Map<string, Record<string, unknown>>,
57
+ ): boolean {
58
+ const seen = new Set<string>();
59
+ let current: string | undefined = id;
60
+ while (current) {
61
+ if (current === rootId) return true;
62
+ if (seen.has(current)) return false;
63
+ seen.add(current);
64
+ const el = all.get(current);
65
+ current = el ? str(el['parent']) : undefined;
66
+ }
67
+ return false;
68
+ }
69
+
51
70
  /** Depth of `id` in its `parent` chain within `all` (root = 0, each step up
52
71
  * adds 1). Cycle-safe (a cycle resolves the entering node to depth 0) and
53
72
  * memoized across calls via the shared `memo` map. */
@@ -166,6 +166,7 @@ export function ingestComplianceDoc(canon: ComplianceCanon, doc: unknown): strin
166
166
  if (!verifies || !method || !outcome) return null;
167
167
  const ok = pushUnique(canon, canon.verifications, {
168
168
  id, verifies, method, outcome,
169
+ protocol: str(d.protocol),
169
170
  performed_at: str(d.performed_at),
170
171
  evidenceCount: Array.isArray(d.evidence) ? d.evidence.length : 0,
171
172
  admitted_at: str(d.admitted_at),
@@ -12,6 +12,7 @@ import type { ComplianceCanon } from './classify.js';
12
12
  import { buildComplianceIndex } from './reverse-index.js';
13
13
  import type { CanonCatalog } from '../typed-id.js';
14
14
  import type { ComplianceIndex, IndexAssertion, IndexRequirement, ObjectDetailInput, ObjectDetailDef, DeadlineStatus } from './types.js';
15
+ import { isStageColumnId } from '../process-blueprint/resolve-columns.js';
15
16
 
16
17
  /** Filter selecting REQUIREMENTs by codex source (jurisdiction / regime keys
17
18
  * are accepted for forward compatibility but not yet honoured — the canon
@@ -649,22 +650,48 @@ function buildTaskColumns(
649
650
  *
650
651
  * At `'product'` grain: `a.subject` must match `col.subjectId`.
651
652
  * At `'product-stage'` grain: `a.subject` must match AND either
652
- * `a.realised_via` is absent (claim covers the whole subject) OR
653
- * `col.stageId` ∈ `a.realised_via`.
654
- * At `'product-stage-task'` grain: as above for stage, plus `col.taskId` ∈
655
- * `a.realised_via` counts as a match (task-level claim covers this task column).
656
- * A stage-level claim (`col.stageId` ∈ `a.realised_via`) covers all task columns
657
- * in that stage.
653
+ * `a.realised_via` is absent (claim covers the whole subject) OR the token
654
+ * pins this column: a `PROCESS-…` id matching `col.stageId`, or a STEP whose
655
+ * home process is that column. Sketch `STAGE-…` ids are never a join key.
656
+ * At `'product-stage-task'` grain: a task-id hit covers that task column; a
657
+ * process-level claim covers every task column of that process.
658
658
  */
659
- function assertionMatchesColumn(a: IndexAssertion, col: ImpactColumn): boolean {
659
+ function assertionMatchesColumn(
660
+ a: IndexAssertion,
661
+ col: ImpactColumn,
662
+ stepHomeProcess?: ReadonlyMap<string, string>,
663
+ ): boolean {
660
664
  if (a.subject !== col.subjectId) return false;
661
665
  if (!col.stageId) return true;
662
666
  if (!a.realised_via || a.realised_via.length === 0) return true;
663
- if (col.taskId) {
664
- // Task grain: match on taskId OR on stageId (stage claim covers all tasks).
665
- return a.realised_via.includes(col.taskId) || a.realised_via.includes(col.stageId);
667
+
668
+ const stageId = col.stageId;
669
+ if (isStageColumnId(stageId)) {
670
+ // Sketch column: only a task-id hit counts — never the STAGE- key.
671
+ return col.taskId != null && a.realised_via.includes(col.taskId);
672
+ }
673
+
674
+ for (const ref of a.realised_via) {
675
+ if (typeof ref !== 'string') continue;
676
+ if (isStageColumnId(ref)) continue;
677
+ if (col.taskId && ref === col.taskId) return true;
678
+ if (ref === stageId) return true;
679
+ if (!col.taskId && stepHomeProcess?.get(ref) === stageId) return true;
666
680
  }
667
- return a.realised_via.includes(col.stageId);
681
+ return false;
682
+ }
683
+
684
+ function stepHomeFromObjectDetails(objectDetails?: ObjectDetailInput[]): Map<string, string> {
685
+ const out = new Map<string, string>();
686
+ if (!objectDetails) return out;
687
+ for (const od of objectDetails) {
688
+ for (const d of od.details ?? []) {
689
+ for (const t of d.tasks ?? []) {
690
+ if (typeof t?.id === 'string' && t.id.length > 0) out.set(t.id, d.id);
691
+ }
692
+ }
693
+ }
694
+ return out;
668
695
  }
669
696
 
670
697
  /**
@@ -691,6 +718,7 @@ export function buildImpactMatrix(
691
718
  canon: ComplianceCanon,
692
719
  config: ImpactViewConfig,
693
720
  objectDetails?: ObjectDetailInput[],
721
+ join?: { stepHomeProcess?: ReadonlyMap<string, string> },
694
722
  ): ImpactMatrix {
695
723
  const index = buildComplianceIndex({
696
724
  requirements: canon.requirements,
@@ -755,6 +783,7 @@ export function buildImpactMatrix(
755
783
 
756
784
  const rowIndex = new Set(obligations.map(r => r.id));
757
785
  const cells: ImpactCell[][] = obligations.map(() => columns.map(() => emptyCell()));
786
+ const stepHome = join?.stepHomeProcess ?? stepHomeFromObjectDetails(objectDetails);
758
787
 
759
788
  for (let c = 0; c < columns.length; c++) {
760
789
  const col = columns[c];
@@ -762,7 +791,7 @@ export function buildImpactMatrix(
762
791
  for (const a of subjectAssertions) {
763
792
  if (!rowIndex.has(a.about)) continue;
764
793
  if (!allowedStatuses.has(a.status)) continue;
765
- if (!assertionMatchesColumn(a, col)) continue;
794
+ if (!assertionMatchesColumn(a, col, stepHome)) continue;
766
795
  const rowIdx = obligations.findIndex(r => r.id === a.about);
767
796
  if (rowIdx < 0) continue;
768
797
  cells[rowIdx][c].assertions.push(a);
@@ -128,6 +128,12 @@ export interface IndexVerification {
128
128
  verifies: string;
129
129
  method: VerificationMethod;
130
130
  outcome: VerificationOutcome;
131
+ /**
132
+ * The verification procedure (27-verification.md §2). VERIFICATION carries
133
+ * no `name` field of its own; `protocol` is the closest human-readable
134
+ * description and is what a matrix/list view shows as the row's label.
135
+ */
136
+ protocol?: string;
131
137
  performed_at?: string;
132
138
  /** Number of evidence entries — feeds the VERIF-006 gap. */
133
139
  evidenceCount?: number;
@@ -0,0 +1,84 @@
1
+ // Requirement -> verification matrix builder.
2
+
3
+ import type { ComplianceIndex } from '../compliance/types.js';
4
+ import { buildGapReport } from '../compliance/gap-report.js';
5
+ import type {
6
+ RequirementVerificationMatrix,
7
+ RequirementVerificationMatrixOptions,
8
+ RequirementVerificationRow,
9
+ } from './types.js';
10
+
11
+ function byId<T extends { id: string }>(a: T, b: T): number {
12
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
13
+ }
14
+
15
+ /**
16
+ * Builds the repository-wide requirement -> verification matrix.
17
+ *
18
+ * Pure projection over an already-built `ComplianceIndex`
19
+ * (`compliance/reverse-index.ts`) — no I/O, no revalidation. Reuses
20
+ * `buildGapReport`'s `REQ-VERIF-COVERAGE-001`/`-002` verdicts verbatim rather
21
+ * than recomputing a competing coverage status.
22
+ *
23
+ * Ordering: requirements sorted by id; within a requirement, its verification
24
+ * rows sorted by verification id. Both are explicit sorts over the index's
25
+ * values, so the result is independent of the Map's insertion order (which —
26
+ * for a caller that built the index from a filesystem walk — would otherwise
27
+ * track directory enumeration order).
28
+ */
29
+ export function buildRequirementVerificationMatrix(
30
+ index: ComplianceIndex,
31
+ options: RequirementVerificationMatrixOptions = {},
32
+ ): RequirementVerificationMatrix {
33
+ const gapReport = buildGapReport(index, options);
34
+ const noVerification = new Set(gapReport.requirementsWithoutVerification.map(r => r.id));
35
+ const unresolvedVerification = new Set(gapReport.requirementsWithUnresolvedVerification.map(r => r.id));
36
+
37
+ const requirements = [...index.requirementById.values()].sort(byId);
38
+
39
+ const rows: RequirementVerificationRow[] = [];
40
+ let verificationRowCount = 0;
41
+ let gapRequirementCount = 0;
42
+
43
+ for (const requirement of requirements) {
44
+ const parentId = requirement.parent;
45
+ const parentLabel = parentId ? index.requirementById.get(parentId)?.name : undefined;
46
+
47
+ const base: Pick<RequirementVerificationRow, 'requirementId' | 'requirementLabel' | 'parentId' | 'parentLabel'> = {
48
+ requirementId: requirement.id,
49
+ requirementLabel: requirement.name,
50
+ ...(parentId !== undefined ? { parentId } : {}),
51
+ ...(parentLabel !== undefined ? { parentLabel } : {}),
52
+ };
53
+
54
+ if (noVerification.has(requirement.id)) {
55
+ gapRequirementCount++;
56
+ rows.push({ ...base, coverageGap: 'REQ-VERIF-COVERAGE-001' });
57
+ continue;
58
+ }
59
+
60
+ const gap = unresolvedVerification.has(requirement.id) ? ('REQ-VERIF-COVERAGE-002' as const) : undefined;
61
+ if (gap) gapRequirementCount++;
62
+
63
+ const verifications = [...(index.verificationsByRequirement.get(requirement.id) ?? [])].sort(byId);
64
+ for (const verification of verifications) {
65
+ verificationRowCount++;
66
+ rows.push({
67
+ ...base,
68
+ verificationId: verification.id,
69
+ verificationLabel: verification.protocol ?? verification.id,
70
+ verificationOutcome: verification.outcome,
71
+ ...(gap ? { coverageGap: gap } : {}),
72
+ });
73
+ }
74
+ }
75
+
76
+ return {
77
+ rows,
78
+ summary: {
79
+ requirements: requirements.length,
80
+ verifications: verificationRowCount,
81
+ gaps: gapRequirementCount,
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,47 @@
1
+ // Deterministic CSV serialiser for the requirement-verification matrix.
2
+ // Same row order as `buildRequirementVerificationMatrix` (requirement id,
3
+ // then verification id). CRLF line endings, RFC-4180 quoting — the same
4
+ // convention as the Gap Dashboard export.
5
+
6
+ import type { RequirementVerificationMatrix, RequirementVerificationRow } from './types.js';
7
+
8
+ const HEADER = [
9
+ 'requirement_id',
10
+ 'requirement_label',
11
+ 'parent_id',
12
+ 'parent_label',
13
+ 'related_test_result_id',
14
+ 'related_test_result_label',
15
+ 'outcome',
16
+ 'coverage_gap',
17
+ ] as const;
18
+
19
+ function csvCell(value: string | undefined): string {
20
+ const v = value ?? '';
21
+ return `"${v.replace(/"/g, '""')}"`;
22
+ }
23
+
24
+ function rowCells(row: RequirementVerificationRow): string[] {
25
+ return [
26
+ row.requirementId,
27
+ row.requirementLabel,
28
+ row.parentId ?? '',
29
+ row.parentLabel ?? '',
30
+ row.verificationId ?? '',
31
+ row.verificationLabel ?? '',
32
+ row.verificationOutcome ?? '',
33
+ row.coverageGap ?? '',
34
+ ];
35
+ }
36
+
37
+ /**
38
+ * Serialise the matrix to CSV. Identical input yields byte-identical output;
39
+ * the row order is the builder's stable order.
40
+ */
41
+ export function renderRequirementVerificationMatrixCsv(matrix: RequirementVerificationMatrix): string {
42
+ const lines = [HEADER.map(h => csvCell(h)).join(',')];
43
+ for (const row of matrix.rows) {
44
+ lines.push(rowCells(row).map(csvCell).join(','));
45
+ }
46
+ return lines.join('\r\n') + '\r\n';
47
+ }
@@ -0,0 +1,7 @@
1
+ export { buildRequirementVerificationMatrix } from './build.js';
2
+ export { renderRequirementVerificationMatrixCsv } from './csv.js';
3
+ export type {
4
+ RequirementVerificationMatrix,
5
+ RequirementVerificationMatrixOptions,
6
+ RequirementVerificationRow,
7
+ } from './types.js';
@@ -0,0 +1,85 @@
1
+ // Requirement -> verification matrix — deterministic, repository-wide
2
+ // projection over the shared compliance reverse-index. Pure data; the
3
+ // consumer (a VS Code table, an export) renders it.
4
+
5
+ import type { VerificationOutcome } from '../verification/types.js';
6
+
7
+ /**
8
+ * One row of the requirement-verification matrix.
9
+ *
10
+ * A requirement with N admitted VERIFICATION records produces N rows, one per
11
+ * verification. A requirement with none still produces exactly one row,
12
+ * carrying the `REQ-VERIF-COVERAGE-001` gap. A requirement whose
13
+ * verifications exist but never closed (pass/fail) produces one row per
14
+ * verification — each carrying the `REQ-VERIF-COVERAGE-002` gap alongside its
15
+ * (still-open) outcome; the open verification is real data and is never
16
+ * replaced by a placeholder gap row.
17
+ *
18
+ * Coverage comes only from `VERIFICATION.verifies` (27-verification.md §2). A
19
+ * child requirement's verification never covers its `parent`
20
+ * (15-requirement.md §2.4 — `parent` records structure only, no roll-up).
21
+ */
22
+ export interface RequirementVerificationRow {
23
+ requirementId: string;
24
+ requirementLabel: string;
25
+
26
+ /**
27
+ * Direct `REQUIREMENT.parent` id, verbatim, present whenever the field is
28
+ * set — whether or not it resolves. `parent` resolution is not currently a
29
+ * validator concern (15-requirement.md §2.4); a dangling parent must still
30
+ * be visible on the row, not silently dropped.
31
+ */
32
+ parentId?: string;
33
+ /**
34
+ * The resolved parent's `name`, present only when `parentId` names an
35
+ * admitted requirement in the index. Absent when `parentId` is set but
36
+ * unresolved (dangling), or when the requirement has no parent.
37
+ */
38
+ parentLabel?: string;
39
+
40
+ /** The verification filling this row, when one exists. */
41
+ verificationId?: string;
42
+ /**
43
+ * VERIFICATION carries no `name`/label field of its own
44
+ * (27-verification.md §2); `protocol` — the procedure description — is the
45
+ * closest human-readable text and is what this row shows as its label.
46
+ */
47
+ verificationLabel?: string;
48
+ verificationOutcome?: VerificationOutcome;
49
+
50
+ /**
51
+ * `REQ-VERIF-COVERAGE-001` / `-002` (`compliance/gap-report.ts`), reused
52
+ * verbatim — this builder never recomputes a competing coverage verdict.
53
+ * Absent once the requirement has at least one closed (pass/fail)
54
+ * verification.
55
+ */
56
+ coverageGap?: 'REQ-VERIF-COVERAGE-001' | 'REQ-VERIF-COVERAGE-002';
57
+ }
58
+
59
+ export interface RequirementVerificationMatrixOptions {
60
+ /**
61
+ * Forwarded to `buildGapReport`. Neither coverage gap this matrix surfaces
62
+ * (`REQ-VERIF-COVERAGE-001`/`-002`) depends on `today` — accepted only for
63
+ * call-site symmetry with the other compliance-view builders.
64
+ */
65
+ today?: string;
66
+ }
67
+
68
+ export interface RequirementVerificationMatrix {
69
+ /**
70
+ * One row per (requirement, verification) pair, plus one gap row per
71
+ * requirement with zero verifications.
72
+ *
73
+ * Ordering (stable, independent of filesystem/Map enumeration order):
74
+ * requirements sorted by id; within a requirement, its verification rows
75
+ * sorted by verification id.
76
+ */
77
+ rows: RequirementVerificationRow[];
78
+ summary: {
79
+ requirements: number;
80
+ /** Rows carrying a real verification (excludes -001 gap-only rows). */
81
+ verifications: number;
82
+ /** Requirements carrying a REQ-VERIF-COVERAGE-001 or -002 gap. */
83
+ gaps: number;
84
+ };
85
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Action → Change linkage on an element record.
3
+ *
4
+ * Canonical field is `delivers_changes` (Action element spec). `changes` is
5
+ * the pre-rename alias still present on older fixtures and inline docs.
6
+ */
7
+ export function actionChangeLinkField(
8
+ el: Record<string, unknown>,
9
+ ): 'delivers_changes' | 'changes' {
10
+ return Object.prototype.hasOwnProperty.call(el, 'delivers_changes')
11
+ ? 'delivers_changes'
12
+ : 'changes';
13
+ }
@@ -8,7 +8,7 @@
8
8
  * - Typed string IDs per `IDS_AND_REFERENCES.md` (`FACTOR-1`,
9
9
  * `GOAL-RET-1`, `CHANGE-1`, `ACTION-ONBOARD-1` (legacy `ACTIVITY-*` accepted).
10
10
  * - Plural canonical-direction cross-references: `goal.factors`,
11
- * `change.goals`, `activity.changes`, `activity.goals`.
11
+ * `change.goals`, `action.delivers_changes`, `action.goals`.
12
12
  * - Per-notation validation codes `FGCA-001 … FGCA-015`.
13
13
  *
14
14
  * Returns the validation result plus, on success, an internal `FGCADoc`
@@ -36,6 +36,7 @@ import type {
36
36
  ValidationWarning,
37
37
  ValidationResult,
38
38
  } from '../validation-types.js';
39
+ import { actionChangeLinkField } from './action-change-ids.js';
39
40
 
40
41
  export interface CanonicalFGCAResult extends ValidationResult {
41
42
  /** On success, the internal-form FGCADoc derived from the canonical input. */
@@ -64,9 +65,10 @@ function asObjectArray(v: unknown): Array<Record<string, unknown>> | null {
64
65
  return v.map((el) => (el && typeof el === 'object' ? (el as Record<string, unknown>) : null) as Record<string, unknown>);
65
66
  }
66
67
 
67
- /** DGA mode (`view_config.layers.changes: off`) may omit the changes layer. */
68
- function isChangesLayerOff(raw: Record<string, unknown>): boolean {
69
- const vc = raw['view_config'];
68
+ /** DGA mode (`view_config.layers.changes: off`) omits the Changes column. */
69
+ export function isDgcaChangesLayerOff(input: unknown): boolean {
70
+ if (!input || typeof input !== 'object') return false;
71
+ const vc = (input as Record<string, unknown>)['view_config'];
70
72
  if (!vc || typeof vc !== 'object') return false;
71
73
  const layers = (vc as Record<string, unknown>)['layers'];
72
74
  if (!layers || typeof layers !== 'object') return false;
@@ -125,7 +127,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
125
127
  const factorsRaw = asObjectArray(raw['factors']);
126
128
  const goalsRaw = asObjectArray(raw['goals']);
127
129
  let changesRaw = asObjectArray(raw['changes']);
128
- if (changesRaw === null && raw['changes'] === undefined && isChangesLayerOff(raw)) {
130
+ if (changesRaw === null && raw['changes'] === undefined && isDgcaChangesLayerOff(raw)) {
129
131
  changesRaw = [];
130
132
  }
131
133
  const activitiesRaw = asObjectArray(raw['actions']);
@@ -253,11 +255,12 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
253
255
  };
254
256
  });
255
257
 
256
- // Build a reverse mapping: which changes does each activity reference (via
257
- // canonical activity.changes)? Used to populate the internal `change.activity_ids`.
258
+ // Build a reverse mapping: which changes does each action deliver (via
259
+ // canonical `delivers_changes`, or the pre-rename `changes` alias)?
258
260
  const activityChangesByCanonicalActivityId = new Map<string, string[]>();
259
261
  activities.forEach((el, i) => {
260
- const refIds = checkRefArray(el!, 'changes', changeIds, CHANGE_ID_RE, 'FGCA-010', `activities[${i}]`);
262
+ const field = actionChangeLinkField(el!);
263
+ const refIds = checkRefArray(el!, field, changeIds, CHANGE_ID_RE, 'FGCA-010', `activities[${i}]`);
261
264
  activityChangesByCanonicalActivityId.set(String(el!['id']), refIds);
262
265
  });
263
266
 
@@ -331,6 +334,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
331
334
  goals: internalGoals,
332
335
  changes: internalChanges,
333
336
  activities: internalActivities,
337
+ hideChanges: isDgcaChangesLayerOff(raw),
334
338
  };
335
339
 
336
340
  return { valid: true, errors, warnings, parsed };