@lsctech/polaris 0.5.11 → 0.5.12

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.
@@ -37,6 +37,8 @@ const FINDING_BOOKKEEPING_KEYS = [
37
37
  "findingId",
38
38
  ];
39
39
  const PROGRESS_SHAPE_KEYS = new Set(["event", "progress", "heartbeat", "complete", "review_context"]);
40
+ const ERROR_TYPE_RATE_LIMIT = new Set(["rate_limit", "rate-limit", "ratelimit", "rate limit"]);
41
+ const ERROR_TYPE_AUTH = new Set(["auth", "authentication", "unauthorized", "unauthorised", "forbidden"]);
40
42
  const PROGRESS_TYPE_VALUES = new Set([
41
43
  "progress",
42
44
  "status",
@@ -56,6 +58,14 @@ const PROGRESS_STATUS_VALUES = new Set([
56
58
  "ok",
57
59
  "success",
58
60
  ]);
61
+ const TERMINAL_COMPLETE_STATUSES = new Set([
62
+ "review_completed",
63
+ "review_skipped",
64
+ "completed",
65
+ "complete",
66
+ "done",
67
+ "success",
68
+ ]);
59
69
  function hasFindingLocation(record) {
60
70
  return FINDING_LOCATION_KEYS.some((key) => record[key] !== undefined);
61
71
  }
@@ -112,6 +122,8 @@ function isProgressRecord(record) {
112
122
  return false;
113
123
  }
114
124
  function isActionableFinding(record) {
125
+ if (isErrorRecord(record))
126
+ return false;
115
127
  if (isProgressRecord(record))
116
128
  return false;
117
129
  return hasFindingLocation(record) || hasFindingContent(record);
@@ -124,6 +136,85 @@ function makeUnusableOutputError(message) {
124
136
  err.qcFailureReason = "unusable-output";
125
137
  return err;
126
138
  }
139
+ function makeQcFailureError(reason, message) {
140
+ const err = new Error(message);
141
+ err.qcFailureReason = reason;
142
+ return err;
143
+ }
144
+ function isErrorRecord(record) {
145
+ if (record.type === "error")
146
+ return true;
147
+ if (typeof record.errorType === "string")
148
+ return true;
149
+ if (record.error === true || record.error === "true")
150
+ return true;
151
+ return false;
152
+ }
153
+ function errorTypeFromRecord(record) {
154
+ const fromType = typeof record.errorType === "string" ? record.errorType : undefined;
155
+ const fromMessage = typeof record.message === "string" ? record.message : undefined;
156
+ return fromType ?? fromMessage;
157
+ }
158
+ function classifyErrorType(record) {
159
+ const raw = errorTypeFromRecord(record);
160
+ const errorType = (raw ?? "").toString().toLowerCase().trim();
161
+ if (errorType === "")
162
+ return "unavailable-provider";
163
+ if (ERROR_TYPE_RATE_LIMIT.has(errorType))
164
+ return "rate-limited";
165
+ if (ERROR_TYPE_AUTH.has(errorType))
166
+ return "auth-failure";
167
+ if (errorType.includes("rate limit"))
168
+ return "rate-limited";
169
+ if (errorType.includes("auth"))
170
+ return "auth-failure";
171
+ if (errorType.includes("unauthorized"))
172
+ return "auth-failure";
173
+ if (errorType.includes("forbidden"))
174
+ return "auth-failure";
175
+ if (["timeout", "timed out"].includes(errorType))
176
+ return "timeout";
177
+ if (errorType.includes("not found") || errorType.includes("not_found"))
178
+ return "command-not-found";
179
+ if (errorType.includes("unavailable") || errorType.includes("api_error") || errorType.includes("apierror") || errorType.includes("server") || errorType.includes("network") || errorType.includes("connection") || errorType.includes("internal")) {
180
+ return "unavailable-provider";
181
+ }
182
+ return "unavailable-provider";
183
+ }
184
+ function getTerminalCompleteFindingsCount(record) {
185
+ if (record.findings !== undefined) {
186
+ if (Array.isArray(record.findings)) {
187
+ return record.findings.length;
188
+ }
189
+ const count = typeof record.findings === "number" ? record.findings : Number(record.findings);
190
+ if (Number.isFinite(count))
191
+ return count;
192
+ }
193
+ const summary = record.summary;
194
+ if (summary && typeof summary === "object") {
195
+ const summaryRecord = summary;
196
+ if (summaryRecord.total !== undefined) {
197
+ const count = Number(summaryRecord.total);
198
+ if (Number.isFinite(count))
199
+ return count;
200
+ }
201
+ if (summaryRecord.issues !== undefined) {
202
+ const count = Number(summaryRecord.issues);
203
+ if (Number.isFinite(count))
204
+ return count;
205
+ }
206
+ }
207
+ return undefined;
208
+ }
209
+ function isTerminalCompleteNoFindings(record) {
210
+ if (record.type !== "complete")
211
+ return false;
212
+ const status = typeof record.status === "string" ? record.status.trim().toLowerCase() : "";
213
+ if (!TERMINAL_COMPLETE_STATUSES.has(status))
214
+ return false;
215
+ const count = getTerminalCompleteFindingsCount(record);
216
+ return count === undefined || count === 0;
217
+ }
127
218
  function buildRange(raw) {
128
219
  const startLine = coerceNumber(raw.startLine ?? raw.line) ?? 1;
129
220
  const endLine = coerceNumber(raw.endLine);
@@ -144,14 +235,27 @@ function parseFindingsFromPayload(payload) {
144
235
  return [];
145
236
  }
146
237
  const record = payload;
147
- if (Array.isArray(record.findings)) {
148
- return record.findings.filter((item) => typeof item === "object" && item !== null && isActionableFinding(item));
149
- }
150
- if (Array.isArray(record.issues)) {
151
- return record.issues.filter((item) => typeof item === "object" && item !== null && isActionableFinding(item));
238
+ if (isErrorRecord(record)) {
239
+ throw makeQcFailureError(classifyErrorType(record), `CodeRabbit provider returned an error: ${record.message ?? record.errorType ?? "unknown"}`);
152
240
  }
153
- if (Array.isArray(record.results)) {
154
- return record.results.filter((item) => typeof item === "object" && item !== null && isActionableFinding(item));
241
+ const arrays = [record.findings, record.issues, record.results];
242
+ for (const arr of arrays) {
243
+ if (Array.isArray(arr)) {
244
+ const findings = [];
245
+ for (const item of arr) {
246
+ if (typeof item !== "object" || item === null) {
247
+ continue;
248
+ }
249
+ const itemRecord = item;
250
+ if (isErrorRecord(itemRecord)) {
251
+ throw makeQcFailureError(classifyErrorType(itemRecord), `CodeRabbit provider returned an error in findings array: ${itemRecord.message ?? itemRecord.errorType ?? "unknown"}`);
252
+ }
253
+ if (isActionableFinding(itemRecord)) {
254
+ findings.push(item);
255
+ }
256
+ }
257
+ return findings;
258
+ }
155
259
  }
156
260
  // Single finding wrapped in an object
157
261
  if (isActionableFinding(record)) {
@@ -172,6 +276,9 @@ function parseReport(output, format, parser) {
172
276
  const findings = parseFindingsFromPayload(data);
173
277
  const record = data;
174
278
  if (findings.length === 0) {
279
+ if (isTerminalCompleteNoFindings(record)) {
280
+ return { findings: [] };
281
+ }
175
282
  if (isProgressRecord(record)) {
176
283
  throw makeUnusableOutputError("CodeRabbit output was a progress/status/heartbeat record");
177
284
  }
@@ -198,7 +305,19 @@ function parseReport(output, format, parser) {
198
305
  try {
199
306
  const parsed = JSON.parse(text);
200
307
  if (Array.isArray(parsed)) {
201
- const findings = parsed.filter((item) => typeof item === "object" && item !== null && isActionableFinding(item));
308
+ const findings = [];
309
+ for (const item of parsed) {
310
+ if (typeof item !== "object" || item === null) {
311
+ continue;
312
+ }
313
+ const itemRecord = item;
314
+ if (isErrorRecord(itemRecord)) {
315
+ throw makeQcFailureError(classifyErrorType(itemRecord), `CodeRabbit provider returned an error in JSON array: ${itemRecord.message ?? itemRecord.errorType ?? "unknown"}`);
316
+ }
317
+ if (isActionableFinding(itemRecord)) {
318
+ findings.push(item);
319
+ }
320
+ }
202
321
  if (findings.length === 0 && parsed.length > 0) {
203
322
  const unusableCount = parsed.filter((item) => typeof item === "object" && item !== null && isUnusableFindingRecord(item)).length;
204
323
  if (unusableCount > 0) {
@@ -210,6 +329,9 @@ function parseReport(output, format, parser) {
210
329
  const findings = parseFindingsFromPayload(parsed);
211
330
  if (findings.length === 0) {
212
331
  const parsedRecord = parsed;
332
+ if (isTerminalCompleteNoFindings(parsedRecord)) {
333
+ return { findings: [] };
334
+ }
213
335
  if (isProgressRecord(parsedRecord)) {
214
336
  throw makeUnusableOutputError("CodeRabbit output was a progress/status/heartbeat record");
215
337
  }
@@ -260,9 +382,12 @@ function parseReport(output, format, parser) {
260
382
  continue;
261
383
  }
262
384
  const record = parsed;
263
- if (record.type === "complete" && record.status === "review_skipped") {
385
+ if (isTerminalCompleteNoFindings(record)) {
264
386
  sawExplicitSkip = true;
265
387
  }
388
+ if (isErrorRecord(record)) {
389
+ throw makeQcFailureError(classifyErrorType(record), `CodeRabbit provider returned an error in JSONL: ${record.message ?? record.errorType ?? "unknown"}`);
390
+ }
266
391
  if (isProgressRecord(record)) {
267
392
  progressLineCount++;
268
393
  continue;
@@ -274,7 +399,10 @@ function parseReport(output, format, parser) {
274
399
  unusableLineCount++;
275
400
  }
276
401
  }
277
- catch {
402
+ catch (err) {
403
+ if (typeof err === "object" && err !== null && "qcFailureReason" in err) {
404
+ throw err;
405
+ }
278
406
  // Ignore unparseable lines.
279
407
  }
280
408
  }
@@ -19,10 +19,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.DEFAULT_REPAIR_DISPATCH_TIMEOUT_MS = exports.DEFAULT_MAX_REPAIR_ROUNDS = void 0;
20
20
  exports.partitionRepairPackets = partitionRepairPackets;
21
21
  exports.initRepairLoopState = initRepairLoopState;
22
+ exports.getQcResolutionArtifactPath = getQcResolutionArtifactPath;
23
+ exports.resolveQcResolutionFindings = resolveQcResolutionFindings;
24
+ exports.getResolverIdentity = getResolverIdentity;
25
+ exports.isValidQcResolutionArtifact = isValidQcResolutionArtifact;
26
+ exports.readQcResolutionArtifact = readQcResolutionArtifact;
27
+ exports.writeQcResolutionArtifact = writeQcResolutionArtifact;
22
28
  exports.runQcRepairLoop = runQcRepairLoop;
23
29
  const node_fs_1 = require("node:fs");
30
+ const node_child_process_1 = require("node:child_process");
24
31
  const node_path_1 = require("node:path");
25
32
  const repair_packets_js_1 = require("./repair-packets.js");
33
+ const types_js_1 = require("./types.js");
26
34
  const orchestration_js_1 = require("./orchestration.js");
27
35
  const store_js_1 = require("../cluster-state/store.js");
28
36
  // ── Constants ─────────────────────────────────────────────────────────────────
@@ -189,6 +197,127 @@ function initRepairLoopState(opts) {
189
197
  updated_at: now,
190
198
  };
191
199
  }
200
+ // ── Operator resolution artifact helpers ──────────────────────────────────────
201
+ /** Path for the operator-written resolution artifact for a repair round. */
202
+ function getQcResolutionArtifactPath(clusterId, round, repoRoot) {
203
+ return (0, node_path_1.join)((0, repair_packets_js_1.getRepairRoundDir)(clusterId, round, repoRoot), "resolution.json");
204
+ }
205
+ /** Resolve the finding IDs covered by a resolution artifact. */
206
+ function resolveQcResolutionFindings(manifest, explicitFindings) {
207
+ const manifestFindings = [
208
+ ...new Set(manifest.packets.flatMap((p) => p.findingIds)),
209
+ ].sort();
210
+ if (!explicitFindings || explicitFindings.length === 0) {
211
+ return manifestFindings;
212
+ }
213
+ const allowed = new Set(manifestFindings);
214
+ const unknown = explicitFindings.filter((id) => !allowed.has(id));
215
+ if (unknown.length > 0) {
216
+ throw new Error(`Unknown finding IDs: ${unknown.join(", ")}. Expected one of: ${manifestFindings.join(", ") || "none"}`);
217
+ }
218
+ return [...new Set(explicitFindings)].sort();
219
+ }
220
+ /** Determine the resolver identity from git config or the default operator. */
221
+ function getResolverIdentity(repoRoot) {
222
+ try {
223
+ const name = (0, node_child_process_1.execFileSync)("git", ["config", "user.name"], {
224
+ cwd: repoRoot || process.cwd(),
225
+ encoding: "utf-8",
226
+ }).trim();
227
+ return name || "lsctech";
228
+ }
229
+ catch {
230
+ return "lsctech";
231
+ }
232
+ }
233
+ const QC_RESOLUTION_OUTCOME_SET = new Set(types_js_1.QC_RESOLUTION_OUTCOMES);
234
+ /** Validate a resolution artifact object. */
235
+ function isValidQcResolutionArtifact(value) {
236
+ if (!value || typeof value !== "object")
237
+ return false;
238
+ const v = value;
239
+ if (typeof v.schemaVersion !== "string")
240
+ return false;
241
+ if (typeof v.clusterId !== "string")
242
+ return false;
243
+ if (typeof v.round !== "number")
244
+ return false;
245
+ if (typeof v.resolvedAt !== "string")
246
+ return false;
247
+ if (typeof v.resolver !== "string" || v.resolver === "")
248
+ return false;
249
+ if (typeof v.resolvedOutcome !== "string" ||
250
+ !QC_RESOLUTION_OUTCOME_SET.has(v.resolvedOutcome)) {
251
+ return false;
252
+ }
253
+ if (typeof v.reason !== "string" || v.reason.trim() === "")
254
+ return false;
255
+ if (!Array.isArray(v.findings) ||
256
+ v.findings.length === 0 ||
257
+ !v.findings.every((f) => typeof f === "string")) {
258
+ return false;
259
+ }
260
+ return true;
261
+ }
262
+ /** Read and validate a resolution artifact, if it exists. */
263
+ function readQcResolutionArtifact(clusterId, round, repoRoot) {
264
+ const artifactPath = getQcResolutionArtifactPath(clusterId, round, repoRoot);
265
+ try {
266
+ const data = (0, node_fs_1.readFileSync)(artifactPath, "utf-8");
267
+ const parsed = JSON.parse(data);
268
+ if (isValidQcResolutionArtifact(parsed)) {
269
+ // Verify identity matches expected clusterId and round
270
+ if (parsed.clusterId !== clusterId || parsed.round !== round) {
271
+ return null;
272
+ }
273
+ return parsed;
274
+ }
275
+ return null;
276
+ }
277
+ catch (error) {
278
+ if (error.code === "ENOENT") {
279
+ return null;
280
+ }
281
+ if (error instanceof SyntaxError) {
282
+ return null;
283
+ }
284
+ throw error;
285
+ }
286
+ }
287
+ /** Write a resolution artifact atomically and return its absolute path. */
288
+ function writeQcResolutionArtifact(options) {
289
+ const { clusterId, round, resolver, resolvedOutcome, reason, findings, repoRoot, } = options;
290
+ const artifactPath = getQcResolutionArtifactPath(clusterId, round, repoRoot);
291
+ const dir = (0, node_path_1.dirname)(artifactPath);
292
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
293
+ const artifact = {
294
+ schemaVersion: "1.0",
295
+ clusterId,
296
+ round,
297
+ resolvedAt: new Date().toISOString(),
298
+ resolver,
299
+ resolvedOutcome,
300
+ reason,
301
+ findings,
302
+ };
303
+ const tempPath = `${artifactPath}.tmp.${process.pid}.${Date.now()}.${Math.random()
304
+ .toString(36)
305
+ .slice(2, 8)}`;
306
+ try {
307
+ (0, node_fs_1.writeFileSync)(tempPath, JSON.stringify(artifact, null, 2), "utf-8");
308
+ (0, node_fs_1.renameSync)(tempPath, artifactPath);
309
+ }
310
+ catch (error) {
311
+ try {
312
+ (0, node_fs_1.unlinkSync)(tempPath);
313
+ }
314
+ catch {
315
+ // Ignore cleanup failure.
316
+ }
317
+ throw error;
318
+ }
319
+ return artifactPath;
320
+ }
192
321
  // ── Main repair loop ───────────────────────────────────────────────────────────
193
322
  /**
194
323
  * Run the bounded QC repair loop.
@@ -293,10 +422,21 @@ async function runQcRepairLoop(options) {
293
422
  else {
294
423
  const existingManifestPath = loopState.manifest_path ??
295
424
  (0, node_path_1.join)(repoRoot, ".polaris", "clusters", clusterId, "qc", "repair-rounds", String(round), "repair-packets.json");
425
+ // Synchronize manifest packet statuses with the persisted loop state.
426
+ const completedSet = new Set(loopState.completed_packet_ids);
427
+ for (const packet of manifest.packets) {
428
+ if (completedSet.has(packet.packetId)) {
429
+ packet.status = "completed";
430
+ }
431
+ }
432
+ (0, repair_packets_js_1.writeRepairPacketManifest)(manifest, repoRoot);
296
433
  loopState = {
297
434
  ...loopState,
298
435
  current_round: round,
299
436
  manifest_path: existingManifestPath,
437
+ pending_packet_ids: manifest.packets
438
+ .filter((p) => p.status === "pending" && !completedSet.has(p.packetId))
439
+ .map((p) => p.packetId),
300
440
  updated_at: new Date().toISOString(),
301
441
  };
302
442
  onStateUpdate?.(loopState);
@@ -367,10 +507,16 @@ async function runQcRepairLoop(options) {
367
507
  const result = await dispatchRepairWorkerWithTimeout(dispatchRepairWorker, pkt, round, manifest, repairDispatchTimeoutMs, telemetryFile, runId, clusterId);
368
508
  allWorkerResults.push(result);
369
509
  }
370
- // Record completed packet IDs.
510
+ // Record completed packet IDs and update manifest statuses.
371
511
  const completedIds = allWorkerResults
372
512
  .filter((r) => r.status !== "skipped")
373
513
  .map((r) => r.packetId);
514
+ for (const packet of manifest.packets) {
515
+ if (completedIds.includes(packet.packetId)) {
516
+ packet.status = "completed";
517
+ }
518
+ }
519
+ (0, repair_packets_js_1.writeRepairPacketManifest)(manifest, repoRoot);
374
520
  loopState = {
375
521
  ...loopState,
376
522
  completed_packet_ids: [...loopState.completed_packet_ids, ...completedIds],
@@ -78,6 +78,16 @@ function subsystemFromFilePath(filePath) {
78
78
  const lastSlash = filePath.lastIndexOf("/");
79
79
  return lastSlash === -1 ? "root" : filePath.slice(0, lastSlash);
80
80
  }
81
+ function deriveSourceQcRunId(finding, fallbackRunId) {
82
+ // Some findingId formats encode the source QC run as `<provider>-<index>-<runId>`
83
+ // (e.g. coderabbit-1-1783818427443). Prefer that provenance over the result
84
+ // run ID so packet sourceQcRunIds match the runs that emitted the findings.
85
+ const match = finding.findingId.match(/^(.+?)-(\d+)-(\d+)$/);
86
+ if (match) {
87
+ return `${match[1]}-${match[3]}`;
88
+ }
89
+ return fallbackRunId;
90
+ }
81
91
  function enrichFinding(finding, config, sourceQcRunId) {
82
92
  return {
83
93
  ...finding,
@@ -85,7 +95,7 @@ function enrichFinding(finding, config, sourceQcRunId) {
85
95
  risk: riskLevel(finding),
86
96
  confidenceBand: confidenceBand(finding),
87
97
  subsystem: subsystemFromFilePath(finding.filePath),
88
- sourceQcRunId,
98
+ sourceQcRunId: deriveSourceQcRunId(finding, sourceQcRunId),
89
99
  };
90
100
  }
91
101
  /**
@@ -213,6 +223,8 @@ function buildPacket(group, index, clusterId, round, allRunIds, validationComman
213
223
  const packetId = `pkt-${clusterId}-r${round}-${String(index).padStart(3, "0")}`;
214
224
  const findingIds = group.map((f) => f.findingId).sort();
215
225
  const sourceRunIds = [...new Set(group.map((f) => f.sourceQcRunId))].sort();
226
+ // Preserve the artifact run ID while also capturing the finding-level source run.
227
+ const combinedSourceRunIds = [...new Set([...allRunIds, ...sourceRunIds])].sort();
216
228
  const severityFloor = group.reduce((acc, f) => (0, severity_js_1.maxSeverity)(acc, f.severity), "info");
217
229
  const categories = [...new Set(group.map((f) => f.category).filter(Boolean))].sort();
218
230
  const filePaths = group.map((f) => f.filePath).filter((f) => Boolean(f));
@@ -230,7 +242,7 @@ function buildPacket(group, index, clusterId, round, allRunIds, validationComman
230
242
  packetId,
231
243
  round,
232
244
  clusterId,
233
- sourceQcRunIds: sourceRunIds.length > 0 ? sourceRunIds : allRunIds,
245
+ sourceQcRunIds: combinedSourceRunIds,
234
246
  findingIds,
235
247
  severityFloor,
236
248
  rootCauseHint,
@@ -346,12 +358,13 @@ function compileRepairPackets(input) {
346
358
  packets.push(buildPacket(group, packets.length, clusterId, round, allRunIds, validationCommands, compiledAt));
347
359
  }
348
360
  const finalPackets = assignParallelGroups(packets);
361
+ const manifestSourceRunIds = [...new Set(finalPackets.flatMap((p) => p.sourceQcRunIds))].sort();
349
362
  const manifest = {
350
363
  schemaVersion: "1.0",
351
364
  clusterId,
352
365
  round,
353
366
  compiledAt,
354
- sourceQcRunIds: allRunIds,
367
+ sourceQcRunIds: manifestSourceRunIds.length > 0 ? manifestSourceRunIds : allRunIds,
355
368
  packets: finalPackets,
356
369
  };
357
370
  const manifestPath = getRepairPacketManifestPath(clusterId, round, repoRoot);
@@ -12,6 +12,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.decideRepairRouting = decideRepairRouting;
13
13
  const security_category_js_1 = require("./security-category.js");
14
14
  const severity_js_1 = require("./severity.js");
15
+ function isStateRepairCategory(category) {
16
+ return category === "state-repair";
17
+ }
15
18
  function getBlockThreshold(config, context) {
16
19
  const route = context.routeName ? config.routes?.[context.routeName] : undefined;
17
20
  return route?.blockThreshold ?? config.severityThresholds?.block ?? "high";
@@ -34,6 +37,9 @@ function decideRepairRouting(finding, config, autofixEligible, context = {}) {
34
37
  if ((0, security_category_js_1.isSecurityCategory)(finding.category)) {
35
38
  return "operator-review";
36
39
  }
40
+ if (isStateRepairCategory(finding.category)) {
41
+ return "operator-review";
42
+ }
37
43
  const attribution = finding.attribution;
38
44
  const clearAttribution = attribution.confidence === "high" || attribution.confidence === "medium";
39
45
  if (autofixEligible && clearAttribution && attribution.childId) {
package/dist/qc/types.js CHANGED
@@ -7,3 +7,6 @@
7
7
  * shapes so that Polaris can support multiple QC backends without vendor lock-in.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.QC_RESOLUTION_OUTCOMES = void 0;
11
+ /** Allowed outcomes for an operator resolution artifact. */
12
+ exports.QC_RESOLUTION_OUTCOMES = ["pass", "no-repairable"];
@@ -15,6 +15,7 @@ const migration_js_1 = require("./migration.js");
15
15
  */
16
16
  class LocalGraph {
17
17
  graph;
18
+ orderingDependenciesMerged = false;
18
19
  constructor(graph) {
19
20
  this.graph = graph;
20
21
  }
@@ -54,19 +55,76 @@ class LocalGraph {
54
55
  }
55
56
  /**
56
57
  * Persists the graph to `.polaris/clusters/{clusterId}/clusters.json`.
57
- * Creates the directory if it does not exist.
58
+ * Children are sorted topologically before writing so the saved cluster
59
+ * definition reflects dependency order.
58
60
  *
59
61
  * @param clusterId The cluster ID to use as the directory name (e.g., "POL-198").
60
62
  * @param repoRoot The root directory of the repository.
61
63
  * @returns The absolute path of the written file.
62
64
  */
63
65
  async save(clusterId, repoRoot = process.cwd()) {
66
+ this.mergeOrderingDependencies();
67
+ const persistedGraph = {
68
+ ...this.graph,
69
+ clusters: Object.fromEntries(Object.entries(this.graph.clusters).map(([id, cluster]) => [
70
+ id,
71
+ Array.isArray(cluster.children) && cluster.children.length > 1
72
+ ? { ...cluster, children: this.topoSortChildren(cluster.children) }
73
+ : cluster,
74
+ ])),
75
+ };
64
76
  const dir = node_path_1.default.join(repoRoot, ".polaris", "clusters", clusterId);
65
77
  await (0, promises_1.mkdir)(dir, { recursive: true });
66
78
  const filePath = node_path_1.default.join(dir, "clusters.json");
67
- await (0, promises_1.writeFile)(filePath, JSON.stringify(this.graph, null, 2), "utf-8");
79
+ await (0, promises_1.writeFile)(filePath, JSON.stringify(persistedGraph, null, 2), "utf-8");
68
80
  return filePath;
69
81
  }
82
+ /**
83
+ * Sort a list of children topologically using the graph dependencies.
84
+ * Children with no in-cluster dependencies come first; if a cycle exists,
85
+ * the remaining nodes are appended in their original order.
86
+ */
87
+ topoSortChildren(children) {
88
+ const childSet = new Set(children);
89
+ const inDegree = new Map();
90
+ const dependents = new Map();
91
+ for (const child of children) {
92
+ inDegree.set(child, 0);
93
+ dependents.set(child, []);
94
+ }
95
+ for (const child of children) {
96
+ for (const dep of this.getDependencies(child)) {
97
+ if (!childSet.has(dep))
98
+ continue;
99
+ inDegree.set(child, (inDegree.get(child) ?? 0) + 1);
100
+ dependents.get(dep).push(child);
101
+ }
102
+ }
103
+ const queue = [];
104
+ for (const [child, deg] of inDegree) {
105
+ if (deg === 0)
106
+ queue.push(child);
107
+ }
108
+ const sorted = [];
109
+ while (queue.length > 0) {
110
+ const node = queue.shift();
111
+ sorted.push(node);
112
+ for (const dependent of dependents.get(node) ?? []) {
113
+ const newDeg = (inDegree.get(dependent) ?? 1) - 1;
114
+ inDegree.set(dependent, newDeg);
115
+ if (newDeg === 0)
116
+ queue.push(dependent);
117
+ }
118
+ }
119
+ if (sorted.length < children.length) {
120
+ const sortedSet = new Set(sorted);
121
+ for (const child of children) {
122
+ if (!sortedSet.has(child))
123
+ sorted.push(child);
124
+ }
125
+ }
126
+ return sorted;
127
+ }
70
128
  /**
71
129
  * Returns the full v2 execution graph.
72
130
  */
@@ -87,12 +145,57 @@ class LocalGraph {
87
145
  return this.graph.nodes[id];
88
146
  }
89
147
  /**
90
- * Returns the dependencies for a given node.
148
+ * Returns the dependencies for a given node, merging any dependencies declared
149
+ * in the node's `## Ordering` body section.
91
150
  * @param id The ID of the node to get dependencies for.
92
151
  * @returns An array of node IDs that the given node is blocked by.
93
152
  */
94
153
  getDependencies(id) {
154
+ this.mergeOrderingDependencies();
95
155
  return this.graph.dependencies[id] ?? [];
96
156
  }
157
+ /**
158
+ * Extracts issue IDs from the `## Ordering` section of a node body.
159
+ *
160
+ * Only lines that explicitly declare ordering ("depends on" or "after" / "sequence after")
161
+ * contribute, and "before or after" clauses are ignored because they are not strict.
162
+ */
163
+ extractOrderingIds(body) {
164
+ const sectionMatch = body.match(/##\s*Ordering\b([\s\S]*?)(?:\n##\s|\n\n(?=\n##\s)|$)/i);
165
+ if (!sectionMatch)
166
+ return [];
167
+ const section = sectionMatch[1];
168
+ const cleaned = section.replace(/before\s+or\s+after\s*\[[^\]]*\]/gi, "");
169
+ const ids = new Set();
170
+ const re = /(?:depends\s+on|after)\s*\[?\s*(\w+-\d+)\b/gi;
171
+ for (const line of cleaned.split("\n")) {
172
+ if (!/^\s*[-*]\s/.test(line))
173
+ continue;
174
+ for (const match of line.matchAll(re)) {
175
+ ids.add(match[1]);
176
+ }
177
+ }
178
+ return [...ids];
179
+ }
180
+ /**
181
+ * Merges dependencies declared in node body `## Ordering` sections into the
182
+ * graph's explicit dependency map. This is idempotent for the loaded graph.
183
+ */
184
+ mergeOrderingDependencies() {
185
+ if (this.orderingDependenciesMerged)
186
+ return;
187
+ for (const node of Object.values(this.graph.nodes)) {
188
+ const orderingIds = this.extractOrderingIds(node.body ?? "");
189
+ if (orderingIds.length === 0)
190
+ continue;
191
+ const existing = new Set(this.graph.dependencies[node.id] ?? []);
192
+ for (const dep of orderingIds) {
193
+ if (dep !== node.id)
194
+ existing.add(dep);
195
+ }
196
+ this.graph.dependencies[node.id] = [...existing].sort();
197
+ }
198
+ this.orderingDependenciesMerged = true;
199
+ }
97
200
  }
98
201
  exports.LocalGraph = LocalGraph;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lsctech/polaris",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
4
4
  "description": "Polaris — standalone taskchain orchestration framework for governed AI agent workflows",
5
5
  "bin": {
6
6
  "polaris": "dist/cli/index.js",