@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.
- package/dist/activities/resolver.d.ts +3 -0
- package/dist/activities/resolver.js +39 -18
- package/dist/activities/validate.js +59 -0
- package/dist/canon-resolver-utils.d.ts +3 -0
- package/dist/canon-resolver-utils.js +16 -0
- package/dist/compliance/classify.js +1 -0
- package/dist/compliance/impact.d.ts +3 -1
- package/dist/compliance/impact.js +41 -13
- package/dist/compliance/types.d.ts +6 -0
- package/dist/compliance-verification-matrix/build.d.ts +17 -0
- package/dist/compliance-verification-matrix/build.js +65 -0
- package/dist/compliance-verification-matrix/csv.d.ts +6 -0
- package/dist/compliance-verification-matrix/csv.js +41 -0
- package/dist/compliance-verification-matrix/index.d.ts +3 -0
- package/dist/compliance-verification-matrix/index.js +2 -0
- package/dist/compliance-verification-matrix/types.d.ts +75 -0
- package/dist/compliance-verification-matrix/types.js +4 -0
- package/dist/fgca/action-change-ids.d.ts +7 -0
- package/dist/fgca/action-change-ids.js +11 -0
- package/dist/fgca/parse-canonical.d.ts +3 -1
- package/dist/fgca/parse-canonical.js +13 -8
- package/dist/fgca/resolver.d.ts +6 -2
- package/dist/fgca/resolver.js +12 -6
- package/dist/fgca/validate.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/process-blueprint/index.d.ts +3 -1
- package/dist/process-blueprint/index.js +1 -0
- package/dist/process-blueprint/layout.js +33 -29
- package/dist/process-blueprint/resolve-columns.d.ts +52 -0
- package/dist/process-blueprint/resolve-columns.js +138 -0
- package/dist/process-blueprint/types.d.ts +23 -5
- package/dist/process-blueprint/validate.d.ts +19 -1
- package/dist/process-blueprint/validate.js +73 -21
- package/dist/repo-validate/check-process-parent.d.ts +8 -0
- package/dist/repo-validate/check-process-parent.js +172 -0
- package/dist/repo-validate/index.d.ts +1 -0
- package/dist/repo-validate/index.js +1 -0
- package/dist/repo-validate/validate-repo.js +3 -0
- package/dist/webview/render-fgca.js +1 -1
- package/dist/webview/render-process.d.ts +19 -1
- package/dist/webview/render-process.js +75 -42
- package/package.json +1 -1
- package/src/activities/resolver.ts +40 -20
- package/src/activities/validate.ts +58 -0
- package/src/canon-resolver-utils.ts +19 -0
- package/src/compliance/classify.ts +1 -0
- package/src/compliance/impact.ts +41 -12
- package/src/compliance/types.ts +6 -0
- package/src/compliance-verification-matrix/build.ts +84 -0
- package/src/compliance-verification-matrix/csv.ts +47 -0
- package/src/compliance-verification-matrix/index.ts +7 -0
- package/src/compliance-verification-matrix/types.ts +85 -0
- package/src/fgca/action-change-ids.ts +13 -0
- package/src/fgca/parse-canonical.ts +12 -8
- package/src/fgca/resolver.ts +15 -6
- package/src/fgca/validate.ts +2 -0
- package/src/index.ts +1 -0
- package/src/process-blueprint/index.ts +11 -0
- package/src/process-blueprint/layout.ts +48 -29
- package/src/process-blueprint/resolve-columns.ts +147 -0
- package/src/process-blueprint/types.ts +19 -5
- package/src/process-blueprint/validate.ts +107 -21
- package/src/repo-validate/check-process-parent.ts +184 -0
- package/src/repo-validate/index.ts +5 -0
- package/src/repo-validate/validate-repo.ts +3 -0
- package/src/webview/render-fgca.ts +1 -1
- 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,
|
|
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.
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
143
|
-
[...
|
|
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
|
-
//
|
|
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
|
-
|
|
151
|
-
[...
|
|
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
|
-
//
|
|
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
|
-
|
|
165
|
-
[...
|
|
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),
|
package/src/compliance/impact.ts
CHANGED
|
@@ -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
|
|
654
|
-
*
|
|
655
|
-
* `
|
|
656
|
-
*
|
|
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(
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
|
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);
|
package/src/compliance/types.ts
CHANGED
|
@@ -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`, `
|
|
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`)
|
|
68
|
-
function
|
|
69
|
-
|
|
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 &&
|
|
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
|
|
257
|
-
// canonical
|
|
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
|
|
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 };
|