@h-rig/core 0.0.6-alpha.132 → 0.0.6-alpha.134

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.
@@ -0,0 +1,375 @@
1
+ // @bun
2
+ // packages/core/src/stageResolve.ts
3
+ class PipelineUnresolvableError extends Error {
4
+ cycles;
5
+ contributors;
6
+ constructor(message, cycles, contributors) {
7
+ super(message);
8
+ this.name = "PipelineUnresolvableError";
9
+ this.cycles = cycles;
10
+ this.contributors = contributors;
11
+ }
12
+ }
13
+ function uniqueSorted(values) {
14
+ return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right));
15
+ }
16
+ function wrapperForMutation(mutation) {
17
+ return "wrapper" in mutation ? mutation.wrapper : mutation.around;
18
+ }
19
+ function contributorOf(mutation) {
20
+ if (mutation.contributedBy?.trim())
21
+ return mutation.contributedBy;
22
+ if (mutation.op === "wrap") {
23
+ const wrapper = wrapperForMutation(mutation);
24
+ if (wrapper.id?.trim())
25
+ return wrapper.id;
26
+ }
27
+ return "anonymous";
28
+ }
29
+ function stableMutationCompare(left, right) {
30
+ const leftTarget = left.op === "insert" ? left.stage.id : left.id;
31
+ const rightTarget = right.op === "insert" ? right.stage.id : right.id;
32
+ const targetDelta = leftTarget.localeCompare(rightTarget);
33
+ if (targetDelta !== 0)
34
+ return targetDelta;
35
+ return contributorOf(left).localeCompare(contributorOf(right));
36
+ }
37
+ function ensureUniqueStageIds(stages) {
38
+ const seen = new Set;
39
+ for (const stage of stages) {
40
+ if (seen.has(stage.id)) {
41
+ throw new PipelineUnresolvableError(`Duplicate stage id: ${stage.id}`, [], []);
42
+ }
43
+ seen.add(stage.id);
44
+ }
45
+ }
46
+ function assertNoDuplicateMutationTargets(mutations, op) {
47
+ const seen = new Map;
48
+ for (const mutation of mutations.filter((entry) => entry.op === op)) {
49
+ const id = mutation.op === "insert" ? mutation.stage.id : mutation.id;
50
+ const previous = seen.get(id);
51
+ if (previous) {
52
+ throw new PipelineUnresolvableError(`Duplicate ${op} mutation for stage ${id}: ${previous}, ${contributorOf(mutation)}`, [], uniqueSorted([previous, contributorOf(mutation)]));
53
+ }
54
+ seen.set(id, contributorOf(mutation));
55
+ }
56
+ }
57
+ function hasProtectedGrant(grants, stageId, pluginId, op) {
58
+ return grants.some((grant) => grant.stageId === stageId && grant.pluginId === pluginId && (grant.op === undefined || grant.op === op));
59
+ }
60
+ function mergeAnchors(current, incoming) {
61
+ return uniqueSorted([...current, ...incoming ?? []]);
62
+ }
63
+ function stagePriority(stage) {
64
+ return typeof stage.priority === "number" && Number.isFinite(stage.priority) ? stage.priority : 0;
65
+ }
66
+ function readyCompare(states) {
67
+ return (left, right) => {
68
+ const leftState = states.get(left);
69
+ const rightState = states.get(right);
70
+ const priorityDelta = stagePriority(rightState?.stage ?? { id: right }) - stagePriority(leftState?.stage ?? { id: left });
71
+ if (priorityDelta !== 0)
72
+ return priorityDelta;
73
+ if (leftState?.baseIndex !== null && rightState?.baseIndex !== null && leftState?.baseIndex !== rightState?.baseIndex) {
74
+ return (leftState?.baseIndex ?? 0) - (rightState?.baseIndex ?? 0);
75
+ }
76
+ if (leftState?.baseIndex !== null && rightState?.baseIndex === null)
77
+ return -1;
78
+ if (leftState?.baseIndex === null && rightState?.baseIndex !== null)
79
+ return 1;
80
+ return left.localeCompare(right);
81
+ };
82
+ }
83
+ function findCycles(nodes, edges) {
84
+ const adjacency = new Map;
85
+ for (const node of nodes)
86
+ adjacency.set(node, []);
87
+ for (const edge of edges)
88
+ adjacency.get(edge.from)?.push(edge.to);
89
+ for (const targets of adjacency.values())
90
+ targets.sort((left, right) => left.localeCompare(right));
91
+ let nextIndex = 0;
92
+ const indexByNode = new Map;
93
+ const lowByNode = new Map;
94
+ const stack = [];
95
+ const onStack = new Set;
96
+ const cycles = [];
97
+ const visit = (node) => {
98
+ indexByNode.set(node, nextIndex);
99
+ lowByNode.set(node, nextIndex);
100
+ nextIndex += 1;
101
+ stack.push(node);
102
+ onStack.add(node);
103
+ for (const target of adjacency.get(node) ?? []) {
104
+ if (!indexByNode.has(target)) {
105
+ visit(target);
106
+ lowByNode.set(node, Math.min(lowByNode.get(node) ?? 0, lowByNode.get(target) ?? 0));
107
+ } else if (onStack.has(target)) {
108
+ lowByNode.set(node, Math.min(lowByNode.get(node) ?? 0, indexByNode.get(target) ?? 0));
109
+ }
110
+ }
111
+ if (lowByNode.get(node) !== indexByNode.get(node))
112
+ return;
113
+ const component = [];
114
+ let current;
115
+ do {
116
+ current = stack.pop();
117
+ if (current) {
118
+ onStack.delete(current);
119
+ component.push(current);
120
+ }
121
+ } while (current && current !== node);
122
+ const hasSelfLoop = edges.some((edge) => edge.from === node && edge.to === node);
123
+ if (component.length > 1 || hasSelfLoop) {
124
+ cycles.push(component.sort((left, right) => left.localeCompare(right)));
125
+ }
126
+ };
127
+ for (const node of [...nodes].sort((left, right) => left.localeCompare(right))) {
128
+ if (!indexByNode.has(node))
129
+ visit(node);
130
+ }
131
+ return cycles.sort((left, right) => left.join("\x00").localeCompare(right.join("\x00")));
132
+ }
133
+ function topologicalOrder(states, edges) {
134
+ const nodes = [...states.keys()];
135
+ const outgoing = new Map;
136
+ const indegree = new Map;
137
+ for (const node of nodes) {
138
+ outgoing.set(node, []);
139
+ indegree.set(node, 0);
140
+ }
141
+ for (const edge of edges) {
142
+ outgoing.get(edge.from)?.push(edge.to);
143
+ indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1);
144
+ }
145
+ for (const targets of outgoing.values())
146
+ targets.sort((left, right) => left.localeCompare(right));
147
+ const compare = readyCompare(states);
148
+ const ready = nodes.filter((node) => (indegree.get(node) ?? 0) === 0).sort(compare);
149
+ const order = [];
150
+ while (ready.length > 0) {
151
+ const node = ready.shift();
152
+ if (!node)
153
+ break;
154
+ order.push(node);
155
+ for (const target of outgoing.get(node) ?? []) {
156
+ const next = (indegree.get(target) ?? 0) - 1;
157
+ indegree.set(target, next);
158
+ if (next === 0) {
159
+ ready.push(target);
160
+ ready.sort(compare);
161
+ }
162
+ }
163
+ }
164
+ if (order.length === nodes.length)
165
+ return order;
166
+ const cycles = findCycles(nodes, edges);
167
+ const cycleMembers = new Set(cycles.flat());
168
+ const contributors = uniqueSorted(edges.filter((edge) => cycleMembers.has(edge.from) && cycleMembers.has(edge.to)).flatMap((edge) => edge.contributors));
169
+ throw new PipelineUnresolvableError(`Stage pipeline has unresolved cycle: ${cycles.map((cycle) => cycle.join(" -> ")).join("; ")}`, cycles, contributors);
170
+ }
171
+ function composeStageWrappers(state) {
172
+ const wrappers = state.wrappers.toSorted((left, right) => {
173
+ const priorityDelta = (right.wrapper.priority ?? 0) - (left.wrapper.priority ?? 0);
174
+ if (priorityDelta !== 0)
175
+ return priorityDelta;
176
+ return left.contributedBy.localeCompare(right.contributedBy);
177
+ });
178
+ let stage = state.stage;
179
+ for (const wrapper of wrappers.toReversed()) {
180
+ if (wrapper.wrapper.apply)
181
+ stage = wrapper.wrapper.apply(stage);
182
+ }
183
+ return stage;
184
+ }
185
+ function resolveStagePipeline(input) {
186
+ ensureUniqueStageIds(input.defaultStages);
187
+ const mutations = [...input.mutations ?? []];
188
+ assertNoDuplicateMutationTargets(mutations, "insert");
189
+ assertNoDuplicateMutationTargets(mutations, "replace");
190
+ const grants = input.protectedStageGrants ?? [];
191
+ const states = new Map;
192
+ const removedStates = new Map;
193
+ for (const [index, stage] of input.defaultStages.entries()) {
194
+ states.set(stage.id, {
195
+ stage,
196
+ before: [...stage.before ?? []],
197
+ after: [...stage.after ?? []],
198
+ baseIndex: index,
199
+ contributedBy: "default",
200
+ wrappers: [],
201
+ droppedAnchors: [],
202
+ isProtected: stage.protected === true
203
+ });
204
+ }
205
+ for (const mutation of mutations.filter((entry) => entry.op === "remove").sort(stableMutationCompare)) {
206
+ const state = states.get(mutation.id);
207
+ const contributor = contributorOf(mutation);
208
+ if (!state)
209
+ continue;
210
+ if (state.isProtected && !hasProtectedGrant(grants, mutation.id, contributor, "remove")) {
211
+ throw new PipelineUnresolvableError(`Protected stage ${mutation.id} cannot be removed by ${contributor} without an explicit grant`, [], [contributor]);
212
+ }
213
+ const removedState = {
214
+ ...state,
215
+ removedBy: contributor,
216
+ ...state.isProtected ? { grantUsedBy: contributor } : {}
217
+ };
218
+ removedStates.set(mutation.id, removedState);
219
+ states.delete(mutation.id);
220
+ }
221
+ for (const mutation of mutations.filter((entry) => entry.op === "replace").sort(stableMutationCompare)) {
222
+ const state = states.get(mutation.id);
223
+ const contributor = contributorOf(mutation);
224
+ if (!state)
225
+ continue;
226
+ if (state.isProtected && !hasProtectedGrant(grants, mutation.id, contributor, "replace")) {
227
+ throw new PipelineUnresolvableError(`Protected stage ${mutation.id} cannot be replaced by ${contributor} without an explicit grant`, [], [contributor]);
228
+ }
229
+ const replacement = { ...mutation.stage, id: mutation.id };
230
+ states.set(mutation.id, {
231
+ ...state,
232
+ stage: replacement,
233
+ before: mutation.stage.before ? [...mutation.stage.before] : state.before,
234
+ after: mutation.stage.after ? [...mutation.stage.after] : state.after,
235
+ replacedBy: contributor,
236
+ contributedBy: state.contributedBy,
237
+ isProtected: mutation.stage.protected ?? state.isProtected,
238
+ ...state.isProtected ? { grantUsedBy: contributor } : {}
239
+ });
240
+ }
241
+ for (const mutation of mutations.filter((entry) => entry.op === "insert").sort(stableMutationCompare)) {
242
+ const contributor = contributorOf(mutation);
243
+ if (states.has(mutation.stage.id)) {
244
+ throw new PipelineUnresolvableError(`Inserted stage ${mutation.stage.id} conflicts with an existing stage`, [], [contributor]);
245
+ }
246
+ states.set(mutation.stage.id, {
247
+ stage: mutation.stage,
248
+ before: [...mutation.stage.before ?? []],
249
+ after: [...mutation.stage.after ?? []],
250
+ baseIndex: null,
251
+ contributedBy: contributor,
252
+ wrappers: [],
253
+ droppedAnchors: [],
254
+ isProtected: mutation.stage.protected === true
255
+ });
256
+ }
257
+ for (const mutation of mutations.filter((entry) => entry.op === "reorder").sort(stableMutationCompare)) {
258
+ const state = states.get(mutation.id);
259
+ if (!state)
260
+ continue;
261
+ states.set(mutation.id, {
262
+ ...state,
263
+ before: mergeAnchors(state.before, mutation.before),
264
+ after: mergeAnchors(state.after, mutation.after)
265
+ });
266
+ }
267
+ for (const mutation of mutations.filter((entry) => entry.op === "wrap").sort(stableMutationCompare)) {
268
+ const state = states.get(mutation.id);
269
+ const contributor = contributorOf(mutation);
270
+ const wrapper = wrapperForMutation(mutation);
271
+ if (!state)
272
+ continue;
273
+ states.set(mutation.id, {
274
+ ...state,
275
+ wrappers: [...state.wrappers, { contributedBy: contributor, wrapper }]
276
+ });
277
+ }
278
+ const contributorsForAnchor = (stageId, direction, anchor, state) => {
279
+ const contributors = new Set;
280
+ if (state.replacedBy)
281
+ contributors.add(state.replacedBy);
282
+ for (const mutation of mutations) {
283
+ if (mutation.op === "reorder" && mutation.id === stageId && (direction === "before" ? mutation.before : mutation.after)?.includes(anchor)) {
284
+ contributors.add(contributorOf(mutation));
285
+ }
286
+ if (mutation.op === "insert" && mutation.stage.id === stageId && (direction === "before" ? mutation.stage.before : mutation.stage.after)?.includes(anchor)) {
287
+ contributors.add(contributorOf(mutation));
288
+ }
289
+ }
290
+ if (contributors.size === 0)
291
+ contributors.add(state.contributedBy);
292
+ return uniqueSorted(contributors);
293
+ };
294
+ const edges = [];
295
+ for (const [stageId, state] of states) {
296
+ const before = [];
297
+ const after = [];
298
+ for (const anchor of state.before) {
299
+ if (states.has(anchor))
300
+ before.push(anchor);
301
+ else {
302
+ const removed = removedStates.get(anchor);
303
+ state.droppedAnchors.push({
304
+ stageId,
305
+ anchor,
306
+ direction: "before",
307
+ reason: removed ? "removed" : "missing",
308
+ ...removed?.removedBy ? { removedBy: removed.removedBy } : {}
309
+ });
310
+ }
311
+ }
312
+ for (const anchor of state.after) {
313
+ if (states.has(anchor))
314
+ after.push(anchor);
315
+ else {
316
+ const removed = removedStates.get(anchor);
317
+ state.droppedAnchors.push({
318
+ stageId,
319
+ anchor,
320
+ direction: "after",
321
+ reason: removed ? "removed" : "missing",
322
+ ...removed?.removedBy ? { removedBy: removed.removedBy } : {}
323
+ });
324
+ }
325
+ }
326
+ state.before = uniqueSorted(before);
327
+ state.after = uniqueSorted(after);
328
+ for (const target of state.before)
329
+ edges.push({ from: stageId, to: target, contributors: contributorsForAnchor(stageId, "before", target, state) });
330
+ for (const source of state.after)
331
+ edges.push({ from: source, to: stageId, contributors: contributorsForAnchor(stageId, "after", source, state) });
332
+ }
333
+ const order = topologicalOrder(states, edges);
334
+ const stages = [];
335
+ const record = [];
336
+ for (const id of order) {
337
+ const state = states.get(id);
338
+ if (!state)
339
+ continue;
340
+ stages.push(composeStageWrappers(state));
341
+ const wrappedBy = state.wrappers.toSorted((left, right) => {
342
+ const priorityDelta = (right.wrapper.priority ?? 0) - (left.wrapper.priority ?? 0);
343
+ if (priorityDelta !== 0)
344
+ return priorityDelta;
345
+ return left.contributedBy.localeCompare(right.contributedBy);
346
+ }).map((wrapper) => wrapper.contributedBy);
347
+ record.push({
348
+ stageId: id,
349
+ contributedBy: state.contributedBy,
350
+ ...state.replacedBy ? { replacedBy: state.replacedBy } : {},
351
+ ...wrappedBy.length > 0 ? { wrappedBy } : {},
352
+ ...state.droppedAnchors.length > 0 ? { droppedAnchors: state.droppedAnchors.toSorted((left, right) => left.anchor.localeCompare(right.anchor)) } : {},
353
+ isProtected: state.isProtected,
354
+ ...state.grantUsedBy ? { grantUsedBy: state.grantUsedBy } : {}
355
+ });
356
+ }
357
+ record.push(...[...removedStates.entries()].toSorted((left, right) => {
358
+ const leftIndex = left[1].baseIndex ?? Number.MAX_SAFE_INTEGER;
359
+ const rightIndex = right[1].baseIndex ?? Number.MAX_SAFE_INTEGER;
360
+ if (leftIndex !== rightIndex)
361
+ return leftIndex - rightIndex;
362
+ return left[0].localeCompare(right[0]);
363
+ }).map(([stageId, state]) => ({
364
+ stageId,
365
+ contributedBy: state.contributedBy,
366
+ ...state.removedBy ? { removedBy: state.removedBy } : {},
367
+ isProtected: state.isProtected,
368
+ ...state.grantUsedBy ? { grantUsedBy: state.grantUsedBy } : {}
369
+ })));
370
+ return { stages, order, record, cycles: [] };
371
+ }
372
+ export {
373
+ resolveStagePipeline,
374
+ PipelineUnresolvableError
375
+ };
@@ -1,5 +1,5 @@
1
1
  import type { TaskSummary } from "@rig/contracts";
2
- export type TaskDependencyProjection = Pick<TaskSummary, "id" | "status" | "priority" | "metadata"> & Partial<Pick<TaskSummary, "externalId" | "sourceIssueId" | "dependencies" | "parentChildDeps" | "createdAt" | "updatedAt">> & {
2
+ export type TaskDependencyProjection = Pick<TaskSummary, "id" | "status" | "priority" | "metadata"> & Partial<Pick<TaskSummary, "externalId" | "sourceIssueId" | "dependencies" | "parentChildDeps" | "createdAt" | "updatedAt" | "role" | "scope" | "validationKeys">> & {
3
3
  readonly title?: string | null;
4
4
  };
5
5
  export type TaskDependencyBadgeKind = "blocked" | "ready" | "dependency";
@@ -24,8 +24,12 @@ export interface TaskDependencyBadgeSummary {
24
24
  readonly badges: readonly TaskDependencyBadge[];
25
25
  }
26
26
  export declare function readTaskMetadataStringList(task: TaskDependencyProjection, key: "dependencies" | "parentChildDeps" | "labels"): string[];
27
+ export declare function readTaskBlockingDependencyRefs(task: TaskDependencyProjection): string[];
27
28
  export declare function readTaskDependencyRefs(task: TaskDependencyProjection): string[];
28
29
  export declare function readTaskSourceIssueId(task: TaskDependencyProjection): string | null;
30
+ export declare function readTaskScope(task: TaskDependencyProjection): string[];
31
+ export type TaskScopeInput = readonly string[] | TaskDependencyProjection;
32
+ export declare function disjointScope(left: TaskScopeInput, right: TaskScopeInput): boolean;
29
33
  export declare function resolveTaskReference(ref: string, tasksById: ReadonlyMap<string, TaskDependencyProjection>, taskIdByExternalRef: ReadonlyMap<string, string>, taskIdBySourceIssueId: ReadonlyMap<string, string>): string | null;
30
34
  export declare function buildTaskReferenceIndex<T extends TaskDependencyProjection>(tasks: readonly T[]): {
31
35
  readonly tasksById: Map<string, T>;
@@ -39,3 +43,22 @@ export declare function selectNextReadyTaskByPriority<T extends TaskDependencyPr
39
43
  readonly excludeTaskIds?: Iterable<string>;
40
44
  readonly filter?: (task: T) => boolean;
41
45
  }): T | null;
46
+ export type ReadyTaskSelectionMode = "all-ready" | "blocking-only" | "max-unblock";
47
+ export interface RankedReadyTask<T extends TaskDependencyProjection> {
48
+ readonly task: T;
49
+ readonly score: number;
50
+ readonly priority: number;
51
+ readonly unblockCount: number;
52
+ readonly scope: readonly string[];
53
+ }
54
+ export interface RankedReadyTaskOptions<T extends TaskDependencyProjection> {
55
+ readonly excludeTaskIds?: Iterable<string>;
56
+ readonly activeTaskIds?: Iterable<string>;
57
+ readonly filter?: (task: T) => boolean;
58
+ readonly selection?: ReadyTaskSelectionMode;
59
+ readonly requireDisjointScopes?: boolean;
60
+ readonly disjointWithScopes?: Iterable<string>;
61
+ readonly limit?: number;
62
+ }
63
+ export declare function rankReadyTasks<T extends TaskDependencyProjection>(tasks: readonly T[], options?: Omit<RankedReadyTaskOptions<T>, "limit" | "requireDisjointScopes" | "disjointWithScopes">): readonly RankedReadyTask<T>[];
64
+ export declare function selectRankedReadyTasks<T extends TaskDependencyProjection>(tasks: readonly T[], options?: RankedReadyTaskOptions<T>): readonly T[];
@@ -1,4 +1,49 @@
1
1
  // @bun
2
+ // packages/core/src/taskScore.ts
3
+ var ROLE_WEIGHT = {
4
+ architect: 25,
5
+ extractor: 10
6
+ };
7
+ var CRITICALITY_WEIGHT = {
8
+ core: 20,
9
+ high: 10,
10
+ normal: 0
11
+ };
12
+ function finiteNumber(value, fallback) {
13
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
14
+ }
15
+ function scoreTask(input) {
16
+ const priority = finiteNumber(input.priority, 3);
17
+ const unblockCount = Math.max(0, finiteNumber(input.unblockCount, 0));
18
+ const roleWeight = input.role ? ROLE_WEIGHT[input.role] ?? 0 : 0;
19
+ const criticalityWeight = input.criticality ? CRITICALITY_WEIGHT[input.criticality] ?? 0 : 0;
20
+ const validationWeight = (input.validation ?? []).some((entry) => entry.includes("test-contract") || entry.includes("test-boundary")) ? 8 : 0;
21
+ const queueWeight = finiteNumber(input.queueWeight, 0);
22
+ return unblockCount * 100 + (10 - priority) + roleWeight + criticalityWeight + validationWeight + queueWeight;
23
+ }
24
+ function rankTasks(tasks, scoreInput, idOf) {
25
+ return tasks.map((task) => {
26
+ const input = scoreInput(task);
27
+ return {
28
+ task,
29
+ score: scoreTask(input),
30
+ priority: finiteNumber(input.priority, 3),
31
+ unblockCount: Math.max(0, finiteNumber(input.unblockCount, 0))
32
+ };
33
+ }).toSorted((left, right) => {
34
+ const scoreDelta = right.score - left.score;
35
+ if (scoreDelta !== 0)
36
+ return scoreDelta;
37
+ const unblockDelta = right.unblockCount - left.unblockCount;
38
+ if (unblockDelta !== 0)
39
+ return unblockDelta;
40
+ const priorityDelta = left.priority - right.priority;
41
+ if (priorityDelta !== 0)
42
+ return priorityDelta;
43
+ return idOf(left.task).localeCompare(idOf(right.task));
44
+ });
45
+ }
46
+
2
47
  // packages/core/src/taskGraph.ts
3
48
  function isObjectRecord(value) {
4
49
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -23,6 +68,9 @@ function readTaskMetadataStringList(task, key) {
23
68
  }
24
69
  return [];
25
70
  }
71
+ function readTaskBlockingDependencyRefs(task) {
72
+ return readTaskMetadataStringList(task, "dependencies");
73
+ }
26
74
  function readTaskDependencyRefs(task) {
27
75
  return unique([
28
76
  ...readTaskMetadataStringList(task, "dependencies"),
@@ -40,6 +88,32 @@ function readTaskSourceIssueId(task) {
40
88
  const rigMetadata = isObjectRecord(metadata?._rig) ? metadata._rig : null;
41
89
  return typeof rigMetadata?.sourceIssueId === "string" && rigMetadata.sourceIssueId.length > 0 ? rigMetadata.sourceIssueId : null;
42
90
  }
91
+ function readTaskScope(task) {
92
+ const taskRecord = task;
93
+ const topLevel = readStringList(taskRecord.scope);
94
+ if (topLevel.length > 0)
95
+ return unique(topLevel.map((entry) => entry.trim()).filter((entry) => entry.length > 0));
96
+ const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
97
+ const metadataScope = readStringList(metadata?.scope);
98
+ if (metadataScope.length > 0)
99
+ return unique(metadataScope.map((entry) => entry.trim()).filter((entry) => entry.length > 0));
100
+ return unique([
101
+ ...readStringList(metadata?.files),
102
+ ...readStringList(metadata?.paths)
103
+ ].map((entry) => entry.trim()).filter((entry) => entry.length > 0));
104
+ }
105
+ function isScopeList(input) {
106
+ return Array.isArray(input);
107
+ }
108
+ function normalizeScopeInput(input) {
109
+ return isScopeList(input) ? unique(input.map((entry) => entry.trim()).filter((entry) => entry.length > 0)) : readTaskScope(input);
110
+ }
111
+ function disjointScope(left, right) {
112
+ const leftScope = new Set(normalizeScopeInput(left));
113
+ if (leftScope.size === 0)
114
+ return true;
115
+ return normalizeScopeInput(right).every((entry) => !leftScope.has(entry));
116
+ }
43
117
  function resolveTaskReference(ref, tasksById, taskIdByExternalRef, taskIdBySourceIssueId) {
44
118
  if (tasksById.has(ref))
45
119
  return ref;
@@ -68,7 +142,7 @@ function computeTaskBlockingDepths(tasks) {
68
142
  if (!task)
69
143
  return 0;
70
144
  stack.add(taskId);
71
- const blockers = readTaskDependencyRefs(task).map((ref) => resolveTaskReference(ref, tasksById, taskIdByExternalRef, taskIdBySourceIssueId)).filter((ref) => ref !== null && ref !== taskId);
145
+ const blockers = readTaskBlockingDependencyRefs(task).map((ref) => resolveTaskReference(ref, tasksById, taskIdByExternalRef, taskIdBySourceIssueId)).filter((ref) => ref !== null && ref !== taskId);
72
146
  const depth = blockers.length === 0 ? 0 : Math.max(...blockers.map((blockerId) => visit(blockerId, stack) + 1));
73
147
  stack.delete(taskId);
74
148
  memo.set(taskId, depth);
@@ -111,6 +185,39 @@ function isTaskRunnableStatus(status) {
111
185
  function priorityValue(task) {
112
186
  return typeof task.priority === "number" && Number.isFinite(task.priority) ? task.priority : Number.MAX_SAFE_INTEGER;
113
187
  }
188
+ function readTaskRole(task) {
189
+ if (typeof task.role === "string" && task.role.trim())
190
+ return task.role.trim();
191
+ const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
192
+ return typeof metadata?.role === "string" && metadata.role.trim() ? metadata.role.trim() : null;
193
+ }
194
+ function readTaskCriticality(task) {
195
+ const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
196
+ return typeof metadata?.criticality === "string" && metadata.criticality.trim() ? metadata.criticality.trim() : null;
197
+ }
198
+ function readTaskValidationKeys(task) {
199
+ const taskRecord = task;
200
+ const topLevel = readStringList(taskRecord.validationKeys);
201
+ if (topLevel.length > 0)
202
+ return topLevel;
203
+ const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
204
+ return readStringList(metadata?.validation);
205
+ }
206
+ function readTaskQueueWeight(task) {
207
+ const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
208
+ const queueWeight = metadata?.queueWeight ?? metadata?.queue_weight;
209
+ return typeof queueWeight === "number" && Number.isFinite(queueWeight) ? queueWeight : 0;
210
+ }
211
+ function scoreInputForTask(task, unblockCount) {
212
+ return {
213
+ priority: task.priority,
214
+ unblockCount,
215
+ role: readTaskRole(task),
216
+ criticality: readTaskCriticality(task),
217
+ validation: readTaskValidationKeys(task),
218
+ queueWeight: readTaskQueueWeight(task)
219
+ };
220
+ }
114
221
  function computeTaskDependencyBadges(tasks) {
115
222
  const index = buildTaskReferenceIndex(tasks);
116
223
  const blockingDepths = computeTaskBlockingDepths(tasks);
@@ -120,7 +227,7 @@ function computeTaskDependencyBadges(tasks) {
120
227
  for (const task of tasks) {
121
228
  const dependencyIds = [];
122
229
  const unresolvedRefs = [];
123
- for (const ref of readTaskDependencyRefs(task)) {
230
+ for (const ref of readTaskBlockingDependencyRefs(task)) {
124
231
  const dependencyId = resolveTaskReference(ref, index.tasksById, index.taskIdByExternalRef, index.taskIdBySourceIssueId);
125
232
  if (dependencyId && dependencyId !== task.id) {
126
233
  dependencyIds.push(dependencyId);
@@ -207,13 +314,63 @@ function selectNextReadyTaskByPriority(tasks, options = {}) {
207
314
  });
208
315
  return candidates[0] ?? null;
209
316
  }
317
+ function rankReadyTasks(tasks, options = {}) {
318
+ const excluded = new Set(options.excludeTaskIds ?? []);
319
+ const activeTaskIds = new Set(options.activeTaskIds ?? []);
320
+ const badges = computeTaskDependencyBadges(tasks);
321
+ const tasksById = new Map(tasks.map((task) => [String(task.id), task]));
322
+ const openBlockCount = (task) => (badges.get(task.id)?.blocks ?? []).filter((blockedTaskId) => {
323
+ const blockedTask = tasksById.get(blockedTaskId);
324
+ return blockedTask ? !isTaskTerminalStatus(blockedTask.status) : false;
325
+ }).length;
326
+ const readyTasks = tasks.filter((task) => !excluded.has(task.id)).filter((task) => !activeTaskIds.has(task.id)).filter((task) => options.filter?.(task) ?? true).filter((task) => badges.get(task.id)?.ready === true);
327
+ const maxUnblockCount = Math.max(0, ...readyTasks.map(openBlockCount));
328
+ const selectedByMode = readyTasks.filter((task) => {
329
+ const unblockCount = openBlockCount(task);
330
+ if (options.selection === "blocking-only")
331
+ return unblockCount > 0;
332
+ if (options.selection === "max-unblock")
333
+ return maxUnblockCount > 0 && unblockCount === maxUnblockCount;
334
+ return true;
335
+ });
336
+ return rankTasks(selectedByMode, (task) => scoreInputForTask(task, openBlockCount(task)), (task) => task.id).map((entry) => ({
337
+ task: entry.task,
338
+ score: entry.score,
339
+ priority: entry.priority,
340
+ unblockCount: entry.unblockCount,
341
+ scope: readTaskScope(entry.task)
342
+ }));
343
+ }
344
+ function selectRankedReadyTasks(tasks, options = {}) {
345
+ const ranked = rankReadyTasks(tasks, options);
346
+ if (options.requireDisjointScopes !== true && !options.disjointWithScopes) {
347
+ return ranked.slice(0, options.limit).map((entry) => entry.task);
348
+ }
349
+ const occupiedScopes = new Set(options.disjointWithScopes ?? []);
350
+ const selected = [];
351
+ for (const entry of ranked) {
352
+ if (entry.scope.some((scope) => occupiedScopes.has(scope)))
353
+ continue;
354
+ selected.push(entry.task);
355
+ for (const scope of entry.scope)
356
+ occupiedScopes.add(scope);
357
+ if (options.limit !== undefined && selected.length >= options.limit)
358
+ break;
359
+ }
360
+ return selected;
361
+ }
210
362
  export {
363
+ selectRankedReadyTasks,
211
364
  selectNextReadyTaskByPriority,
212
365
  resolveTaskReference,
213
366
  readTaskSourceIssueId,
367
+ readTaskScope,
214
368
  readTaskMetadataStringList,
215
369
  readTaskDependencyRefs,
370
+ readTaskBlockingDependencyRefs,
371
+ rankReadyTasks,
216
372
  isTaskTerminalStatus,
373
+ disjointScope,
217
374
  computeTaskDependencyBadges,
218
375
  computeTaskBlockingDepths,
219
376
  buildTaskReferenceIndex
@@ -18,6 +18,7 @@ export type TaskGraphStage = Readonly<{
18
18
  }>;
19
19
  export type TaskGraphNode = Readonly<{
20
20
  id: string;
21
+ taskId: string;
21
22
  task: TaskSummary;
22
23
  rowKey: string;
23
24
  rowLabel: string;
@@ -107,7 +107,10 @@ function isObjectRecord2(value) {
107
107
  }
108
108
  function readIssueType(task) {
109
109
  const metadata = isObjectRecord2(task.metadata) ? task.metadata : null;
110
- return typeof metadata?.issueType === "string" ? metadata.issueType : null;
110
+ if (typeof metadata?.issueType === "string")
111
+ return metadata.issueType;
112
+ const raw = isObjectRecord2(metadata?.raw) ? metadata.raw : null;
113
+ return typeof raw?.issueType === "string" ? raw.issueType : null;
111
114
  }
112
115
  function isGraphTask(task) {
113
116
  return readIssueType(task) !== "epic";
@@ -354,6 +357,7 @@ function buildTaskGraphLayout(snapshot, tasks, options) {
354
357
  const artifactCount = (snapshot?.artifacts ?? []).filter((artifact) => runIds.has(artifact.runId)).length;
355
358
  nodes.push({
356
359
  id: task.id,
360
+ taskId: task.id,
357
361
  task,
358
362
  rowKey,
359
363
  rowLabel: rowLabelByKey.get(rowKey) ?? rowKey,
@@ -0,0 +1,17 @@
1
+ export type TaskCriticality = "core" | "high" | "normal" | string;
2
+ export interface TaskScoreInput {
3
+ readonly priority?: number | null;
4
+ readonly unblockCount?: number | null;
5
+ readonly role?: string | null;
6
+ readonly criticality?: TaskCriticality | null;
7
+ readonly validation?: readonly string[] | null;
8
+ readonly queueWeight?: number | null;
9
+ }
10
+ export interface RankedTask<T> {
11
+ readonly task: T;
12
+ readonly score: number;
13
+ readonly priority: number;
14
+ readonly unblockCount: number;
15
+ }
16
+ export declare function scoreTask(input: TaskScoreInput): number;
17
+ export declare function rankTasks<T>(tasks: readonly T[], scoreInput: (task: T) => TaskScoreInput, idOf: (task: T) => string): readonly RankedTask<T>[];