@transitrix/diagrams 1.8.8 → 1.8.11
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/index.d.ts +2 -0
- package/dist/activities/index.js +1 -0
- package/dist/activities/resolver.d.ts +54 -0
- package/dist/activities/resolver.js +172 -0
- package/dist/canon-resolver-utils.d.ts +21 -0
- package/dist/canon-resolver-utils.js +81 -0
- package/dist/fgca/resolver.js +5 -1
- package/dist/goals/index.d.ts +2 -0
- package/dist/goals/index.js +1 -0
- package/dist/goals/resolver.d.ts +63 -0
- package/dist/goals/resolver.js +154 -0
- package/package.json +1 -1
- package/src/activities/index.ts +2 -0
- package/src/activities/resolver.ts +208 -0
- package/src/canon-resolver-utils.ts +89 -0
- package/src/fgca/resolver.ts +5 -1
- package/src/goals/index.ts +2 -0
- package/src/goals/resolver.ts +186 -0
|
@@ -3,3 +3,5 @@ export { validateActivities } from './validate.js';
|
|
|
3
3
|
export { computeCpm } from './cpm.js';
|
|
4
4
|
export { layoutActivities } from './layout.js';
|
|
5
5
|
export { computeGanttLayout, isGanttUnavailable } from './gantt.js';
|
|
6
|
+
export { resolveAction, isActionViewDoc } from './resolver.js';
|
|
7
|
+
export type { ActionViewConfig, ActionCanonSources } from './resolver.js';
|
package/dist/activities/index.js
CHANGED
|
@@ -2,3 +2,4 @@ export { validateActivities } from './validate.js';
|
|
|
2
2
|
export { computeCpm } from './cpm.js';
|
|
3
3
|
export { layoutActivities } from './layout.js';
|
|
4
4
|
export { computeGanttLayout, isGanttUnavailable } from './gantt.js';
|
|
5
|
+
export { resolveAction, isActionViewDoc } from './resolver.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action Schedule canon resolver — assembles a flat canonical Action document
|
|
3
|
+
* from a view_config projection and a canon element store (methodology
|
|
4
|
+
* `notations/views/07-action.md` §4 "Projection form (Full tier —
|
|
5
|
+
* post-promotion)"). Mirrors `fgca/resolver.ts`'s `resolveFGCA`/`isFGCAViewDoc`
|
|
6
|
+
* pattern for the `action` notation.
|
|
7
|
+
*
|
|
8
|
+
* Output shape matches the flat inline form `validateActivities` expects:
|
|
9
|
+
* top-level `project` block + `actions[]`, with ACTION element fields carried
|
|
10
|
+
* through unchanged except `type` → `activity_type` (the internal Activity
|
|
11
|
+
* field name; `type` is the canonical element field per elements/24-action.md
|
|
12
|
+
* §2). Filesystem-free — callable from unit tests without vscode.
|
|
13
|
+
*/
|
|
14
|
+
export interface ActionViewConfig {
|
|
15
|
+
scope?: {
|
|
16
|
+
root_action?: string;
|
|
17
|
+
goals?: string[];
|
|
18
|
+
type_filter?: string[];
|
|
19
|
+
valid_at?: string | null;
|
|
20
|
+
};
|
|
21
|
+
schedule?: {
|
|
22
|
+
start_date?: string;
|
|
23
|
+
calendar?: {
|
|
24
|
+
working_days?: string[];
|
|
25
|
+
hours_per_day?: number;
|
|
26
|
+
holidays?: string[];
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
display?: {
|
|
30
|
+
view?: 'network' | 'gantt' | 'both';
|
|
31
|
+
depth?: number | null;
|
|
32
|
+
collapsed?: string[];
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export interface ActionCanonSources {
|
|
36
|
+
elements: unknown[];
|
|
37
|
+
/** `canon/relations/**` docs (`notation: relation`). Optional — omit for
|
|
38
|
+
* canon stores with no relations tree; `action_goal` links then fall back
|
|
39
|
+
* to each element's inline `goals[]`. */
|
|
40
|
+
relations?: unknown[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Returns true when the parsed view document uses the canon-projection form
|
|
44
|
+
* (`view_config` key present, no inline `actions[]`). Used by the preview and
|
|
45
|
+
* CLI to decide whether to run the resolver or fall back to direct
|
|
46
|
+
* `validateActivities` on the inline document.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isActionViewDoc(parsed: unknown): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Assemble a flat canonical Action document from the canon ACTION element
|
|
51
|
+
* store, filtered by the view document's `view_config.scope`. The return
|
|
52
|
+
* value is ready to pass to `validateActivities`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function resolveAction(viewDoc: unknown, sources: ActionCanonSources): Record<string, unknown>;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action Schedule canon resolver — assembles a flat canonical Action document
|
|
3
|
+
* from a view_config projection and a canon element store (methodology
|
|
4
|
+
* `notations/views/07-action.md` §4 "Projection form (Full tier —
|
|
5
|
+
* post-promotion)"). Mirrors `fgca/resolver.ts`'s `resolveFGCA`/`isFGCAViewDoc`
|
|
6
|
+
* pattern for the `action` notation.
|
|
7
|
+
*
|
|
8
|
+
* Output shape matches the flat inline form `validateActivities` expects:
|
|
9
|
+
* top-level `project` block + `actions[]`, with ACTION element fields carried
|
|
10
|
+
* through unchanged except `type` → `activity_type` (the internal Activity
|
|
11
|
+
* field name; `type` is the canonical element field per elements/24-action.md
|
|
12
|
+
* §2). Filesystem-free — callable from unit tests without vscode.
|
|
13
|
+
*/
|
|
14
|
+
import { isObject, str, strArray, descendantsOf, stripEnvelope } from '../canon-resolver-utils.js';
|
|
15
|
+
/** ACTION elements by id. `notation: action` is canonical; `notation: activity`
|
|
16
|
+
* is the deprecated pre-2026-06-25 alias (elements/24-action.md §"Deprecated
|
|
17
|
+
* alias") — accepted the same way `collectByNotation` callers elsewhere do. */
|
|
18
|
+
function collectActionElements(docs) {
|
|
19
|
+
const out = new Map();
|
|
20
|
+
for (const doc of docs) {
|
|
21
|
+
if (!isObject(doc))
|
|
22
|
+
continue;
|
|
23
|
+
const notation = str(doc['notation']);
|
|
24
|
+
if (notation !== 'action' && notation !== 'activity')
|
|
25
|
+
continue;
|
|
26
|
+
const id = str(doc['id']);
|
|
27
|
+
if (!id)
|
|
28
|
+
continue;
|
|
29
|
+
if (!out.has(id))
|
|
30
|
+
out.set(id, doc); // first definition wins
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* GOAL ids reached by an *active* `action_goal` (or legacy `activity_goal`)
|
|
36
|
+
* relation originating at `fromId`. A relation with a non-null `valid_to` has
|
|
37
|
+
* ended (the action was re-aimed) and is excluded. Mirrors
|
|
38
|
+
* `activity-card/resolver.ts`'s `activeActivityGoals` — the canonical, preferred
|
|
39
|
+
* source per elements/24-action.md §3 ("renderers prefer REL files when both
|
|
40
|
+
* are present").
|
|
41
|
+
*/
|
|
42
|
+
function activeActionGoalRelations(relations, fromId) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const doc of relations) {
|
|
45
|
+
if (!isObject(doc))
|
|
46
|
+
continue;
|
|
47
|
+
if (str(doc['notation']) !== 'relation')
|
|
48
|
+
continue;
|
|
49
|
+
const type = str(doc['type']);
|
|
50
|
+
if (type !== 'action_goal' && type !== 'activity_goal')
|
|
51
|
+
continue;
|
|
52
|
+
if (str(doc['from']) !== fromId)
|
|
53
|
+
continue;
|
|
54
|
+
const to = str(doc['to']);
|
|
55
|
+
if (!to)
|
|
56
|
+
continue;
|
|
57
|
+
const validTo = doc['valid_to'];
|
|
58
|
+
if (validTo !== undefined && validTo !== null)
|
|
59
|
+
continue; // ended relation
|
|
60
|
+
if (!out.includes(to))
|
|
61
|
+
out.push(to);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Returns true when the parsed view document uses the canon-projection form
|
|
67
|
+
* (`view_config` key present, no inline `actions[]`). Used by the preview and
|
|
68
|
+
* CLI to decide whether to run the resolver or fall back to direct
|
|
69
|
+
* `validateActivities` on the inline document.
|
|
70
|
+
*/
|
|
71
|
+
export function isActionViewDoc(parsed) {
|
|
72
|
+
if (!isObject(parsed))
|
|
73
|
+
return false;
|
|
74
|
+
return 'view_config' in parsed && !('actions' in parsed);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Assemble a flat canonical Action document from the canon ACTION element
|
|
78
|
+
* store, filtered by the view document's `view_config.scope`. The return
|
|
79
|
+
* value is ready to pass to `validateActivities`.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveAction(viewDoc, sources) {
|
|
82
|
+
if (!isObject(viewDoc)) {
|
|
83
|
+
return { notation: 'action', actions: [] };
|
|
84
|
+
}
|
|
85
|
+
const vc = isObject(viewDoc['view_config']) ? viewDoc['view_config'] : {};
|
|
86
|
+
const scope = isObject(vc['scope']) ? vc['scope'] : {};
|
|
87
|
+
const schedule = isObject(vc['schedule']) ? vc['schedule'] : {};
|
|
88
|
+
const allActions = collectActionElements(sources.elements);
|
|
89
|
+
const relations = sources.relations ?? [];
|
|
90
|
+
/** Effective `goals[]` for an action — the active `action_goal` REL targets
|
|
91
|
+
* when any exist, else the element's transitional inline `goals[]`. */
|
|
92
|
+
function effectiveGoals(id, el) {
|
|
93
|
+
const relGoals = activeActionGoalRelations(relations, id);
|
|
94
|
+
return relGoals.length > 0 ? relGoals : strArray(el['goals']);
|
|
95
|
+
}
|
|
96
|
+
// 1. Scope by root_action (this action + its descendants), or the full
|
|
97
|
+
// catalogue when omitted (§5.1: "Omit to include elements from the full
|
|
98
|
+
// catalogue (or use goals/type_filter for other scoping)").
|
|
99
|
+
const rootAction = str(scope['root_action']);
|
|
100
|
+
let candidateIds;
|
|
101
|
+
if (rootAction) {
|
|
102
|
+
candidateIds = allActions.has(rootAction) ? descendantsOf(rootAction, allActions) : new Set();
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
candidateIds = new Set(allActions.keys());
|
|
106
|
+
}
|
|
107
|
+
// 2. Narrow by goals — only actions linked to at least one listed GOAL
|
|
108
|
+
// (via an active action_goal relation, or inline goals[] when no relation
|
|
109
|
+
// exists for that action).
|
|
110
|
+
const goalsFilter = strArray(scope['goals']);
|
|
111
|
+
if (goalsFilter.length > 0) {
|
|
112
|
+
candidateIds = new Set([...candidateIds].filter((id) => effectiveGoals(id, allActions.get(id)).some((g) => goalsFilter.includes(g))));
|
|
113
|
+
}
|
|
114
|
+
// 3. Narrow by type_filter — only actions of a listed type.
|
|
115
|
+
const typeFilter = strArray(scope['type_filter']);
|
|
116
|
+
if (typeFilter.length > 0) {
|
|
117
|
+
candidateIds = new Set([...candidateIds].filter((id) => {
|
|
118
|
+
const el = allActions.get(id);
|
|
119
|
+
const t = str(el?.['type']) ?? str(el?.['activity_type']);
|
|
120
|
+
return t !== undefined && typeFilter.includes(t);
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
// 4. Narrow by valid_at — only actions in effect on that date. Actions with
|
|
124
|
+
// no valid_from are not lifecycle-tracked and are kept (permissive default,
|
|
125
|
+
// matching the resolver's general "missing = not excluded" stance).
|
|
126
|
+
const validAt = str(scope['valid_at']);
|
|
127
|
+
if (validAt) {
|
|
128
|
+
candidateIds = new Set([...candidateIds].filter((id) => {
|
|
129
|
+
const el = allActions.get(id);
|
|
130
|
+
const from = str(el?.['valid_from']);
|
|
131
|
+
const to = str(el?.['valid_to']);
|
|
132
|
+
if (from && validAt < from)
|
|
133
|
+
return false;
|
|
134
|
+
if (to && validAt > to)
|
|
135
|
+
return false;
|
|
136
|
+
return true;
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
// Element fields map onto Activity fields unchanged, except the canonical
|
|
140
|
+
// `type` (elements/24-action.md §2) which becomes the internal `activity_type`
|
|
141
|
+
// (types.ts `Activity.activity_type`), and `goals` which is resolved via
|
|
142
|
+
// `effectiveGoals` (REL-preferred). The admission/lifecycle envelope fields
|
|
143
|
+
// carry no schedule/render meaning for `validateActivities` and are dropped.
|
|
144
|
+
const selectedActions = [...candidateIds].map((id) => {
|
|
145
|
+
const el = allActions.get(id);
|
|
146
|
+
const { type, activity_type, goals: _inlineGoals, ...rest } = stripEnvelope(el);
|
|
147
|
+
const resolvedType = type ?? activity_type;
|
|
148
|
+
return {
|
|
149
|
+
...rest,
|
|
150
|
+
id,
|
|
151
|
+
goals: effectiveGoals(id, el),
|
|
152
|
+
...(resolvedType !== undefined ? { activity_type: resolvedType } : {}),
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
const project = {};
|
|
156
|
+
const startDate = str(schedule['start_date']);
|
|
157
|
+
if (startDate)
|
|
158
|
+
project['start_date'] = startDate;
|
|
159
|
+
if (isObject(schedule['calendar']))
|
|
160
|
+
project['calendar'] = schedule['calendar'];
|
|
161
|
+
const out = {
|
|
162
|
+
notation: 'action',
|
|
163
|
+
id: viewDoc['id'],
|
|
164
|
+
title: viewDoc['name'],
|
|
165
|
+
spec_version: viewDoc['spec_version'],
|
|
166
|
+
description: viewDoc['description'],
|
|
167
|
+
actions: selectedActions,
|
|
168
|
+
};
|
|
169
|
+
if (Object.keys(project).length > 0)
|
|
170
|
+
out['project'] = project;
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared primitives for the per-notation canon-projection resolvers
|
|
3
|
+
* (activities/resolver.ts, goals/resolver.ts — fgca/resolver.ts predates this
|
|
4
|
+
* module and keeps its own copies). Each resolver assembles a flat document
|
|
5
|
+
* from a canon element store filtered by a view_config.scope block; these
|
|
6
|
+
* helpers are the parts that don't vary by notation.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isObject(v: unknown): v is Record<string, unknown>;
|
|
9
|
+
/** A non-empty string, trimmed. */
|
|
10
|
+
export declare function str(v: unknown): string | undefined;
|
|
11
|
+
export declare function strArray(v: unknown): string[];
|
|
12
|
+
/** `rootId` and every element reachable by following `parent` links downward,
|
|
13
|
+
* per the element map's own `parent` field. Used to scope a projection to a
|
|
14
|
+
* root element and its transitive descendants (WBS / goal hierarchy / etc). */
|
|
15
|
+
export declare function descendantsOf(rootId: string, all: Map<string, Record<string, unknown>>): Set<string>;
|
|
16
|
+
/** Depth of `id` in its `parent` chain within `all` (root = 0, each step up
|
|
17
|
+
* adds 1). Cycle-safe (a cycle resolves the entering node to depth 0) and
|
|
18
|
+
* memoized across calls via the shared `memo` map. */
|
|
19
|
+
export declare function parentChainDepth(id: string, all: Map<string, Record<string, unknown>>, memo?: Map<string, number>, visiting?: Set<string>): number;
|
|
20
|
+
/** `el` with the admission/lifecycle envelope fields removed. */
|
|
21
|
+
export declare function stripEnvelope(el: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared primitives for the per-notation canon-projection resolvers
|
|
3
|
+
* (activities/resolver.ts, goals/resolver.ts — fgca/resolver.ts predates this
|
|
4
|
+
* module and keeps its own copies). Each resolver assembles a flat document
|
|
5
|
+
* from a canon element store filtered by a view_config.scope block; these
|
|
6
|
+
* helpers are the parts that don't vary by notation.
|
|
7
|
+
*/
|
|
8
|
+
export function isObject(v) {
|
|
9
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
10
|
+
}
|
|
11
|
+
/** A non-empty string, trimmed. */
|
|
12
|
+
export function str(v) {
|
|
13
|
+
if (typeof v !== 'string')
|
|
14
|
+
return undefined;
|
|
15
|
+
const t = v.trim();
|
|
16
|
+
return t.length > 0 ? t : undefined;
|
|
17
|
+
}
|
|
18
|
+
export function strArray(v) {
|
|
19
|
+
if (!Array.isArray(v))
|
|
20
|
+
return [];
|
|
21
|
+
return v.filter((x) => typeof x === 'string' && x.trim().length > 0);
|
|
22
|
+
}
|
|
23
|
+
/** `rootId` and every element reachable by following `parent` links downward,
|
|
24
|
+
* per the element map's own `parent` field. Used to scope a projection to a
|
|
25
|
+
* root element and its transitive descendants (WBS / goal hierarchy / etc). */
|
|
26
|
+
export function descendantsOf(rootId, all) {
|
|
27
|
+
const childrenByParent = new Map();
|
|
28
|
+
for (const [id, el] of all) {
|
|
29
|
+
const parent = str(el['parent']);
|
|
30
|
+
if (!parent)
|
|
31
|
+
continue;
|
|
32
|
+
const list = childrenByParent.get(parent) ?? [];
|
|
33
|
+
list.push(id);
|
|
34
|
+
childrenByParent.set(parent, list);
|
|
35
|
+
}
|
|
36
|
+
const result = new Set();
|
|
37
|
+
const queue = [rootId];
|
|
38
|
+
while (queue.length > 0) {
|
|
39
|
+
const id = queue.shift();
|
|
40
|
+
if (result.has(id))
|
|
41
|
+
continue;
|
|
42
|
+
result.add(id);
|
|
43
|
+
for (const child of childrenByParent.get(id) ?? [])
|
|
44
|
+
queue.push(child);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
/** Depth of `id` in its `parent` chain within `all` (root = 0, each step up
|
|
49
|
+
* adds 1). Cycle-safe (a cycle resolves the entering node to depth 0) and
|
|
50
|
+
* memoized across calls via the shared `memo` map. */
|
|
51
|
+
export function parentChainDepth(id, all, memo = new Map(), visiting = new Set()) {
|
|
52
|
+
const cached = memo.get(id);
|
|
53
|
+
if (cached !== undefined)
|
|
54
|
+
return cached;
|
|
55
|
+
if (visiting.has(id)) {
|
|
56
|
+
memo.set(id, 0);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
const el = all.get(id);
|
|
60
|
+
const parent = el ? str(el['parent']) : undefined;
|
|
61
|
+
if (!parent || !all.has(parent)) {
|
|
62
|
+
memo.set(id, 0);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
visiting.add(id);
|
|
66
|
+
const level = parentChainDepth(parent, all, memo, visiting) + 1;
|
|
67
|
+
visiting.delete(id);
|
|
68
|
+
memo.set(id, level);
|
|
69
|
+
return level;
|
|
70
|
+
}
|
|
71
|
+
/** The admission/lifecycle envelope fields (CONTRACT.md §6-7) every canon
|
|
72
|
+
* element carries but that carry no rendering/validation meaning once
|
|
73
|
+
* projected into a flat notation-specific document. */
|
|
74
|
+
const ENVELOPE_FIELDS = ['notation', 'zone', 'admitted_at', 'admitted_by', 'gate_checks', 'valid_from', 'valid_to'];
|
|
75
|
+
/** `el` with the admission/lifecycle envelope fields removed. */
|
|
76
|
+
export function stripEnvelope(el) {
|
|
77
|
+
const rest = { ...el };
|
|
78
|
+
for (const field of ENVELOPE_FIELDS)
|
|
79
|
+
delete rest[field];
|
|
80
|
+
return rest;
|
|
81
|
+
}
|
package/dist/fgca/resolver.js
CHANGED
|
@@ -66,7 +66,11 @@ export function resolveFGCA(viewDoc, sources) {
|
|
|
66
66
|
const factorElems = new Map([...factorElemsLegacy, ...factorElemsNew]);
|
|
67
67
|
const goalElems = collectByNotation(sources.elements, 'goal');
|
|
68
68
|
const changeElems = collectByNotation(sources.elements, 'change');
|
|
69
|
-
|
|
69
|
+
// `action` is canonical since methodology 1.0; `activity` is the deprecated
|
|
70
|
+
// pre-rename alias, still accepted (elements/24-action.md §"Deprecated alias").
|
|
71
|
+
const activityElemsLegacy = collectByNotation(sources.elements, 'activity');
|
|
72
|
+
const activityElemsNew = collectByNotation(sources.elements, 'action');
|
|
73
|
+
const activityElems = new Map([...activityElemsLegacy, ...activityElemsNew]);
|
|
70
74
|
// 1. Select goal set
|
|
71
75
|
const goalsFilter = str(goalsConf['filter']) ?? 'all';
|
|
72
76
|
let selectedGoals;
|
package/dist/goals/index.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export type { Goal, GoalType, GoalTree, ImpactType, ValidationResult, Validation
|
|
|
2
2
|
export { validateGoalTree } from './validate.js';
|
|
3
3
|
export { parseCanonicalGoals } from './parse-canonical.js';
|
|
4
4
|
export type { CanonicalGoalsResult } from './parse-canonical.js';
|
|
5
|
+
export { resolveGoals, isGoalsViewDoc } from './resolver.js';
|
|
6
|
+
export type { GoalsViewConfig, GoalsCanonSources } from './resolver.js';
|
|
5
7
|
export { layoutGoalTree, selectScopedGoals } from './layout.js';
|
|
6
8
|
export { reparent, addChild, deleteWithDescendants, moveToBacklog, restoreFromBacklog } from './mutations.js';
|
|
7
9
|
export { GoalTreeView } from './GoalTreeView.js';
|
package/dist/goals/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { validateGoalTree } from './validate.js';
|
|
2
2
|
export { parseCanonicalGoals } from './parse-canonical.js';
|
|
3
|
+
export { resolveGoals, isGoalsViewDoc } from './resolver.js';
|
|
3
4
|
export { layoutGoalTree, selectScopedGoals } from './layout.js';
|
|
4
5
|
export { reparent, addChild, deleteWithDescendants, moveToBacklog, restoreFromBacklog } from './mutations.js';
|
|
5
6
|
export { GoalTreeView } from './GoalTreeView.js';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goals Tree canon resolver — assembles a flat canonical Goals document from
|
|
3
|
+
* a view_config projection and a canon element store (methodology
|
|
4
|
+
* `notations/views/04-goals.md` §4 "Projection form (Full tier —
|
|
5
|
+
* post-promotion)"). Mirrors `fgca/resolver.ts`'s `resolveFGCA`/`isFGCAViewDoc`
|
|
6
|
+
* and `activities/resolver.ts`'s `resolveAction`/`isActionViewDoc` pattern
|
|
7
|
+
* for the `goals` notation.
|
|
8
|
+
*
|
|
9
|
+
* Output shape matches the flat inline form `parseCanonicalGoals` expects:
|
|
10
|
+
* top-level `goal_types[]` + `goals[]`. Filesystem-free — callable from unit
|
|
11
|
+
* tests without vscode.
|
|
12
|
+
*
|
|
13
|
+
* `goal_types[]` synthesis: when `view_config.goal_types` is present it is
|
|
14
|
+
* used as-is (the documented projection-form shape always includes it) and
|
|
15
|
+
* each goal keeps its own authored `level`, validated against that table as
|
|
16
|
+
* usual. When absent, §5.2 says the renderer "infers levels from the parent
|
|
17
|
+
* chain depth (root goals at 0, each step deeper adds 1)" — this resolver
|
|
18
|
+
* implements that literally (`parentChainDepth`), overriding each selected
|
|
19
|
+
* goal's `level` with its structural depth, rather than trusting the
|
|
20
|
+
* element's own stored `level` field. A stored `level` can't be trusted here:
|
|
21
|
+
* nothing enforces cross-element consistency on it (ELEMENT_PRIMITIVES.md
|
|
22
|
+
* §7.2: `type`/`level` are independent, both-optional per-element fields),
|
|
23
|
+
* and worse, using it made the synthesized table's chosen level depend on
|
|
24
|
+
* which element the canon loader happened to enumerate first — the CLI
|
|
25
|
+
* (`readdirSync`, unsorted) and the VS Code preview
|
|
26
|
+
* (`vscode.workspace.fs.readDirectory`, unsorted) can enumerate the same
|
|
27
|
+
* canon store in different orders, so the two surfaces could disagree on
|
|
28
|
+
* `GOALS-008` findings for identical input. Structural depth has no such
|
|
29
|
+
* dependency. A goal with no `type` still surfaces as a genuine `GOALS-006`
|
|
30
|
+
* finding — this resolver never synthesizes a type label to paper over one.
|
|
31
|
+
*/
|
|
32
|
+
export interface GoalsViewConfig {
|
|
33
|
+
scope?: {
|
|
34
|
+
root_goal?: string | null;
|
|
35
|
+
period?: string | null;
|
|
36
|
+
type_filter?: string[] | null;
|
|
37
|
+
valid_at?: string | null;
|
|
38
|
+
};
|
|
39
|
+
goal_types?: Array<{
|
|
40
|
+
name: string;
|
|
41
|
+
level: number;
|
|
42
|
+
}>;
|
|
43
|
+
display?: {
|
|
44
|
+
depth?: number | null;
|
|
45
|
+
collapsed?: string[];
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export interface GoalsCanonSources {
|
|
49
|
+
elements: unknown[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Returns true when the parsed view document uses the canon-projection form
|
|
53
|
+
* (`view_config` key present, no inline `goals[]`). Used by the preview and
|
|
54
|
+
* CLI to decide whether to run the resolver or fall back to direct
|
|
55
|
+
* `parseCanonicalGoals` on the inline document.
|
|
56
|
+
*/
|
|
57
|
+
export declare function isGoalsViewDoc(parsed: unknown): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Assemble a flat canonical Goals document from the canon GOAL element
|
|
60
|
+
* store, filtered by the view document's `view_config.scope`. The return
|
|
61
|
+
* value is ready to pass to `parseCanonicalGoals`.
|
|
62
|
+
*/
|
|
63
|
+
export declare function resolveGoals(viewDoc: unknown, sources: GoalsCanonSources): Record<string, unknown>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goals Tree canon resolver — assembles a flat canonical Goals document from
|
|
3
|
+
* a view_config projection and a canon element store (methodology
|
|
4
|
+
* `notations/views/04-goals.md` §4 "Projection form (Full tier —
|
|
5
|
+
* post-promotion)"). Mirrors `fgca/resolver.ts`'s `resolveFGCA`/`isFGCAViewDoc`
|
|
6
|
+
* and `activities/resolver.ts`'s `resolveAction`/`isActionViewDoc` pattern
|
|
7
|
+
* for the `goals` notation.
|
|
8
|
+
*
|
|
9
|
+
* Output shape matches the flat inline form `parseCanonicalGoals` expects:
|
|
10
|
+
* top-level `goal_types[]` + `goals[]`. Filesystem-free — callable from unit
|
|
11
|
+
* tests without vscode.
|
|
12
|
+
*
|
|
13
|
+
* `goal_types[]` synthesis: when `view_config.goal_types` is present it is
|
|
14
|
+
* used as-is (the documented projection-form shape always includes it) and
|
|
15
|
+
* each goal keeps its own authored `level`, validated against that table as
|
|
16
|
+
* usual. When absent, §5.2 says the renderer "infers levels from the parent
|
|
17
|
+
* chain depth (root goals at 0, each step deeper adds 1)" — this resolver
|
|
18
|
+
* implements that literally (`parentChainDepth`), overriding each selected
|
|
19
|
+
* goal's `level` with its structural depth, rather than trusting the
|
|
20
|
+
* element's own stored `level` field. A stored `level` can't be trusted here:
|
|
21
|
+
* nothing enforces cross-element consistency on it (ELEMENT_PRIMITIVES.md
|
|
22
|
+
* §7.2: `type`/`level` are independent, both-optional per-element fields),
|
|
23
|
+
* and worse, using it made the synthesized table's chosen level depend on
|
|
24
|
+
* which element the canon loader happened to enumerate first — the CLI
|
|
25
|
+
* (`readdirSync`, unsorted) and the VS Code preview
|
|
26
|
+
* (`vscode.workspace.fs.readDirectory`, unsorted) can enumerate the same
|
|
27
|
+
* canon store in different orders, so the two surfaces could disagree on
|
|
28
|
+
* `GOALS-008` findings for identical input. Structural depth has no such
|
|
29
|
+
* dependency. A goal with no `type` still surfaces as a genuine `GOALS-006`
|
|
30
|
+
* finding — this resolver never synthesizes a type label to paper over one.
|
|
31
|
+
*/
|
|
32
|
+
import { isObject, str, strArray, descendantsOf, parentChainDepth, stripEnvelope } from '../canon-resolver-utils.js';
|
|
33
|
+
/** GOAL elements by id (`notation: goal`). */
|
|
34
|
+
function collectGoalElements(docs) {
|
|
35
|
+
const out = new Map();
|
|
36
|
+
for (const doc of docs) {
|
|
37
|
+
if (!isObject(doc))
|
|
38
|
+
continue;
|
|
39
|
+
if (str(doc['notation']) !== 'goal')
|
|
40
|
+
continue;
|
|
41
|
+
const id = str(doc['id']);
|
|
42
|
+
if (!id)
|
|
43
|
+
continue;
|
|
44
|
+
if (!out.has(id))
|
|
45
|
+
out.set(id, doc); // first definition wins
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Returns true when the parsed view document uses the canon-projection form
|
|
51
|
+
* (`view_config` key present, no inline `goals[]`). Used by the preview and
|
|
52
|
+
* CLI to decide whether to run the resolver or fall back to direct
|
|
53
|
+
* `parseCanonicalGoals` on the inline document.
|
|
54
|
+
*/
|
|
55
|
+
export function isGoalsViewDoc(parsed) {
|
|
56
|
+
if (!isObject(parsed))
|
|
57
|
+
return false;
|
|
58
|
+
return 'view_config' in parsed && !('goals' in parsed);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Assemble a flat canonical Goals document from the canon GOAL element
|
|
62
|
+
* store, filtered by the view document's `view_config.scope`. The return
|
|
63
|
+
* value is ready to pass to `parseCanonicalGoals`.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveGoals(viewDoc, sources) {
|
|
66
|
+
if (!isObject(viewDoc)) {
|
|
67
|
+
return { notation: 'goals', goal_types: [], goals: [] };
|
|
68
|
+
}
|
|
69
|
+
const vc = isObject(viewDoc['view_config']) ? viewDoc['view_config'] : {};
|
|
70
|
+
const scope = isObject(vc['scope']) ? vc['scope'] : {};
|
|
71
|
+
const allGoals = collectGoalElements(sources.elements);
|
|
72
|
+
// 1. Scope by root_goal (this goal + its descendants), or every goal when
|
|
73
|
+
// omitted (§5.1: "Omit to show all root goals").
|
|
74
|
+
const rootGoal = str(scope['root_goal']);
|
|
75
|
+
let candidateIds;
|
|
76
|
+
if (rootGoal) {
|
|
77
|
+
candidateIds = allGoals.has(rootGoal) ? descendantsOf(rootGoal, allGoals) : new Set();
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
candidateIds = new Set(allGoals.keys());
|
|
81
|
+
}
|
|
82
|
+
// 2. Narrow by period — matched against the element's own `period` field
|
|
83
|
+
// when present (the GOAL element schema, ELEMENT_PRIMITIVES.md §7.2, does
|
|
84
|
+
// not define this field explicitly; treated as a forward-compatible tag).
|
|
85
|
+
const period = str(scope['period']);
|
|
86
|
+
if (period) {
|
|
87
|
+
candidateIds = new Set([...candidateIds].filter((id) => str(allGoals.get(id)?.['period']) === period));
|
|
88
|
+
}
|
|
89
|
+
// 3. Narrow by type_filter — only goals of a listed type.
|
|
90
|
+
const typeFilter = strArray(scope['type_filter']);
|
|
91
|
+
if (typeFilter.length > 0) {
|
|
92
|
+
candidateIds = new Set([...candidateIds].filter((id) => {
|
|
93
|
+
const t = str(allGoals.get(id)?.['type']);
|
|
94
|
+
return t !== undefined && typeFilter.includes(t);
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
// 4. Narrow by valid_at — only goals in effect on that date. Goals with no
|
|
98
|
+
// valid_from are not lifecycle-tracked and are kept (permissive default,
|
|
99
|
+
// matching the action/dgca resolvers' stance).
|
|
100
|
+
const validAt = str(scope['valid_at']);
|
|
101
|
+
if (validAt) {
|
|
102
|
+
candidateIds = new Set([...candidateIds].filter((id) => {
|
|
103
|
+
const el = allGoals.get(id);
|
|
104
|
+
const from = str(el?.['valid_from']);
|
|
105
|
+
const to = str(el?.['valid_to']);
|
|
106
|
+
if (from && validAt < from)
|
|
107
|
+
return false;
|
|
108
|
+
if (to && validAt > to)
|
|
109
|
+
return false;
|
|
110
|
+
return true;
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
const explicitGoalTypes = Array.isArray(vc['goal_types'])
|
|
114
|
+
? vc['goal_types'].filter(isObject)
|
|
115
|
+
: undefined;
|
|
116
|
+
// Element fields map onto Goal fields unchanged — the admission/lifecycle
|
|
117
|
+
// envelope fields carry no rendering meaning for `parseCanonicalGoals` and
|
|
118
|
+
// are dropped.
|
|
119
|
+
let selectedGoals;
|
|
120
|
+
let goalTypes;
|
|
121
|
+
if (explicitGoalTypes) {
|
|
122
|
+
// Main-line case: goals keep their own authored `level`, validated
|
|
123
|
+
// against the explicit table by parseCanonicalGoals as usual.
|
|
124
|
+
selectedGoals = [...candidateIds].map((id) => ({ ...stripEnvelope(allGoals.get(id)), id }));
|
|
125
|
+
goalTypes = explicitGoalTypes;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
// §5.2 fallback: level is the goal's structural depth in the parent
|
|
129
|
+
// chain, not its (untrustworthy, order-dependent) stored `level` field.
|
|
130
|
+
const depthMemo = new Map();
|
|
131
|
+
selectedGoals = [...candidateIds].map((id) => {
|
|
132
|
+
const { level: _level, ...rest } = stripEnvelope(allGoals.get(id));
|
|
133
|
+
return { ...rest, id, level: parentChainDepth(id, allGoals, depthMemo) };
|
|
134
|
+
});
|
|
135
|
+
const seen = new Map();
|
|
136
|
+
for (const g of selectedGoals) {
|
|
137
|
+
const name = typeof g['type'] === 'string' ? g['type'] : undefined;
|
|
138
|
+
if (name !== undefined && !seen.has(name)) {
|
|
139
|
+
seen.set(name, g['level']);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
goalTypes = [...seen.entries()].map(([name, level]) => ({ name, level }));
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
notation: 'goals',
|
|
146
|
+
id: viewDoc['id'],
|
|
147
|
+
name: viewDoc['name'],
|
|
148
|
+
spec_version: viewDoc['spec_version'],
|
|
149
|
+
description: viewDoc['description'],
|
|
150
|
+
period: viewDoc['period'],
|
|
151
|
+
goal_types: goalTypes,
|
|
152
|
+
goals: selectedGoals,
|
|
153
|
+
};
|
|
154
|
+
}
|
package/package.json
CHANGED
package/src/activities/index.ts
CHANGED
|
@@ -26,3 +26,5 @@ export { validateActivities } from './validate.js';
|
|
|
26
26
|
export { computeCpm } from './cpm.js';
|
|
27
27
|
export { layoutActivities } from './layout.js';
|
|
28
28
|
export { computeGanttLayout, isGanttUnavailable } from './gantt.js';
|
|
29
|
+
export { resolveAction, isActionViewDoc } from './resolver.js';
|
|
30
|
+
export type { ActionViewConfig, ActionCanonSources } from './resolver.js';
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action Schedule canon resolver — assembles a flat canonical Action document
|
|
3
|
+
* from a view_config projection and a canon element store (methodology
|
|
4
|
+
* `notations/views/07-action.md` §4 "Projection form (Full tier —
|
|
5
|
+
* post-promotion)"). Mirrors `fgca/resolver.ts`'s `resolveFGCA`/`isFGCAViewDoc`
|
|
6
|
+
* pattern for the `action` notation.
|
|
7
|
+
*
|
|
8
|
+
* Output shape matches the flat inline form `validateActivities` expects:
|
|
9
|
+
* top-level `project` block + `actions[]`, with ACTION element fields carried
|
|
10
|
+
* through unchanged except `type` → `activity_type` (the internal Activity
|
|
11
|
+
* field name; `type` is the canonical element field per elements/24-action.md
|
|
12
|
+
* §2). Filesystem-free — callable from unit tests without vscode.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isObject, str, strArray, descendantsOf, stripEnvelope } from '../canon-resolver-utils.js';
|
|
16
|
+
|
|
17
|
+
export interface ActionViewConfig {
|
|
18
|
+
scope?: {
|
|
19
|
+
root_action?: string;
|
|
20
|
+
goals?: string[];
|
|
21
|
+
type_filter?: string[];
|
|
22
|
+
valid_at?: string | null;
|
|
23
|
+
};
|
|
24
|
+
schedule?: {
|
|
25
|
+
start_date?: string;
|
|
26
|
+
calendar?: {
|
|
27
|
+
working_days?: string[];
|
|
28
|
+
hours_per_day?: number;
|
|
29
|
+
holidays?: string[];
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
display?: {
|
|
33
|
+
view?: 'network' | 'gantt' | 'both';
|
|
34
|
+
depth?: number | null;
|
|
35
|
+
collapsed?: string[];
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ActionCanonSources {
|
|
40
|
+
elements: unknown[];
|
|
41
|
+
/** `canon/relations/**` docs (`notation: relation`). Optional — omit for
|
|
42
|
+
* canon stores with no relations tree; `action_goal` links then fall back
|
|
43
|
+
* to each element's inline `goals[]`. */
|
|
44
|
+
relations?: unknown[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** ACTION elements by id. `notation: action` is canonical; `notation: activity`
|
|
48
|
+
* is the deprecated pre-2026-06-25 alias (elements/24-action.md §"Deprecated
|
|
49
|
+
* alias") — accepted the same way `collectByNotation` callers elsewhere do. */
|
|
50
|
+
function collectActionElements(docs: unknown[]): Map<string, Record<string, unknown>> {
|
|
51
|
+
const out = new Map<string, Record<string, unknown>>();
|
|
52
|
+
for (const doc of docs) {
|
|
53
|
+
if (!isObject(doc)) continue;
|
|
54
|
+
const notation = str(doc['notation']);
|
|
55
|
+
if (notation !== 'action' && notation !== 'activity') continue;
|
|
56
|
+
const id = str(doc['id']);
|
|
57
|
+
if (!id) continue;
|
|
58
|
+
if (!out.has(id)) out.set(id, doc); // first definition wins
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* GOAL ids reached by an *active* `action_goal` (or legacy `activity_goal`)
|
|
65
|
+
* relation originating at `fromId`. A relation with a non-null `valid_to` has
|
|
66
|
+
* ended (the action was re-aimed) and is excluded. Mirrors
|
|
67
|
+
* `activity-card/resolver.ts`'s `activeActivityGoals` — the canonical, preferred
|
|
68
|
+
* source per elements/24-action.md §3 ("renderers prefer REL files when both
|
|
69
|
+
* are present").
|
|
70
|
+
*/
|
|
71
|
+
function activeActionGoalRelations(relations: unknown[], fromId: string): string[] {
|
|
72
|
+
const out: string[] = [];
|
|
73
|
+
for (const doc of relations) {
|
|
74
|
+
if (!isObject(doc)) continue;
|
|
75
|
+
if (str(doc['notation']) !== 'relation') continue;
|
|
76
|
+
const type = str(doc['type']);
|
|
77
|
+
if (type !== 'action_goal' && type !== 'activity_goal') continue;
|
|
78
|
+
if (str(doc['from']) !== fromId) continue;
|
|
79
|
+
const to = str(doc['to']);
|
|
80
|
+
if (!to) continue;
|
|
81
|
+
const validTo = doc['valid_to'];
|
|
82
|
+
if (validTo !== undefined && validTo !== null) continue; // ended relation
|
|
83
|
+
if (!out.includes(to)) out.push(to);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns true when the parsed view document uses the canon-projection form
|
|
90
|
+
* (`view_config` key present, no inline `actions[]`). Used by the preview and
|
|
91
|
+
* CLI to decide whether to run the resolver or fall back to direct
|
|
92
|
+
* `validateActivities` on the inline document.
|
|
93
|
+
*/
|
|
94
|
+
export function isActionViewDoc(parsed: unknown): boolean {
|
|
95
|
+
if (!isObject(parsed)) return false;
|
|
96
|
+
return 'view_config' in parsed && !('actions' in parsed);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Assemble a flat canonical Action document from the canon ACTION element
|
|
101
|
+
* store, filtered by the view document's `view_config.scope`. The return
|
|
102
|
+
* value is ready to pass to `validateActivities`.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveAction(
|
|
105
|
+
viewDoc: unknown,
|
|
106
|
+
sources: ActionCanonSources,
|
|
107
|
+
): Record<string, unknown> {
|
|
108
|
+
if (!isObject(viewDoc)) {
|
|
109
|
+
return { notation: 'action', actions: [] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const vc = isObject(viewDoc['view_config']) ? viewDoc['view_config'] : {};
|
|
113
|
+
const scope = isObject(vc['scope']) ? vc['scope'] : {};
|
|
114
|
+
const schedule = isObject(vc['schedule']) ? vc['schedule'] : {};
|
|
115
|
+
|
|
116
|
+
const allActions = collectActionElements(sources.elements);
|
|
117
|
+
const relations = sources.relations ?? [];
|
|
118
|
+
|
|
119
|
+
/** Effective `goals[]` for an action — the active `action_goal` REL targets
|
|
120
|
+
* when any exist, else the element's transitional inline `goals[]`. */
|
|
121
|
+
function effectiveGoals(id: string, el: Record<string, unknown>): string[] {
|
|
122
|
+
const relGoals = activeActionGoalRelations(relations, id);
|
|
123
|
+
return relGoals.length > 0 ? relGoals : strArray(el['goals']);
|
|
124
|
+
}
|
|
125
|
+
|
|
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
|
+
}
|
|
136
|
+
|
|
137
|
+
// 2. Narrow by goals — only actions linked to at least one listed GOAL
|
|
138
|
+
// (via an active action_goal relation, or inline goals[] when no relation
|
|
139
|
+
// exists for that action).
|
|
140
|
+
const goalsFilter = strArray(scope['goals']);
|
|
141
|
+
if (goalsFilter.length > 0) {
|
|
142
|
+
candidateIds = new Set(
|
|
143
|
+
[...candidateIds].filter((id) => effectiveGoals(id, allActions.get(id)!).some((g) => goalsFilter.includes(g))),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 3. Narrow by type_filter — only actions of a listed type.
|
|
148
|
+
const typeFilter = strArray(scope['type_filter']);
|
|
149
|
+
if (typeFilter.length > 0) {
|
|
150
|
+
candidateIds = new Set(
|
|
151
|
+
[...candidateIds].filter((id) => {
|
|
152
|
+
const el = allActions.get(id);
|
|
153
|
+
const t = str(el?.['type']) ?? str(el?.['activity_type']);
|
|
154
|
+
return t !== undefined && typeFilter.includes(t);
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 4. Narrow by valid_at — only actions in effect on that date. Actions with
|
|
160
|
+
// no valid_from are not lifecycle-tracked and are kept (permissive default,
|
|
161
|
+
// matching the resolver's general "missing = not excluded" stance).
|
|
162
|
+
const validAt = str(scope['valid_at']);
|
|
163
|
+
if (validAt) {
|
|
164
|
+
candidateIds = new Set(
|
|
165
|
+
[...candidateIds].filter((id) => {
|
|
166
|
+
const el = allActions.get(id);
|
|
167
|
+
const from = str(el?.['valid_from']);
|
|
168
|
+
const to = str(el?.['valid_to']);
|
|
169
|
+
if (from && validAt < from) return false;
|
|
170
|
+
if (to && validAt > to) return false;
|
|
171
|
+
return true;
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Element fields map onto Activity fields unchanged, except the canonical
|
|
177
|
+
// `type` (elements/24-action.md §2) which becomes the internal `activity_type`
|
|
178
|
+
// (types.ts `Activity.activity_type`), and `goals` which is resolved via
|
|
179
|
+
// `effectiveGoals` (REL-preferred). The admission/lifecycle envelope fields
|
|
180
|
+
// carry no schedule/render meaning for `validateActivities` and are dropped.
|
|
181
|
+
const selectedActions = [...candidateIds].map((id) => {
|
|
182
|
+
const el = allActions.get(id)!;
|
|
183
|
+
const { type, activity_type, goals: _inlineGoals, ...rest } = stripEnvelope(el);
|
|
184
|
+
const resolvedType = type ?? activity_type;
|
|
185
|
+
return {
|
|
186
|
+
...rest,
|
|
187
|
+
id,
|
|
188
|
+
goals: effectiveGoals(id, el),
|
|
189
|
+
...(resolvedType !== undefined ? { activity_type: resolvedType } : {}),
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const project: Record<string, unknown> = {};
|
|
194
|
+
const startDate = str(schedule['start_date']);
|
|
195
|
+
if (startDate) project['start_date'] = startDate;
|
|
196
|
+
if (isObject(schedule['calendar'])) project['calendar'] = schedule['calendar'];
|
|
197
|
+
|
|
198
|
+
const out: Record<string, unknown> = {
|
|
199
|
+
notation: 'action',
|
|
200
|
+
id: viewDoc['id'],
|
|
201
|
+
title: viewDoc['name'],
|
|
202
|
+
spec_version: viewDoc['spec_version'],
|
|
203
|
+
description: viewDoc['description'],
|
|
204
|
+
actions: selectedActions,
|
|
205
|
+
};
|
|
206
|
+
if (Object.keys(project).length > 0) out['project'] = project;
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared primitives for the per-notation canon-projection resolvers
|
|
3
|
+
* (activities/resolver.ts, goals/resolver.ts — fgca/resolver.ts predates this
|
|
4
|
+
* module and keeps its own copies). Each resolver assembles a flat document
|
|
5
|
+
* from a canon element store filtered by a view_config.scope block; these
|
|
6
|
+
* helpers are the parts that don't vary by notation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function isObject(v: unknown): v is Record<string, unknown> {
|
|
10
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A non-empty string, trimmed. */
|
|
14
|
+
export function str(v: unknown): string | undefined {
|
|
15
|
+
if (typeof v !== 'string') return undefined;
|
|
16
|
+
const t = v.trim();
|
|
17
|
+
return t.length > 0 ? t : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function strArray(v: unknown): string[] {
|
|
21
|
+
if (!Array.isArray(v)) return [];
|
|
22
|
+
return v.filter((x): x is string => typeof x === 'string' && x.trim().length > 0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `rootId` and every element reachable by following `parent` links downward,
|
|
26
|
+
* per the element map's own `parent` field. Used to scope a projection to a
|
|
27
|
+
* root element and its transitive descendants (WBS / goal hierarchy / etc). */
|
|
28
|
+
export function descendantsOf(
|
|
29
|
+
rootId: string,
|
|
30
|
+
all: Map<string, Record<string, unknown>>,
|
|
31
|
+
): Set<string> {
|
|
32
|
+
const childrenByParent = new Map<string, string[]>();
|
|
33
|
+
for (const [id, el] of all) {
|
|
34
|
+
const parent = str(el['parent']);
|
|
35
|
+
if (!parent) continue;
|
|
36
|
+
const list = childrenByParent.get(parent) ?? [];
|
|
37
|
+
list.push(id);
|
|
38
|
+
childrenByParent.set(parent, list);
|
|
39
|
+
}
|
|
40
|
+
const result = new Set<string>();
|
|
41
|
+
const queue: string[] = [rootId];
|
|
42
|
+
while (queue.length > 0) {
|
|
43
|
+
const id = queue.shift()!;
|
|
44
|
+
if (result.has(id)) continue;
|
|
45
|
+
result.add(id);
|
|
46
|
+
for (const child of childrenByParent.get(id) ?? []) queue.push(child);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Depth of `id` in its `parent` chain within `all` (root = 0, each step up
|
|
52
|
+
* adds 1). Cycle-safe (a cycle resolves the entering node to depth 0) and
|
|
53
|
+
* memoized across calls via the shared `memo` map. */
|
|
54
|
+
export function parentChainDepth(
|
|
55
|
+
id: string,
|
|
56
|
+
all: Map<string, Record<string, unknown>>,
|
|
57
|
+
memo: Map<string, number> = new Map(),
|
|
58
|
+
visiting: Set<string> = new Set(),
|
|
59
|
+
): number {
|
|
60
|
+
const cached = memo.get(id);
|
|
61
|
+
if (cached !== undefined) return cached;
|
|
62
|
+
if (visiting.has(id)) {
|
|
63
|
+
memo.set(id, 0);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
const el = all.get(id);
|
|
67
|
+
const parent = el ? str(el['parent']) : undefined;
|
|
68
|
+
if (!parent || !all.has(parent)) {
|
|
69
|
+
memo.set(id, 0);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
visiting.add(id);
|
|
73
|
+
const level = parentChainDepth(parent, all, memo, visiting) + 1;
|
|
74
|
+
visiting.delete(id);
|
|
75
|
+
memo.set(id, level);
|
|
76
|
+
return level;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The admission/lifecycle envelope fields (CONTRACT.md §6-7) every canon
|
|
80
|
+
* element carries but that carry no rendering/validation meaning once
|
|
81
|
+
* projected into a flat notation-specific document. */
|
|
82
|
+
const ENVELOPE_FIELDS = ['notation', 'zone', 'admitted_at', 'admitted_by', 'gate_checks', 'valid_from', 'valid_to'] as const;
|
|
83
|
+
|
|
84
|
+
/** `el` with the admission/lifecycle envelope fields removed. */
|
|
85
|
+
export function stripEnvelope(el: Record<string, unknown>): Record<string, unknown> {
|
|
86
|
+
const rest = { ...el };
|
|
87
|
+
for (const field of ENVELOPE_FIELDS) delete rest[field];
|
|
88
|
+
return rest;
|
|
89
|
+
}
|
package/src/fgca/resolver.ts
CHANGED
|
@@ -91,7 +91,11 @@ export function resolveFGCA(
|
|
|
91
91
|
const factorElems = new Map([...factorElemsLegacy, ...factorElemsNew]);
|
|
92
92
|
const goalElems = collectByNotation(sources.elements, 'goal');
|
|
93
93
|
const changeElems = collectByNotation(sources.elements, 'change');
|
|
94
|
-
|
|
94
|
+
// `action` is canonical since methodology 1.0; `activity` is the deprecated
|
|
95
|
+
// pre-rename alias, still accepted (elements/24-action.md §"Deprecated alias").
|
|
96
|
+
const activityElemsLegacy = collectByNotation(sources.elements, 'activity');
|
|
97
|
+
const activityElemsNew = collectByNotation(sources.elements, 'action');
|
|
98
|
+
const activityElems = new Map([...activityElemsLegacy, ...activityElemsNew]);
|
|
95
99
|
|
|
96
100
|
// 1. Select goal set
|
|
97
101
|
const goalsFilter = str(goalsConf['filter']) ?? 'all';
|
package/src/goals/index.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type {
|
|
|
16
16
|
export { validateGoalTree } from './validate.js';
|
|
17
17
|
export { parseCanonicalGoals } from './parse-canonical.js';
|
|
18
18
|
export type { CanonicalGoalsResult } from './parse-canonical.js';
|
|
19
|
+
export { resolveGoals, isGoalsViewDoc } from './resolver.js';
|
|
20
|
+
export type { GoalsViewConfig, GoalsCanonSources } from './resolver.js';
|
|
19
21
|
export { layoutGoalTree, selectScopedGoals } from './layout.js';
|
|
20
22
|
export { reparent, addChild, deleteWithDescendants, moveToBacklog, restoreFromBacklog } from './mutations.js';
|
|
21
23
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goals Tree canon resolver — assembles a flat canonical Goals document from
|
|
3
|
+
* a view_config projection and a canon element store (methodology
|
|
4
|
+
* `notations/views/04-goals.md` §4 "Projection form (Full tier —
|
|
5
|
+
* post-promotion)"). Mirrors `fgca/resolver.ts`'s `resolveFGCA`/`isFGCAViewDoc`
|
|
6
|
+
* and `activities/resolver.ts`'s `resolveAction`/`isActionViewDoc` pattern
|
|
7
|
+
* for the `goals` notation.
|
|
8
|
+
*
|
|
9
|
+
* Output shape matches the flat inline form `parseCanonicalGoals` expects:
|
|
10
|
+
* top-level `goal_types[]` + `goals[]`. Filesystem-free — callable from unit
|
|
11
|
+
* tests without vscode.
|
|
12
|
+
*
|
|
13
|
+
* `goal_types[]` synthesis: when `view_config.goal_types` is present it is
|
|
14
|
+
* used as-is (the documented projection-form shape always includes it) and
|
|
15
|
+
* each goal keeps its own authored `level`, validated against that table as
|
|
16
|
+
* usual. When absent, §5.2 says the renderer "infers levels from the parent
|
|
17
|
+
* chain depth (root goals at 0, each step deeper adds 1)" — this resolver
|
|
18
|
+
* implements that literally (`parentChainDepth`), overriding each selected
|
|
19
|
+
* goal's `level` with its structural depth, rather than trusting the
|
|
20
|
+
* element's own stored `level` field. A stored `level` can't be trusted here:
|
|
21
|
+
* nothing enforces cross-element consistency on it (ELEMENT_PRIMITIVES.md
|
|
22
|
+
* §7.2: `type`/`level` are independent, both-optional per-element fields),
|
|
23
|
+
* and worse, using it made the synthesized table's chosen level depend on
|
|
24
|
+
* which element the canon loader happened to enumerate first — the CLI
|
|
25
|
+
* (`readdirSync`, unsorted) and the VS Code preview
|
|
26
|
+
* (`vscode.workspace.fs.readDirectory`, unsorted) can enumerate the same
|
|
27
|
+
* canon store in different orders, so the two surfaces could disagree on
|
|
28
|
+
* `GOALS-008` findings for identical input. Structural depth has no such
|
|
29
|
+
* dependency. A goal with no `type` still surfaces as a genuine `GOALS-006`
|
|
30
|
+
* finding — this resolver never synthesizes a type label to paper over one.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { isObject, str, strArray, descendantsOf, parentChainDepth, stripEnvelope } from '../canon-resolver-utils.js';
|
|
34
|
+
|
|
35
|
+
export interface GoalsViewConfig {
|
|
36
|
+
scope?: {
|
|
37
|
+
root_goal?: string | null;
|
|
38
|
+
period?: string | null;
|
|
39
|
+
type_filter?: string[] | null;
|
|
40
|
+
valid_at?: string | null;
|
|
41
|
+
};
|
|
42
|
+
goal_types?: Array<{ name: string; level: number }>;
|
|
43
|
+
display?: {
|
|
44
|
+
depth?: number | null;
|
|
45
|
+
collapsed?: string[];
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GoalsCanonSources {
|
|
50
|
+
elements: unknown[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** GOAL elements by id (`notation: goal`). */
|
|
54
|
+
function collectGoalElements(docs: unknown[]): Map<string, Record<string, unknown>> {
|
|
55
|
+
const out = new Map<string, Record<string, unknown>>();
|
|
56
|
+
for (const doc of docs) {
|
|
57
|
+
if (!isObject(doc)) continue;
|
|
58
|
+
if (str(doc['notation']) !== 'goal') continue;
|
|
59
|
+
const id = str(doc['id']);
|
|
60
|
+
if (!id) continue;
|
|
61
|
+
if (!out.has(id)) out.set(id, doc); // first definition wins
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns true when the parsed view document uses the canon-projection form
|
|
68
|
+
* (`view_config` key present, no inline `goals[]`). Used by the preview and
|
|
69
|
+
* CLI to decide whether to run the resolver or fall back to direct
|
|
70
|
+
* `parseCanonicalGoals` on the inline document.
|
|
71
|
+
*/
|
|
72
|
+
export function isGoalsViewDoc(parsed: unknown): boolean {
|
|
73
|
+
if (!isObject(parsed)) return false;
|
|
74
|
+
return 'view_config' in parsed && !('goals' in parsed);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Assemble a flat canonical Goals document from the canon GOAL element
|
|
79
|
+
* store, filtered by the view document's `view_config.scope`. The return
|
|
80
|
+
* value is ready to pass to `parseCanonicalGoals`.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveGoals(
|
|
83
|
+
viewDoc: unknown,
|
|
84
|
+
sources: GoalsCanonSources,
|
|
85
|
+
): Record<string, unknown> {
|
|
86
|
+
if (!isObject(viewDoc)) {
|
|
87
|
+
return { notation: 'goals', goal_types: [], goals: [] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const vc = isObject(viewDoc['view_config']) ? viewDoc['view_config'] : {};
|
|
91
|
+
const scope = isObject(vc['scope']) ? vc['scope'] : {};
|
|
92
|
+
|
|
93
|
+
const allGoals = collectGoalElements(sources.elements);
|
|
94
|
+
|
|
95
|
+
// 1. Scope by root_goal (this goal + its descendants), or every goal when
|
|
96
|
+
// omitted (§5.1: "Omit to show all root goals").
|
|
97
|
+
const rootGoal = str(scope['root_goal']);
|
|
98
|
+
let candidateIds: Set<string>;
|
|
99
|
+
if (rootGoal) {
|
|
100
|
+
candidateIds = allGoals.has(rootGoal) ? descendantsOf(rootGoal, allGoals) : new Set();
|
|
101
|
+
} else {
|
|
102
|
+
candidateIds = new Set(allGoals.keys());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2. Narrow by period — matched against the element's own `period` field
|
|
106
|
+
// when present (the GOAL element schema, ELEMENT_PRIMITIVES.md §7.2, does
|
|
107
|
+
// not define this field explicitly; treated as a forward-compatible tag).
|
|
108
|
+
const period = str(scope['period']);
|
|
109
|
+
if (period) {
|
|
110
|
+
candidateIds = new Set(
|
|
111
|
+
[...candidateIds].filter((id) => str(allGoals.get(id)?.['period']) === period),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 3. Narrow by type_filter — only goals of a listed type.
|
|
116
|
+
const typeFilter = strArray(scope['type_filter']);
|
|
117
|
+
if (typeFilter.length > 0) {
|
|
118
|
+
candidateIds = new Set(
|
|
119
|
+
[...candidateIds].filter((id) => {
|
|
120
|
+
const t = str(allGoals.get(id)?.['type']);
|
|
121
|
+
return t !== undefined && typeFilter.includes(t);
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 4. Narrow by valid_at — only goals in effect on that date. Goals with no
|
|
127
|
+
// valid_from are not lifecycle-tracked and are kept (permissive default,
|
|
128
|
+
// matching the action/dgca resolvers' stance).
|
|
129
|
+
const validAt = str(scope['valid_at']);
|
|
130
|
+
if (validAt) {
|
|
131
|
+
candidateIds = new Set(
|
|
132
|
+
[...candidateIds].filter((id) => {
|
|
133
|
+
const el = allGoals.get(id);
|
|
134
|
+
const from = str(el?.['valid_from']);
|
|
135
|
+
const to = str(el?.['valid_to']);
|
|
136
|
+
if (from && validAt < from) return false;
|
|
137
|
+
if (to && validAt > to) return false;
|
|
138
|
+
return true;
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const explicitGoalTypes = Array.isArray(vc['goal_types'])
|
|
144
|
+
? (vc['goal_types'] as unknown[]).filter(isObject)
|
|
145
|
+
: undefined;
|
|
146
|
+
|
|
147
|
+
// Element fields map onto Goal fields unchanged — the admission/lifecycle
|
|
148
|
+
// envelope fields carry no rendering meaning for `parseCanonicalGoals` and
|
|
149
|
+
// are dropped.
|
|
150
|
+
let selectedGoals: Array<Record<string, unknown>>;
|
|
151
|
+
let goalTypes: Array<Record<string, unknown>>;
|
|
152
|
+
|
|
153
|
+
if (explicitGoalTypes) {
|
|
154
|
+
// Main-line case: goals keep their own authored `level`, validated
|
|
155
|
+
// against the explicit table by parseCanonicalGoals as usual.
|
|
156
|
+
selectedGoals = [...candidateIds].map((id) => ({ ...stripEnvelope(allGoals.get(id)!), id }));
|
|
157
|
+
goalTypes = explicitGoalTypes;
|
|
158
|
+
} else {
|
|
159
|
+
// §5.2 fallback: level is the goal's structural depth in the parent
|
|
160
|
+
// chain, not its (untrustworthy, order-dependent) stored `level` field.
|
|
161
|
+
const depthMemo = new Map<string, number>();
|
|
162
|
+
selectedGoals = [...candidateIds].map((id) => {
|
|
163
|
+
const { level: _level, ...rest } = stripEnvelope(allGoals.get(id)!);
|
|
164
|
+
return { ...rest, id, level: parentChainDepth(id, allGoals, depthMemo) };
|
|
165
|
+
});
|
|
166
|
+
const seen = new Map<string, number>();
|
|
167
|
+
for (const g of selectedGoals) {
|
|
168
|
+
const name = typeof g['type'] === 'string' ? g['type'] : undefined;
|
|
169
|
+
if (name !== undefined && !seen.has(name)) {
|
|
170
|
+
seen.set(name, g['level'] as number);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
goalTypes = [...seen.entries()].map(([name, level]) => ({ name, level }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
notation: 'goals',
|
|
178
|
+
id: viewDoc['id'],
|
|
179
|
+
name: viewDoc['name'],
|
|
180
|
+
spec_version: viewDoc['spec_version'],
|
|
181
|
+
description: viewDoc['description'],
|
|
182
|
+
period: viewDoc['period'],
|
|
183
|
+
goal_types: goalTypes,
|
|
184
|
+
goals: selectedGoals,
|
|
185
|
+
};
|
|
186
|
+
}
|