@lsctech/polaris 0.5.9 → 0.5.10
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/cli/adopt-canon.js +16 -5
- package/dist/cli/adopt-cognition.js +23 -8
- package/dist/cognition/closeout-librarian-types.js +53 -0
- package/dist/cognition/librarian-packet.js +25 -1
- package/dist/cognition/validate.js +85 -3
- package/dist/config/defaults.js +1 -0
- package/dist/finalize/index.js +190 -3
- package/dist/loop/adapters/terminal-cli.js +3 -2
- package/dist/loop/parent.js +7 -1
- package/dist/qc/providers/coderabbit.js +90 -18
- package/dist/qc/repair-loop.js +108 -28
- package/dist/qc/runner.js +7 -1
- package/dist/smartdocs-engine/validate-instructions.js +60 -2
- package/package.json +1 -1
package/dist/cli/adopt-canon.js
CHANGED
|
@@ -148,7 +148,7 @@ function dispatchCanonAgent(options) {
|
|
|
148
148
|
.slice(0, 30)
|
|
149
149
|
.map((d, i) => `${i + 1}. [${d.title}] path: ${d.path}`)
|
|
150
150
|
.join("\n");
|
|
151
|
-
const prompt = `You are a Polaris librarian generating
|
|
151
|
+
const prompt = `You are a Polaris librarian generating route cognition for an agent work area.
|
|
152
152
|
|
|
153
153
|
Route folder: ${routeFolder}
|
|
154
154
|
Repo root: ${repoRoot}
|
|
@@ -156,16 +156,27 @@ Repo root: ${repoRoot}
|
|
|
156
156
|
Available doctrine documents:
|
|
157
157
|
${docList}
|
|
158
158
|
|
|
159
|
-
Generate two outputs for this route area
|
|
159
|
+
Generate two outputs for this route area.
|
|
160
160
|
|
|
161
|
-
1. SUMMARY.md content
|
|
161
|
+
1. SUMMARY.md content (current-state memory / navigation index):
|
|
162
|
+
- Include: 2-4 lines describing what this area covers, its current canonical status, and which doctrine docs are relevant.
|
|
163
|
+
- Avoid: operational procedures, step-by-step agent instructions, duplicated doctrine text, or per-run diary content.
|
|
162
164
|
|
|
163
|
-
2. POLARIS.md content
|
|
165
|
+
2. POLARIS.md content (route operating guidance):
|
|
166
|
+
REQUIRED sections that must be present in polaris_lines:
|
|
167
|
+
- Purpose/boundaries: what this area is responsible for and what it excludes
|
|
168
|
+
- Invariants/safety rules: constraints and what to avoid
|
|
169
|
+
- Commands/workflows: key patterns/conventions agents must follow
|
|
170
|
+
- Canonical links: which doctrine docs to consult for specific concerns
|
|
171
|
+
- Read-before-edit references: upstream/downstream dependencies
|
|
172
|
+
|
|
173
|
+
Avoid: navigation-index content, summary-style history, per-run notes, or duplicating doctrine text instead of linking to it.
|
|
164
174
|
|
|
165
175
|
Respond with ONLY valid JSON on a single line:
|
|
166
176
|
{"relevant_docs":[{"path":"<doc path>","title":"<doc title>"}],"summary_lines":["line1","line2"],"polaris_lines":["line1","line2"]}
|
|
167
177
|
|
|
168
|
-
If no doctrine docs are relevant, return an empty array for relevant_docs
|
|
178
|
+
If no doctrine docs are relevant, return an empty array for relevant_docs.
|
|
179
|
+
The polaris_lines array must contain all required POLARIS contract sections or be empty if the agent cannot generate valid content.`;
|
|
169
180
|
// Same dispatch pattern as dispatchLibrarianReview — pass args straight through,
|
|
170
181
|
// letting the provider config (e.g. env CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0) handle auth.
|
|
171
182
|
const args = (cfg.args ?? []).map((a) => (a === "{{worker_prompt}}" ? prompt : a));
|
|
@@ -119,7 +119,7 @@ function buildPolarisDraft(folder) {
|
|
|
119
119
|
"<!-- polaris:draft -->",
|
|
120
120
|
`# ${folderName}`,
|
|
121
121
|
"",
|
|
122
|
-
"> Polaris draft —
|
|
122
|
+
"> Polaris draft — route operating guidance. Remove the `<!-- polaris:draft -->` marker to promote.",
|
|
123
123
|
"",
|
|
124
124
|
"## Purpose",
|
|
125
125
|
"",
|
|
@@ -129,13 +129,15 @@ function buildPolarisDraft(folder) {
|
|
|
129
129
|
`**Route:** ${folder}`,
|
|
130
130
|
"**Taskchain:** unknown",
|
|
131
131
|
"",
|
|
132
|
-
"##
|
|
132
|
+
"## Responsibilities",
|
|
133
133
|
"",
|
|
134
|
-
"<!--
|
|
134
|
+
"<!-- What agents entering this route must know, do, and check. -->",
|
|
135
135
|
"",
|
|
136
|
-
"##
|
|
136
|
+
"## Constraints and exclusions",
|
|
137
137
|
"",
|
|
138
|
-
"
|
|
138
|
+
"- Do NOT duplicate doctrine from `smartdocs/doctrine/active/`; link to it instead.",
|
|
139
|
+
"- Do NOT use this file as a run diary; per-run notes belong in the run artifact, not here or in SUMMARY.md.",
|
|
140
|
+
"- Keep guidance operational and directive — this is not a history or index.",
|
|
139
141
|
"",
|
|
140
142
|
].join("\n");
|
|
141
143
|
}
|
|
@@ -145,15 +147,28 @@ function buildSummaryDraft(folder) {
|
|
|
145
147
|
"<!-- polaris:draft -->",
|
|
146
148
|
`# Summary — ${folderName}`,
|
|
147
149
|
"",
|
|
148
|
-
"> Polaris draft —
|
|
150
|
+
"> Polaris draft — current-state memory for this route. Remove the `<!-- polaris:draft -->` marker to promote.",
|
|
149
151
|
"",
|
|
150
152
|
`- Route: \`${folder}\``,
|
|
151
153
|
"- Canon status: draft",
|
|
152
154
|
"- Linked doctrine: `POLARIS.md`",
|
|
153
155
|
"",
|
|
154
|
-
"##
|
|
156
|
+
"## Current state",
|
|
155
157
|
"",
|
|
156
|
-
"<!--
|
|
158
|
+
"<!-- Synthesized current-state context: what's here, ownership, and domain. -->",
|
|
159
|
+
"",
|
|
160
|
+
"## Synthesized recent changes",
|
|
161
|
+
"",
|
|
162
|
+
"<!-- High-level summary of significant changes, not a per-run diary. Run-specific notes belong in run artifacts. -->",
|
|
163
|
+
"",
|
|
164
|
+
"## Caveats and drift",
|
|
165
|
+
"",
|
|
166
|
+
"<!-- Known gaps, stale areas, or planned changes that affect navigation. -->",
|
|
167
|
+
"",
|
|
168
|
+
"## Canonical sources",
|
|
169
|
+
"",
|
|
170
|
+
"- [POLARIS.md](POLARIS.md) — operational guidance for this route",
|
|
171
|
+
"<!-- Link to relevant doctrine from `smartdocs/doctrine/active/` -->",
|
|
157
172
|
"",
|
|
158
173
|
].join("\n");
|
|
159
174
|
}
|
|
@@ -118,6 +118,59 @@ function validateCloseoutLibrarianResult(value) {
|
|
|
118
118
|
if (!Array.isArray(r["summary_md_updates"])) {
|
|
119
119
|
errors.push("summary_md_updates must be an array");
|
|
120
120
|
}
|
|
121
|
+
if (r["artifact_reconciliation"] !== undefined &&
|
|
122
|
+
!Array.isArray(r["artifact_reconciliation"])) {
|
|
123
|
+
errors.push("artifact_reconciliation must be an array when provided");
|
|
124
|
+
}
|
|
125
|
+
if (Array.isArray(r["artifact_reconciliation"])) {
|
|
126
|
+
const allowed = new Set([
|
|
127
|
+
"polaris-only",
|
|
128
|
+
"summary-only",
|
|
129
|
+
"both",
|
|
130
|
+
"no-change",
|
|
131
|
+
]);
|
|
132
|
+
for (const [index, update] of r["artifact_reconciliation"].entries()) {
|
|
133
|
+
if (typeof update !== "object" || update === null) {
|
|
134
|
+
errors.push(`artifact_reconciliation[${index}] must be an object`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const entry = update;
|
|
138
|
+
if (typeof entry["folder"] !== "string" || !entry["folder"]) {
|
|
139
|
+
errors.push(`artifact_reconciliation[${index}].folder must be a non-empty string`);
|
|
140
|
+
}
|
|
141
|
+
const decision = entry["decision"];
|
|
142
|
+
if (typeof entry["decision"] !== "string" ||
|
|
143
|
+
!allowed.has(decision)) {
|
|
144
|
+
errors.push(`artifact_reconciliation[${index}].decision must be one of: polaris-only, summary-only, both, no-change`);
|
|
145
|
+
}
|
|
146
|
+
if (typeof entry["polaris_md"] !== "string" || !entry["polaris_md"]) {
|
|
147
|
+
errors.push(`artifact_reconciliation[${index}].polaris_md must be a non-empty string`);
|
|
148
|
+
}
|
|
149
|
+
// Validate summary_md based on decision: required for summary-only and both, null for polaris-only and no-change
|
|
150
|
+
if (decision === "summary-only" || decision === "both") {
|
|
151
|
+
if (typeof entry["summary_md"] !== "string" || !entry["summary_md"]) {
|
|
152
|
+
errors.push(`artifact_reconciliation[${index}].summary_md must be a non-empty string for decision "${decision}"`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else if (decision === "polaris-only" || decision === "no-change") {
|
|
156
|
+
if (entry["summary_md"] !== null && (typeof entry["summary_md"] !== "string" || !entry["summary_md"])) {
|
|
157
|
+
errors.push(`artifact_reconciliation[${index}].summary_md must be a non-empty string or null for decision "${decision}"`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
// Fallback for any decision type: reject empty strings
|
|
162
|
+
if (entry["summary_md"] !== null && typeof entry["summary_md"] !== "string") {
|
|
163
|
+
errors.push(`artifact_reconciliation[${index}].summary_md must be a string or null`);
|
|
164
|
+
}
|
|
165
|
+
if (typeof entry["summary_md"] === "string" && !entry["summary_md"]) {
|
|
166
|
+
errors.push(`artifact_reconciliation[${index}].summary_md must not be an empty string when provided`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (typeof entry["reason"] !== "string" || !entry["reason"]) {
|
|
170
|
+
errors.push(`artifact_reconciliation[${index}].reason must be a non-empty string`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
121
174
|
if (!Array.isArray(r["docs_ingested"])) {
|
|
122
175
|
errors.push("docs_ingested must be an array");
|
|
123
176
|
}
|
|
@@ -76,6 +76,24 @@ function generateLibrarianPacket(options) {
|
|
|
76
76
|
cognition_index: node_fs_1.default.existsSync(cognitionIndexAbs)
|
|
77
77
|
? node_path_1.default.relative(repoRoot, cognitionIndexAbs)
|
|
78
78
|
: null,
|
|
79
|
+
artifact_contract: {
|
|
80
|
+
polaris_md: {
|
|
81
|
+
path: node_path_1.default.join(folder, "POLARIS.md"),
|
|
82
|
+
intent: "must-reconcile",
|
|
83
|
+
reason: "Affected folders must reconcile POLARIS.md to keep operational doctrine current.",
|
|
84
|
+
},
|
|
85
|
+
summary_md: node_fs_1.default.existsSync(summaryAbs)
|
|
86
|
+
? {
|
|
87
|
+
path: node_path_1.default.join(folder, "SUMMARY.md"),
|
|
88
|
+
intent: "reconcile-if-present",
|
|
89
|
+
reason: "SUMMARY.md exists for this folder and may be reconciled independently when informational canon changed.",
|
|
90
|
+
}
|
|
91
|
+
: {
|
|
92
|
+
path: null,
|
|
93
|
+
intent: "not-present",
|
|
94
|
+
reason: "SUMMARY.md is missing for this folder; skip SUMMARY reconciliation and evaluate POLARIS.md independently.",
|
|
95
|
+
},
|
|
96
|
+
},
|
|
79
97
|
};
|
|
80
98
|
});
|
|
81
99
|
const cognitionNotes = findPendingCognitionNotes(repoRoot, state.completed_children);
|
|
@@ -103,10 +121,16 @@ function generateLibrarianPacket(options) {
|
|
|
103
121
|
node_path_1.default.join(repoRoot, "scripts"),
|
|
104
122
|
];
|
|
105
123
|
// Allowed: documentation and cognition paths only
|
|
124
|
+
// Build explicit smartdocs paths instead of granting the entire root
|
|
125
|
+
const smartdocsRawDir = node_path_1.default.join(repoRoot, "smartdocs", "raw");
|
|
126
|
+
const smartdocsSpecsActiveDir = node_path_1.default.join(repoRoot, "smartdocs", "specs", "active");
|
|
127
|
+
const smartdocsDoctrineActiveDir = node_path_1.default.join(repoRoot, "smartdocs", "doctrine", "active");
|
|
106
128
|
const allowedWritePaths = [
|
|
107
129
|
...polarisMdPaths.map((p) => node_path_1.default.join(repoRoot, p.polaris_md)),
|
|
108
130
|
...polarisMdPaths.filter((p) => p.summary_md).map((p) => node_path_1.default.join(repoRoot, p.summary_md)),
|
|
109
|
-
|
|
131
|
+
smartdocsRawDir,
|
|
132
|
+
smartdocsSpecsActiveDir,
|
|
133
|
+
smartdocsDoctrineActiveDir,
|
|
110
134
|
node_path_1.default.join(repoRoot, ".polaris", "cognition", "archive"),
|
|
111
135
|
resultPath,
|
|
112
136
|
];
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - Locality assumptions (route-local only, no repo-wide surface)
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.computeNormalizedSimilarity = computeNormalizedSimilarity;
|
|
12
13
|
exports.looksLikePolarisChurn = looksLikePolarisChurn;
|
|
13
14
|
exports.validateCognitionSurfaces = validateCognitionSurfaces;
|
|
14
15
|
exports.validateSummaryFile = validateSummaryFile;
|
|
@@ -17,6 +18,48 @@ const node_fs_1 = require("node:fs");
|
|
|
17
18
|
const node_path_1 = require("node:path");
|
|
18
19
|
const route_cognition_delta_js_1 = require("./route-cognition-delta.js");
|
|
19
20
|
const summary_delta_js_1 = require("./summary-delta.js");
|
|
21
|
+
const DEFAULT_PAIRWISE_DRIFT_THRESHOLD = 0.5;
|
|
22
|
+
// ── Pairwise POLARIS.md / SUMMARY.md drift detection ───────────────────────────
|
|
23
|
+
function escapeRegExp(s) {
|
|
24
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Normalize route artifact text so shared boilerplate (headings, links, route
|
|
28
|
+
* names) does not dominate similarity scores.
|
|
29
|
+
*/
|
|
30
|
+
function normalizeRouteArtifact(content, routeName) {
|
|
31
|
+
let s = content.toLowerCase();
|
|
32
|
+
// Strip markdown headings
|
|
33
|
+
s = s.replace(/^#+\s+.*$/gm, " ");
|
|
34
|
+
// Remove link URLs but keep link text
|
|
35
|
+
s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
36
|
+
// Remove bare URLs
|
|
37
|
+
s = s.replace(/https?:\/\/\S+/g, " ");
|
|
38
|
+
// Remove route name tokens
|
|
39
|
+
if (routeName) {
|
|
40
|
+
for (const token of routeName.split(/[-_\s]+/).filter(Boolean)) {
|
|
41
|
+
s = s.replace(new RegExp(`\\b${escapeRegExp(token)}\\b`, "g"), " ");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Drop non-alphanumeric characters
|
|
45
|
+
s = s.replace(/[^\p{L}\p{N}\s]+/gu, " ");
|
|
46
|
+
// Collapse whitespace
|
|
47
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Compute a normalized Jaccard similarity between two route artifacts.
|
|
52
|
+
* Returns a value between 0 and 1.
|
|
53
|
+
*/
|
|
54
|
+
function computeNormalizedSimilarity(a, b, routeName) {
|
|
55
|
+
const tokensA = new Set(normalizeRouteArtifact(a, routeName).split(" ").filter(Boolean));
|
|
56
|
+
const tokensB = new Set(normalizeRouteArtifact(b, routeName).split(" ").filter(Boolean));
|
|
57
|
+
if (tokensA.size === 0 && tokensB.size === 0)
|
|
58
|
+
return 0;
|
|
59
|
+
const intersection = new Set([...tokensA].filter((t) => tokensB.has(t)));
|
|
60
|
+
const union = new Set([...tokensA, ...tokensB]);
|
|
61
|
+
return union.size === 0 ? 0 : intersection.size / union.size;
|
|
62
|
+
}
|
|
20
63
|
// ── POLARIS.md churn detection ────────────────────────────────────────────────
|
|
21
64
|
/**
|
|
22
65
|
* Signals that indicate a POLARIS.md change is likely non-operational churn
|
|
@@ -62,18 +105,21 @@ function* walkForCognitionFiles(dir, repoRoot) {
|
|
|
62
105
|
}
|
|
63
106
|
}
|
|
64
107
|
}
|
|
65
|
-
// ── Main validation entry point ───────────────────────────────────────────────
|
|
66
108
|
/**
|
|
67
109
|
* Validate all route-local cognition surfaces under repoRoot.
|
|
68
110
|
*
|
|
69
111
|
* Checks:
|
|
70
112
|
* 1. SUMMARY.md size guard (≤ SUMMARY_MAX_BYTES)
|
|
71
113
|
* 2. SUMMARY.md doctrine bleed
|
|
72
|
-
* 3.
|
|
114
|
+
* 3. Pairwise POLARIS.md / SUMMARY.md drift (exact duplicate → error,
|
|
115
|
+
* high normalized similarity → warn)
|
|
116
|
+
* 4. Locality: POLARIS.md at root is not flagged (root is special)
|
|
73
117
|
*/
|
|
74
|
-
function validateCognitionSurfaces(repoRoot) {
|
|
118
|
+
function validateCognitionSurfaces(repoRoot, options = {}) {
|
|
75
119
|
const violations = [];
|
|
76
120
|
const warnings = [];
|
|
121
|
+
const threshold = options.similarityThreshold ?? DEFAULT_PAIRWISE_DRIFT_THRESHOLD;
|
|
122
|
+
const pairs = new Map();
|
|
77
123
|
for (const { rel, name } of walkForCognitionFiles((0, node_path_1.resolve)(repoRoot), repoRoot)) {
|
|
78
124
|
// Skip root-level files — root cognition is special (AGENTS.md / CLAUDE.md)
|
|
79
125
|
if (!rel.includes("/"))
|
|
@@ -85,7 +131,16 @@ function validateCognitionSurfaces(repoRoot) {
|
|
|
85
131
|
catch {
|
|
86
132
|
continue;
|
|
87
133
|
}
|
|
134
|
+
const absDir = (0, node_path_1.dirname)((0, node_path_1.resolve)(repoRoot, rel));
|
|
135
|
+
const dirRel = (0, node_path_1.relative)(repoRoot, absDir).replace(/\\/g, "/");
|
|
136
|
+
let entry = pairs.get(absDir);
|
|
137
|
+
if (!entry) {
|
|
138
|
+
entry = { dirRel };
|
|
139
|
+
pairs.set(absDir, entry);
|
|
140
|
+
}
|
|
88
141
|
if (name === "SUMMARY.md") {
|
|
142
|
+
entry.summary = content;
|
|
143
|
+
entry.summaryRel = rel;
|
|
89
144
|
if ((0, summary_delta_js_1.isSummaryOversized)(content)) {
|
|
90
145
|
const bytes = Buffer.byteLength(content, "utf-8");
|
|
91
146
|
violations.push({
|
|
@@ -106,6 +161,33 @@ function validateCognitionSurfaces(repoRoot) {
|
|
|
106
161
|
});
|
|
107
162
|
}
|
|
108
163
|
}
|
|
164
|
+
else if (name === "POLARIS.md") {
|
|
165
|
+
entry.polaris = content;
|
|
166
|
+
entry.polarisRel = rel;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const entry of pairs.values()) {
|
|
170
|
+
if (!entry.polaris || !entry.summary || !entry.polarisRel || !entry.summaryRel)
|
|
171
|
+
continue;
|
|
172
|
+
if (entry.polaris === entry.summary) {
|
|
173
|
+
violations.push({
|
|
174
|
+
type: "polaris-summary-drift",
|
|
175
|
+
file: entry.dirRel,
|
|
176
|
+
detail: `Route ${entry.dirRel}: POLARIS.md and SUMMARY.md are exact duplicates (${entry.polarisRel}, ${entry.summaryRel})`,
|
|
177
|
+
severity: "error",
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const routeName = entry.dirRel === "." ? undefined : (0, node_path_1.basename)(entry.dirRel);
|
|
182
|
+
const similarity = computeNormalizedSimilarity(entry.polaris, entry.summary, routeName);
|
|
183
|
+
if (similarity >= threshold) {
|
|
184
|
+
warnings.push({
|
|
185
|
+
type: "polaris-summary-drift",
|
|
186
|
+
file: entry.dirRel,
|
|
187
|
+
detail: `Route ${entry.dirRel}: POLARIS.md and SUMMARY.md normalized similarity ${similarity.toFixed(2)} exceeds threshold ${threshold} (${entry.polarisRel}, ${entry.summaryRel})`,
|
|
188
|
+
severity: "warn",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
109
191
|
}
|
|
110
192
|
return {
|
|
111
193
|
valid: violations.length === 0,
|
package/dist/config/defaults.js
CHANGED
package/dist/finalize/index.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.validateAuthoritativeChildState = validateAuthoritativeChildState;
|
|
|
7
7
|
exports.runFinalize = runFinalize;
|
|
8
8
|
exports.createFinalizeCommand = createFinalizeCommand;
|
|
9
9
|
const commander_1 = require("commander");
|
|
10
|
+
const node_crypto_1 = require("node:crypto");
|
|
10
11
|
const node_path_1 = require("node:path");
|
|
11
12
|
const node_fs_1 = require("node:fs");
|
|
12
13
|
const node_child_process_1 = require("node:child_process");
|
|
@@ -35,7 +36,11 @@ const index_js_1 = require("../tracker/sync/index.js");
|
|
|
35
36
|
const finalize_evidence_js_1 = require("../loop/finalize-evidence.js");
|
|
36
37
|
const delivery_integrity_js_1 = require("./delivery-integrity.js");
|
|
37
38
|
const medic_gate_js_1 = require("./medic-gate.js");
|
|
39
|
+
const qc_escalation_js_1 = require("../run-health/qc-escalation.js");
|
|
38
40
|
const index_js_2 = require("../qc/index.js");
|
|
41
|
+
const worker_packet_js_1 = require("../loop/worker-packet.js");
|
|
42
|
+
const registry_js_1 = require("../loop/adapters/registry.js");
|
|
43
|
+
const dispatch_js_1 = require("../loop/dispatch.js");
|
|
39
44
|
function getBranch(repoRoot) {
|
|
40
45
|
try {
|
|
41
46
|
return (0, node_child_process_1.execFileSync)("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
@@ -368,6 +373,188 @@ async function runQcGate(options) {
|
|
|
368
373
|
process.stderr.write(`${stepLabel} QC ${trigger} blocked finalize: ${result.summary}\n`);
|
|
369
374
|
process.exit(1);
|
|
370
375
|
}
|
|
376
|
+
function parseWorkerSummary(summary) {
|
|
377
|
+
if (!summary)
|
|
378
|
+
return null;
|
|
379
|
+
try {
|
|
380
|
+
const parsed = JSON.parse(summary);
|
|
381
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
382
|
+
return parsed;
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
async function runCompletedClusterQcWithRepair(options) {
|
|
391
|
+
const { config, state, stateFile, repoRoot, branch, baseRef, stepLabel } = options;
|
|
392
|
+
const registry = (0, index_js_2.createQcRegistry)(config.qc);
|
|
393
|
+
const qcResult = await (0, index_js_2.runQcAtTrigger)({
|
|
394
|
+
config: config.qc,
|
|
395
|
+
registry,
|
|
396
|
+
trigger: "completed-cluster",
|
|
397
|
+
repoRoot,
|
|
398
|
+
runId: state.run_id,
|
|
399
|
+
clusterId: state.cluster_id,
|
|
400
|
+
branch,
|
|
401
|
+
baseRef,
|
|
402
|
+
telemetryFile: resolveQcTelemetryFile(state, repoRoot),
|
|
403
|
+
state,
|
|
404
|
+
});
|
|
405
|
+
if (qcResult.action === "pass") {
|
|
406
|
+
const maxRounds = config.qc.maxRepairRounds ?? index_js_2.DEFAULT_MAX_REPAIR_ROUNDS;
|
|
407
|
+
const sourceQcRunIds = qcResult.results.map((r) => r.qcRunId).filter(Boolean);
|
|
408
|
+
const existingLoop = state.qc_repair_loop;
|
|
409
|
+
const now = new Date().toISOString();
|
|
410
|
+
const passLoopState = existingLoop
|
|
411
|
+
? {
|
|
412
|
+
...existingLoop,
|
|
413
|
+
terminal_outcome: existingLoop.terminal_outcome ?? "pass",
|
|
414
|
+
updated_at: existingLoop.terminal_outcome == null ? now : (existingLoop.updated_at ?? now),
|
|
415
|
+
}
|
|
416
|
+
: {
|
|
417
|
+
...(0, index_js_2.initRepairLoopState)({ maxRounds, sourceQcRunIds }),
|
|
418
|
+
terminal_outcome: "pass",
|
|
419
|
+
updated_at: now,
|
|
420
|
+
};
|
|
421
|
+
const passedState = { ...state, qc_repair_loop: passLoopState };
|
|
422
|
+
(0, checkpoint_js_1.writeStateAtomic)(stateFile, passedState);
|
|
423
|
+
console.log(`${stepLabel} QC completed-cluster passed: ${qcResult.summary}`);
|
|
424
|
+
return passedState;
|
|
425
|
+
}
|
|
426
|
+
// Append QC escalation symptoms for the initial non-passing completed-cluster QC result.
|
|
427
|
+
if (qcResult.results.length > 0) {
|
|
428
|
+
(0, qc_escalation_js_1.appendQcEscalationSymptoms)({
|
|
429
|
+
runId: state.run_id,
|
|
430
|
+
clusterId: state.cluster_id,
|
|
431
|
+
qcResults: qcResult.results,
|
|
432
|
+
afterRepair: false,
|
|
433
|
+
repoRoot,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
const repairRouting = config.qc.repairRouting ?? "route";
|
|
437
|
+
if (repairRouting !== "route" && repairRouting !== "follow-up") {
|
|
438
|
+
process.stderr.write(`${stepLabel} QC completed-cluster blocked finalize: ${qcResult.summary}\n`);
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
const adapterName = config.execution?.adapter ?? "terminal-cli";
|
|
442
|
+
let providerName;
|
|
443
|
+
if (adapterName === "agent-subtask") {
|
|
444
|
+
providerName = "agent-subtask";
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
try {
|
|
448
|
+
const providerEvidence = (0, dispatch_js_1.resolveProviderAndMode)({ stateFile, repoRoot }, "worker", config);
|
|
449
|
+
providerName = providerEvidence.provider ?? "default";
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
453
|
+
process.stderr.write(`${stepLabel} QC repair loop provider resolution failed: ${msg}\n`);
|
|
454
|
+
process.exit(1);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const executionConfig = adapterName === "agent-subtask"
|
|
458
|
+
? { ...(config.execution ?? { providers: {} }), adapter: "agent-subtask" }
|
|
459
|
+
: config.execution ?? { adapter: adapterName, providers: {} };
|
|
460
|
+
const adapter = (0, registry_js_1.createAdapter)(adapterName, executionConfig);
|
|
461
|
+
const maxConcurrentWorkers = config.execution?.routerPolicy?.defaultWorkerPool?.maxActiveWorkers ?? 1;
|
|
462
|
+
const telemetryFile = resolveQcTelemetryFile(state, repoRoot);
|
|
463
|
+
let nextState = state;
|
|
464
|
+
const repairDispatcher = async (packet, round, manifest, signal) => {
|
|
465
|
+
const dispatchId = (0, node_crypto_1.randomUUID)();
|
|
466
|
+
const repairResultPath = (0, node_path_1.join)(repoRoot, ".polaris", "clusters", nextState.cluster_id, "results", `repair-${packet.packetId}-${dispatchId}.json`);
|
|
467
|
+
const workerPacket = (0, worker_packet_js_1.compileRepairWorkerPacket)({
|
|
468
|
+
runId: nextState.run_id,
|
|
469
|
+
clusterId: nextState.cluster_id,
|
|
470
|
+
packetId: packet.packetId,
|
|
471
|
+
branch,
|
|
472
|
+
stateFile,
|
|
473
|
+
telemetryFile,
|
|
474
|
+
round,
|
|
475
|
+
allowedScope: packet.allowedScope,
|
|
476
|
+
prohibitedScope: packet.prohibitedScope,
|
|
477
|
+
validationCommands: packet.validationCommands,
|
|
478
|
+
rootCauseHint: packet.rootCauseHint,
|
|
479
|
+
resultFile: repairResultPath,
|
|
480
|
+
maxConcurrentWorkers,
|
|
481
|
+
});
|
|
482
|
+
try {
|
|
483
|
+
// TODO: Propagate AbortSignal to adapter.dispatch() once ExecutionAdapter
|
|
484
|
+
// interface supports cancellation. Current limitation: timeout abandons the
|
|
485
|
+
// Promise but doesn't terminate the spawned worker process.
|
|
486
|
+
if (signal?.aborted) {
|
|
487
|
+
throw signal.reason || new Error("Repair dispatch aborted");
|
|
488
|
+
}
|
|
489
|
+
const dispatchResult = await adapter.dispatch(workerPacket, { provider: providerName });
|
|
490
|
+
const workerSummary = parseWorkerSummary(dispatchResult.summary);
|
|
491
|
+
const success = workerSummary?.status === "done";
|
|
492
|
+
const commitSha = workerSummary && typeof workerSummary["commit"] === "string"
|
|
493
|
+
? workerSummary["commit"]
|
|
494
|
+
: undefined;
|
|
495
|
+
const errorMessage = workerSummary && typeof workerSummary["error_message"] === "string"
|
|
496
|
+
? workerSummary["error_message"]
|
|
497
|
+
: "repair worker failed";
|
|
498
|
+
return {
|
|
499
|
+
packetId: packet.packetId,
|
|
500
|
+
status: success ? "success" : "failure",
|
|
501
|
+
commitSha,
|
|
502
|
+
errorMessage: success ? undefined : errorMessage,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
507
|
+
return {
|
|
508
|
+
packetId: packet.packetId,
|
|
509
|
+
status: "failure",
|
|
510
|
+
errorMessage: msg,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
const repairLoopResult = await (0, index_js_2.runQcRepairLoop)({
|
|
515
|
+
clusterId: nextState.cluster_id,
|
|
516
|
+
runId: nextState.run_id,
|
|
517
|
+
branch,
|
|
518
|
+
repoRoot,
|
|
519
|
+
telemetryFile,
|
|
520
|
+
config: config.qc,
|
|
521
|
+
registry,
|
|
522
|
+
initialQcResults: qcResult.results,
|
|
523
|
+
dispatchRepairWorker: repairDispatcher,
|
|
524
|
+
maxRounds: config.qc.maxRepairRounds ?? index_js_2.DEFAULT_MAX_REPAIR_ROUNDS,
|
|
525
|
+
priorLoopState: nextState.qc_repair_loop ?? null,
|
|
526
|
+
onStateUpdate: (loopState) => {
|
|
527
|
+
nextState = { ...nextState, qc_repair_loop: loopState };
|
|
528
|
+
(0, checkpoint_js_1.writeStateAtomic)(stateFile, nextState);
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
nextState = { ...nextState, qc_repair_loop: repairLoopResult.loop_state };
|
|
532
|
+
(0, checkpoint_js_1.writeStateAtomic)(stateFile, nextState);
|
|
533
|
+
// Append QC escalation symptoms for non-passing repair-loop outcomes and post-repair findings.
|
|
534
|
+
(0, qc_escalation_js_1.appendRepairLoopOutcomeSymptom)({
|
|
535
|
+
runId: nextState.run_id,
|
|
536
|
+
clusterId: nextState.cluster_id,
|
|
537
|
+
repairResult: repairLoopResult,
|
|
538
|
+
repoRoot,
|
|
539
|
+
});
|
|
540
|
+
if (repairLoopResult.final_qc_results.length > 0 && repairLoopResult.rounds_completed > 0) {
|
|
541
|
+
(0, qc_escalation_js_1.appendQcEscalationSymptoms)({
|
|
542
|
+
runId: nextState.run_id,
|
|
543
|
+
clusterId: nextState.cluster_id,
|
|
544
|
+
qcResults: repairLoopResult.final_qc_results,
|
|
545
|
+
afterRepair: true,
|
|
546
|
+
repoRoot,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
const repairLoopBlocker = validateQcRepairLoopGate(nextState, config.qc);
|
|
550
|
+
if (repairLoopBlocker) {
|
|
551
|
+
process.stderr.write(`${stepLabel} QC completed-cluster blocked finalize: ${qcResult.summary}\n` +
|
|
552
|
+
`${repairLoopBlocker}\n`);
|
|
553
|
+
process.exit(1);
|
|
554
|
+
}
|
|
555
|
+
console.log(`${stepLabel} QC completed-cluster repaired: ${repairLoopResult.summary}`);
|
|
556
|
+
return nextState;
|
|
557
|
+
}
|
|
371
558
|
async function runFinalize(options) {
|
|
372
559
|
const { repoRoot, stateFile, dryRun, skipDelivery, skipLibrarian, bypassMedicReason } = options;
|
|
373
560
|
const config = (0, loader_js_1.loadConfig)(repoRoot);
|
|
@@ -554,13 +741,13 @@ async function runFinalize(options) {
|
|
|
554
741
|
const qcBaseRef = clusterStateForQc?.base_branch ??
|
|
555
742
|
config.finalize?.targetBranch ??
|
|
556
743
|
"main";
|
|
557
|
-
await
|
|
558
|
-
config
|
|
744
|
+
state = await runCompletedClusterQcWithRepair({
|
|
745
|
+
config,
|
|
559
746
|
state,
|
|
747
|
+
stateFile: (0, node_path_1.resolve)(stateFile),
|
|
560
748
|
repoRoot,
|
|
561
749
|
branch,
|
|
562
750
|
baseRef: qcBaseRef,
|
|
563
|
-
trigger: "completed-cluster",
|
|
564
751
|
stepLabel: "[5.8/14]",
|
|
565
752
|
});
|
|
566
753
|
// Step 5.9: QC repair-loop terminal state gate
|
|
@@ -169,10 +169,11 @@ class TerminalCliAdapter {
|
|
|
169
169
|
const primaryProvider = options.provider || "terminal-cli";
|
|
170
170
|
const routerEvidence = options.routerDecision;
|
|
171
171
|
const providerAttempts = [];
|
|
172
|
-
if ((0, worker_packet_js_1.isWorkerPacket)(packet) &&
|
|
172
|
+
if ((0, worker_packet_js_1.isWorkerPacket)(packet) &&
|
|
173
|
+
(packet.worker_role === "impl" || packet.worker_role === "repair")) {
|
|
173
174
|
const allowed = Array.isArray(packet.instructions?.allowed_scope) ? packet.instructions.allowed_scope : [];
|
|
174
175
|
if (allowed.length === 0) {
|
|
175
|
-
const blockedMsg = `Worker blocked:
|
|
176
|
+
const blockedMsg = `Worker blocked: ${packet.worker_role} packet for ${packet.active_child} has empty allowed_scope. Foreman must provide scope or approve override.`;
|
|
176
177
|
return {
|
|
177
178
|
exit_code: 1,
|
|
178
179
|
provider_used: primaryProvider,
|
package/dist/loop/parent.js
CHANGED
|
@@ -949,7 +949,7 @@ async function runParentLoop(options) {
|
|
|
949
949
|
const maxRepairRounds = config.qc.maxRepairRounds ?? repair_loop_js_1.DEFAULT_MAX_REPAIR_ROUNDS;
|
|
950
950
|
const priorLoopState = state.qc_repair_loop ?? null;
|
|
951
951
|
// Build the repair worker dispatcher using the existing adapter + provider.
|
|
952
|
-
const repairDispatcher = async (packet, round, manifest) => {
|
|
952
|
+
const repairDispatcher = async (packet, round, manifest, signal) => {
|
|
953
953
|
const repairPacketId = packet.packetId;
|
|
954
954
|
const dispatchId = (0, node_crypto_1.randomUUID)();
|
|
955
955
|
const repairWorkerId = `${state.run_id}:repair-${repairPacketId}:${Date.now()}`;
|
|
@@ -979,6 +979,12 @@ async function runParentLoop(options) {
|
|
|
979
979
|
timestamp: new Date().toISOString(),
|
|
980
980
|
});
|
|
981
981
|
try {
|
|
982
|
+
// TODO: Propagate AbortSignal to adapter.dispatch() once ExecutionAdapter
|
|
983
|
+
// interface supports cancellation. Current limitation: timeout abandons the
|
|
984
|
+
// Promise but doesn't terminate the spawned worker process.
|
|
985
|
+
if (signal?.aborted) {
|
|
986
|
+
throw signal.reason || new Error("Repair dispatch aborted");
|
|
987
|
+
}
|
|
982
988
|
const dispatchResult = await adapter.dispatch(workerPacket, { provider: providerName, dryRun });
|
|
983
989
|
const workerSummary = parseWorkerSummary(dispatchResult.summary);
|
|
984
990
|
const success = workerSummary?.status === "done";
|
|
@@ -17,18 +17,20 @@ function pickString(...candidates) {
|
|
|
17
17
|
return undefined;
|
|
18
18
|
}
|
|
19
19
|
const FINDING_LOCATION_KEYS = ["file", "filePath", "path"];
|
|
20
|
-
const
|
|
21
|
-
"severity",
|
|
20
|
+
const FINDING_CONTENT_KEYS = [
|
|
22
21
|
"message",
|
|
23
22
|
"title",
|
|
24
23
|
"summary",
|
|
25
24
|
"description",
|
|
26
25
|
"body",
|
|
27
|
-
"rule",
|
|
28
|
-
"category",
|
|
29
26
|
"suggestion",
|
|
30
27
|
"suggestedAction",
|
|
31
28
|
"fix",
|
|
29
|
+
];
|
|
30
|
+
const FINDING_BOOKKEEPING_KEYS = [
|
|
31
|
+
"severity",
|
|
32
|
+
"category",
|
|
33
|
+
"rule",
|
|
32
34
|
"providerFindingId",
|
|
33
35
|
"id",
|
|
34
36
|
"findingId",
|
|
@@ -56,8 +58,37 @@ const PROGRESS_STATUS_VALUES = new Set([
|
|
|
56
58
|
function hasFindingLocation(record) {
|
|
57
59
|
return FINDING_LOCATION_KEYS.some((key) => record[key] !== undefined);
|
|
58
60
|
}
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
+
function isGenuineTitle(record) {
|
|
62
|
+
const title = record.title;
|
|
63
|
+
if (typeof title !== "string" || title.trim().length === 0) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const trimmedTitle = title.trim().toLowerCase();
|
|
67
|
+
const fallbackSources = [record.category, record.type, record.rule];
|
|
68
|
+
for (const source of fallbackSources) {
|
|
69
|
+
if (typeof source === "string" && source.trim().toLowerCase() === trimmedTitle) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
function hasFindingContent(record) {
|
|
76
|
+
for (const key of FINDING_CONTENT_KEYS) {
|
|
77
|
+
if (key === "title") {
|
|
78
|
+
if (isGenuineTitle(record))
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
else if (record[key] !== undefined) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
function hasFindingBookkeeping(record) {
|
|
88
|
+
return FINDING_BOOKKEEPING_KEYS.some((key) => record[key] !== undefined);
|
|
89
|
+
}
|
|
90
|
+
function hasFindingShape(record) {
|
|
91
|
+
return hasFindingLocation(record) || hasFindingContent(record) || hasFindingBookkeeping(record);
|
|
61
92
|
}
|
|
62
93
|
function isProgressRecord(record) {
|
|
63
94
|
// Check progress/status indicators FIRST before the generic finding-content guard
|
|
@@ -73,8 +104,8 @@ function isProgressRecord(record) {
|
|
|
73
104
|
// Status-only records with category="status" are progress records even if they have message/title fields
|
|
74
105
|
if (typeof record.category === "string" && record.category.toLowerCase() === "status")
|
|
75
106
|
return true;
|
|
76
|
-
// Only reject as progress if it has both location AND
|
|
77
|
-
if (hasFindingLocation(record) &&
|
|
107
|
+
// Only reject as progress if it has both location AND content (true finding shape)
|
|
108
|
+
if (hasFindingLocation(record) && hasFindingContent(record)) {
|
|
78
109
|
return false;
|
|
79
110
|
}
|
|
80
111
|
return false;
|
|
@@ -82,7 +113,10 @@ function isProgressRecord(record) {
|
|
|
82
113
|
function isActionableFinding(record) {
|
|
83
114
|
if (isProgressRecord(record))
|
|
84
115
|
return false;
|
|
85
|
-
return hasFindingLocation(record) ||
|
|
116
|
+
return hasFindingLocation(record) || hasFindingContent(record);
|
|
117
|
+
}
|
|
118
|
+
function isUnusableFindingRecord(record) {
|
|
119
|
+
return !isActionableFinding(record) && (isProgressRecord(record) || hasFindingShape(record));
|
|
86
120
|
}
|
|
87
121
|
function makeUnusableOutputError(message) {
|
|
88
122
|
const err = new Error(message);
|
|
@@ -136,6 +170,21 @@ function parseReport(output, format, parser) {
|
|
|
136
170
|
if (data !== undefined && typeof data === "object" && data !== null) {
|
|
137
171
|
const findings = parseFindingsFromPayload(data);
|
|
138
172
|
const record = data;
|
|
173
|
+
if (findings.length === 0) {
|
|
174
|
+
if (isProgressRecord(record)) {
|
|
175
|
+
throw makeUnusableOutputError("CodeRabbit output was a progress/status/heartbeat record");
|
|
176
|
+
}
|
|
177
|
+
if (isUnusableFindingRecord(record)) {
|
|
178
|
+
throw makeUnusableOutputError("CodeRabbit output contained only bookkeeping records");
|
|
179
|
+
}
|
|
180
|
+
const rawFindings = record.findings ?? record.issues ?? record.results;
|
|
181
|
+
if (Array.isArray(rawFindings) && rawFindings.length > 0) {
|
|
182
|
+
const unusableCount = rawFindings.filter((item) => typeof item === "object" && item !== null && isUnusableFindingRecord(item)).length;
|
|
183
|
+
if (unusableCount > 0) {
|
|
184
|
+
throw makeUnusableOutputError(`CodeRabbit output contained only bookkeeping/progress records (${unusableCount} items)`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
139
188
|
return {
|
|
140
189
|
findings,
|
|
141
190
|
...(typeof record.prUrl === "string" ? { prUrl: record.prUrl } : {}),
|
|
@@ -150,16 +199,29 @@ function parseReport(output, format, parser) {
|
|
|
150
199
|
if (Array.isArray(parsed)) {
|
|
151
200
|
const findings = parsed.filter((item) => typeof item === "object" && item !== null && isActionableFinding(item));
|
|
152
201
|
if (findings.length === 0 && parsed.length > 0) {
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
155
|
-
throw makeUnusableOutputError(`CodeRabbit output contained only progress/status/heartbeat records (${
|
|
202
|
+
const unusableCount = parsed.filter((item) => typeof item === "object" && item !== null && isUnusableFindingRecord(item)).length;
|
|
203
|
+
if (unusableCount > 0) {
|
|
204
|
+
throw makeUnusableOutputError(`CodeRabbit output contained only progress/status/heartbeat and bookkeeping records (${unusableCount} items)`);
|
|
156
205
|
}
|
|
157
206
|
}
|
|
158
207
|
return { findings };
|
|
159
208
|
}
|
|
160
209
|
const findings = parseFindingsFromPayload(parsed);
|
|
161
|
-
if (findings.length === 0
|
|
162
|
-
|
|
210
|
+
if (findings.length === 0) {
|
|
211
|
+
const parsedRecord = parsed;
|
|
212
|
+
if (isProgressRecord(parsedRecord)) {
|
|
213
|
+
throw makeUnusableOutputError("CodeRabbit output was a progress/status/heartbeat record");
|
|
214
|
+
}
|
|
215
|
+
if (isUnusableFindingRecord(parsedRecord)) {
|
|
216
|
+
throw makeUnusableOutputError("CodeRabbit output contained only bookkeeping records");
|
|
217
|
+
}
|
|
218
|
+
const rawFindings = parsedRecord.findings ?? parsedRecord.issues ?? parsedRecord.results;
|
|
219
|
+
if (Array.isArray(rawFindings) && rawFindings.length > 0) {
|
|
220
|
+
const unusableCount = rawFindings.filter((item) => typeof item === "object" && item !== null && isUnusableFindingRecord(item)).length;
|
|
221
|
+
if (unusableCount > 0) {
|
|
222
|
+
throw makeUnusableOutputError(`CodeRabbit output contained only bookkeeping/progress records (${unusableCount} items)`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
163
225
|
}
|
|
164
226
|
return {
|
|
165
227
|
findings,
|
|
@@ -169,8 +231,14 @@ function parseReport(output, format, parser) {
|
|
|
169
231
|
};
|
|
170
232
|
}
|
|
171
233
|
catch (jsonError) {
|
|
172
|
-
//
|
|
173
|
-
//
|
|
234
|
+
// Semantic failures (e.g. unusable-output) surfaced during JSON object/array
|
|
235
|
+
// parsing must not be swallowed; only true JSON parse errors fall through to
|
|
236
|
+
// JSONL line scanning when the format is not explicitly JSON.
|
|
237
|
+
if (typeof jsonError === "object" &&
|
|
238
|
+
jsonError !== null &&
|
|
239
|
+
"qcFailureReason" in jsonError) {
|
|
240
|
+
throw jsonError;
|
|
241
|
+
}
|
|
174
242
|
if (format === "json") {
|
|
175
243
|
throw jsonError;
|
|
176
244
|
}
|
|
@@ -180,6 +248,7 @@ function parseReport(output, format, parser) {
|
|
|
180
248
|
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
181
249
|
const lineFindings = [];
|
|
182
250
|
let progressLineCount = 0;
|
|
251
|
+
let unusableLineCount = 0;
|
|
183
252
|
let parsedLineCount = 0;
|
|
184
253
|
for (const line of lines) {
|
|
185
254
|
try {
|
|
@@ -196,6 +265,9 @@ function parseReport(output, format, parser) {
|
|
|
196
265
|
if (isActionableFinding(record)) {
|
|
197
266
|
lineFindings.push(parsed);
|
|
198
267
|
}
|
|
268
|
+
else if (isUnusableFindingRecord(record)) {
|
|
269
|
+
unusableLineCount++;
|
|
270
|
+
}
|
|
199
271
|
}
|
|
200
272
|
catch {
|
|
201
273
|
// Ignore unparseable lines.
|
|
@@ -204,8 +276,8 @@ function parseReport(output, format, parser) {
|
|
|
204
276
|
if (lineFindings.length > 0) {
|
|
205
277
|
return { findings: lineFindings };
|
|
206
278
|
}
|
|
207
|
-
if (progressLineCount > 0) {
|
|
208
|
-
throw makeUnusableOutputError(`CodeRabbit output contained only progress/status/heartbeat records (${progressLineCount} lines)`);
|
|
279
|
+
if (progressLineCount > 0 || unusableLineCount > 0) {
|
|
280
|
+
throw makeUnusableOutputError(`CodeRabbit output contained only progress/status/heartbeat and bookkeeping records (${progressLineCount + unusableLineCount} lines)`);
|
|
209
281
|
}
|
|
210
282
|
if (parsedLineCount > 0) {
|
|
211
283
|
throw new Error("CodeRabbit output contained no actionable findings");
|
package/dist/qc/repair-loop.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Each repair packet becomes a sealed WorkerPacket with worker_role: "repair".
|
|
17
17
|
*/
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
-
exports.DEFAULT_MAX_REPAIR_ROUNDS = void 0;
|
|
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
22
|
exports.runQcRepairLoop = runQcRepairLoop;
|
|
@@ -28,11 +28,71 @@ const store_js_1 = require("../cluster-state/store.js");
|
|
|
28
28
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
29
29
|
/** Default maximum repair rounds when not configured. */
|
|
30
30
|
exports.DEFAULT_MAX_REPAIR_ROUNDS = 2;
|
|
31
|
+
/** Default timeout for a single repair worker dispatch (30 minutes). */
|
|
32
|
+
exports.DEFAULT_REPAIR_DISPATCH_TIMEOUT_MS = 1_800_000;
|
|
31
33
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
32
34
|
function appendTelemetry(telemetryFile, event) {
|
|
33
35
|
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(telemetryFile), { recursive: true });
|
|
34
36
|
(0, node_fs_1.appendFileSync)(telemetryFile, JSON.stringify(event) + "\n", "utf-8");
|
|
35
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Emit a pre-dispatch telemetry checkpoint and wrap the dispatch call in a
|
|
40
|
+
* configurable timeout. A timed-out dispatch resolves to a failure/timeout
|
|
41
|
+
* RepairWorkerResult instead of blocking the loop indefinitely.
|
|
42
|
+
* On timeout, aborts the dispatch via AbortSignal to terminate the worker process.
|
|
43
|
+
*/
|
|
44
|
+
async function dispatchRepairWorkerWithTimeout(dispatch, packet, round, manifest, timeoutMs, telemetryFile, runId, clusterId) {
|
|
45
|
+
appendTelemetry(telemetryFile, {
|
|
46
|
+
event: "qc-repair-worker-dispatch-start",
|
|
47
|
+
run_id: runId,
|
|
48
|
+
cluster_id: clusterId,
|
|
49
|
+
round,
|
|
50
|
+
packet_id: packet.packetId,
|
|
51
|
+
medic: packet.medic,
|
|
52
|
+
parallel_group: packet.parallelGroup,
|
|
53
|
+
timestamp: new Date().toISOString(),
|
|
54
|
+
});
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
let timer;
|
|
57
|
+
let timedOut = false;
|
|
58
|
+
try {
|
|
59
|
+
const result = await Promise.race([
|
|
60
|
+
dispatch(packet, round, manifest, controller.signal),
|
|
61
|
+
new Promise((_, reject) => {
|
|
62
|
+
timer = setTimeout(() => {
|
|
63
|
+
timedOut = true;
|
|
64
|
+
controller.abort(new Error(`Repair worker dispatch timed out after ${timeoutMs}ms`));
|
|
65
|
+
reject(new Error(`Repair worker dispatch timed out after ${timeoutMs}ms`));
|
|
66
|
+
}, timeoutMs);
|
|
67
|
+
}),
|
|
68
|
+
]);
|
|
69
|
+
if (timer)
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (timer)
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
77
|
+
if (timedOut) {
|
|
78
|
+
appendTelemetry(telemetryFile, {
|
|
79
|
+
event: "qc-repair-worker-dispatch-timeout",
|
|
80
|
+
run_id: runId,
|
|
81
|
+
cluster_id: clusterId,
|
|
82
|
+
round,
|
|
83
|
+
packet_id: packet.packetId,
|
|
84
|
+
timeout_ms: timeoutMs,
|
|
85
|
+
error: msg,
|
|
86
|
+
timestamp: new Date().toISOString(),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
packetId: packet.packetId,
|
|
91
|
+
status: "failure",
|
|
92
|
+
errorMessage: msg,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
36
96
|
async function persistQcRepairOutcome(clusterId, repoRoot, outcome) {
|
|
37
97
|
try {
|
|
38
98
|
const clusterState = (0, store_js_1.readClusterStateSync)(clusterId, repoRoot);
|
|
@@ -96,8 +156,8 @@ function rerunPassed(results) {
|
|
|
96
156
|
}
|
|
97
157
|
/** Partition packets into safe-to-parallel and must-serialize groups. */
|
|
98
158
|
function partitionRepairPackets(packets) {
|
|
99
|
-
// Medic
|
|
100
|
-
const serialized = packets.filter((p) => p.medic
|
|
159
|
+
// Medic packets are always serialized; operator-review packets are never dispatched.
|
|
160
|
+
const serialized = packets.filter((p) => p.medic);
|
|
101
161
|
const parallelizable = packets.filter((p) => !p.medic && p.routingTarget !== "operator-review");
|
|
102
162
|
// Group by parallelGroup assignment from the compiler.
|
|
103
163
|
const groups = new Map();
|
|
@@ -139,6 +199,7 @@ function initRepairLoopState(opts) {
|
|
|
139
199
|
*/
|
|
140
200
|
async function runQcRepairLoop(options) {
|
|
141
201
|
const { clusterId, runId, branch, repoRoot, telemetryFile, config, registry, initialQcResults, dispatchRepairWorker, validationCommands = [], timeoutMs, maxRounds = exports.DEFAULT_MAX_REPAIR_ROUNDS, priorLoopState, onStateUpdate, } = options;
|
|
202
|
+
const repairDispatchTimeoutMs = config.repairDispatchTimeoutMs ?? exports.DEFAULT_REPAIR_DISPATCH_TIMEOUT_MS;
|
|
142
203
|
if (!config.enabled) {
|
|
143
204
|
const state = initRepairLoopState({
|
|
144
205
|
maxRounds,
|
|
@@ -194,25 +255,6 @@ async function runQcRepairLoop(options) {
|
|
|
194
255
|
summary: `QC repair loop halted: all QC providers failed at round ${round}`,
|
|
195
256
|
};
|
|
196
257
|
}
|
|
197
|
-
if (requiresOperatorReview(currentResults)) {
|
|
198
|
-
loopState = { ...loopState, terminal_outcome: "operator-review", updated_at: new Date().toISOString() };
|
|
199
|
-
onStateUpdate?.(loopState);
|
|
200
|
-
appendTelemetry(telemetryFile, {
|
|
201
|
-
event: "qc-repair-loop-terminal",
|
|
202
|
-
run_id: runId,
|
|
203
|
-
outcome: "operator-review",
|
|
204
|
-
round,
|
|
205
|
-
timestamp: new Date().toISOString(),
|
|
206
|
-
});
|
|
207
|
-
await persistQcRepairOutcome(clusterId, repoRoot, "operator-review");
|
|
208
|
-
return {
|
|
209
|
-
outcome: "operator-review",
|
|
210
|
-
rounds_completed: roundsCompleted,
|
|
211
|
-
final_qc_results: currentResults,
|
|
212
|
-
loop_state: loopState,
|
|
213
|
-
summary: `QC repair loop halted: unresolved operator-review findings at round ${round}`,
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
258
|
// ── Compile / discover repair packets ──────────────────────────────────
|
|
217
259
|
let manifest = null;
|
|
218
260
|
// Try to read an existing manifest for this round first (idempotent re-entry).
|
|
@@ -261,10 +303,29 @@ async function runQcRepairLoop(options) {
|
|
|
261
303
|
await persistQcRepairManifest(clusterId, repoRoot, round, existingManifestPath);
|
|
262
304
|
}
|
|
263
305
|
// ── Check for repairable packets ────────────────────────────────────────
|
|
264
|
-
const
|
|
306
|
+
const dispatchablePackets = manifest.packets.filter((p) => p.routingTarget === "repair-worker" &&
|
|
265
307
|
p.status === "pending" &&
|
|
266
308
|
!loopState.completed_packet_ids.includes(p.packetId));
|
|
267
|
-
if (
|
|
309
|
+
if (dispatchablePackets.length === 0 && requiresOperatorReview(currentResults)) {
|
|
310
|
+
loopState = { ...loopState, terminal_outcome: "operator-review", updated_at: new Date().toISOString() };
|
|
311
|
+
onStateUpdate?.(loopState);
|
|
312
|
+
appendTelemetry(telemetryFile, {
|
|
313
|
+
event: "qc-repair-loop-terminal",
|
|
314
|
+
run_id: runId,
|
|
315
|
+
outcome: "operator-review",
|
|
316
|
+
round,
|
|
317
|
+
timestamp: new Date().toISOString(),
|
|
318
|
+
});
|
|
319
|
+
await persistQcRepairOutcome(clusterId, repoRoot, "operator-review");
|
|
320
|
+
return {
|
|
321
|
+
outcome: "operator-review",
|
|
322
|
+
rounds_completed: roundsCompleted,
|
|
323
|
+
final_qc_results: currentResults,
|
|
324
|
+
loop_state: loopState,
|
|
325
|
+
summary: `QC repair loop halted: unresolved operator-review findings at round ${round}`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (dispatchablePackets.length === 0 && !hasRepairableFindings(currentResults)) {
|
|
268
329
|
loopState = { ...loopState, terminal_outcome: "no-repairable", updated_at: new Date().toISOString() };
|
|
269
330
|
onStateUpdate?.(loopState);
|
|
270
331
|
appendTelemetry(telemetryFile, {
|
|
@@ -284,7 +345,7 @@ async function runQcRepairLoop(options) {
|
|
|
284
345
|
};
|
|
285
346
|
}
|
|
286
347
|
// ── Dispatch repair workers (parallel groups, then serialized) ──────────
|
|
287
|
-
const { parallelGroups, serialized } = partitionRepairPackets(
|
|
348
|
+
const { parallelGroups, serialized } = partitionRepairPackets(dispatchablePackets);
|
|
288
349
|
const allWorkerResults = [];
|
|
289
350
|
let hasMedicReferral = false;
|
|
290
351
|
// Dispatch parallel groups.
|
|
@@ -298,12 +359,12 @@ async function runQcRepairLoop(options) {
|
|
|
298
359
|
timestamp: new Date().toISOString(),
|
|
299
360
|
});
|
|
300
361
|
// Within a group, packets are non-conflicting and can run concurrently.
|
|
301
|
-
const groupResults = await Promise.all(group.map((pkt) => dispatchRepairWorker
|
|
362
|
+
const groupResults = await Promise.all(group.map((pkt) => dispatchRepairWorkerWithTimeout(dispatchRepairWorker, pkt, round, manifest, repairDispatchTimeoutMs, telemetryFile, runId, clusterId)));
|
|
302
363
|
allWorkerResults.push(...groupResults);
|
|
303
364
|
}
|
|
304
|
-
// Dispatch serialized (medic
|
|
365
|
+
// Dispatch serialized (medic) packets sequentially.
|
|
305
366
|
for (const pkt of serialized) {
|
|
306
|
-
const result = await dispatchRepairWorker
|
|
367
|
+
const result = await dispatchRepairWorkerWithTimeout(dispatchRepairWorker, pkt, round, manifest, repairDispatchTimeoutMs, telemetryFile, runId, clusterId);
|
|
307
368
|
allWorkerResults.push(result);
|
|
308
369
|
}
|
|
309
370
|
// Record completed packet IDs.
|
|
@@ -438,6 +499,25 @@ async function runQcRepairLoop(options) {
|
|
|
438
499
|
}
|
|
439
500
|
}
|
|
440
501
|
// ── Max rounds exhausted ──────────────────────────────────────────────────
|
|
502
|
+
if (requiresOperatorReview(currentResults)) {
|
|
503
|
+
loopState = { ...loopState, terminal_outcome: "operator-review", updated_at: new Date().toISOString() };
|
|
504
|
+
onStateUpdate?.(loopState);
|
|
505
|
+
appendTelemetry(telemetryFile, {
|
|
506
|
+
event: "qc-repair-loop-terminal",
|
|
507
|
+
run_id: runId,
|
|
508
|
+
outcome: "operator-review",
|
|
509
|
+
rounds_completed: roundsCompleted,
|
|
510
|
+
timestamp: new Date().toISOString(),
|
|
511
|
+
});
|
|
512
|
+
await persistQcRepairOutcome(clusterId, repoRoot, "operator-review");
|
|
513
|
+
return {
|
|
514
|
+
outcome: "operator-review",
|
|
515
|
+
rounds_completed: roundsCompleted,
|
|
516
|
+
final_qc_results: currentResults,
|
|
517
|
+
loop_state: loopState,
|
|
518
|
+
summary: `QC repair loop halted: unresolved operator-review findings after ${roundsCompleted} round(s)`,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
441
521
|
loopState = { ...loopState, terminal_outcome: "max-rounds", updated_at: new Date().toISOString() };
|
|
442
522
|
onStateUpdate?.(loopState);
|
|
443
523
|
appendTelemetry(telemetryFile, {
|
package/dist/qc/runner.js
CHANGED
|
@@ -76,6 +76,12 @@ function classifyTerminalFailure(error, output) {
|
|
|
76
76
|
if (execError?.killed && execError?.signal === "SIGTERM") {
|
|
77
77
|
return "timeout";
|
|
78
78
|
}
|
|
79
|
+
// Exit code 143 is the standard shell convention for a process killed by SIGTERM
|
|
80
|
+
// (128 + SIGTERM). Some provider CLI wrappers report this directly instead of
|
|
81
|
+
// allowing Node's execFile to surface the signal, so treat it as a timeout.
|
|
82
|
+
if (output.exitCode === 143) {
|
|
83
|
+
return "timeout";
|
|
84
|
+
}
|
|
79
85
|
if (execError?.code === "ENOENT") {
|
|
80
86
|
return "command-not-found";
|
|
81
87
|
}
|
|
@@ -174,8 +180,8 @@ async function runSingleProvider(provider, scope, options, fallbackSource) {
|
|
|
174
180
|
const startedAt = new Date().toISOString();
|
|
175
181
|
const command = provider.buildReviewCommand(scope);
|
|
176
182
|
const execFn = options.execFileImpl ?? node_child_process_1.execFile;
|
|
177
|
-
const timeoutMs = options.timeoutMs ?? 300_000; // 5 minutes default
|
|
178
183
|
const providerConfig = getProviderConfig(provider.name, options.config);
|
|
184
|
+
const timeoutMs = options.timeoutMs ?? providerConfig?.timeoutMs ?? 300_000; // 5 minutes default
|
|
179
185
|
emitProviderAttempted(options.telemetryFile, scope.runId, scope.clusterId, provider.name, fallbackSource);
|
|
180
186
|
if (!isModeSupported(provider, scope)) {
|
|
181
187
|
const output = {
|
|
@@ -13,6 +13,47 @@ const loader_js_1 = require("../config/loader.js");
|
|
|
13
13
|
const atlas_js_1 = require("../map/atlas.js");
|
|
14
14
|
const seed_instructions_js_1 = require("./seed-instructions.js");
|
|
15
15
|
const smartdoc_ignore_js_1 = require("./smartdoc-ignore.js");
|
|
16
|
+
const DEFAULT_PAIRWISE_DRIFT_THRESHOLD = 0.5;
|
|
17
|
+
function escapeRegExp(s) {
|
|
18
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Normalize route artifact text so shared boilerplate (headings, links, route
|
|
22
|
+
* names) does not dominate similarity scores.
|
|
23
|
+
*/
|
|
24
|
+
function normalizeRouteArtifact(content, routeName) {
|
|
25
|
+
let s = content.toLowerCase();
|
|
26
|
+
// Strip markdown headings
|
|
27
|
+
s = s.replace(/^#+\s+.*$/gm, " ");
|
|
28
|
+
// Remove link URLs but keep link text
|
|
29
|
+
s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
30
|
+
// Remove bare URLs
|
|
31
|
+
s = s.replace(/https?:\/\/\S+/g, " ");
|
|
32
|
+
// Remove route name tokens
|
|
33
|
+
if (routeName) {
|
|
34
|
+
for (const token of routeName.split(/[-_\s]+/).filter(Boolean)) {
|
|
35
|
+
s = s.replace(new RegExp(`\\b${escapeRegExp(token)}\\b`, "g"), " ");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Drop non-alphanumeric characters
|
|
39
|
+
s = s.replace(/[^\p{L}\p{N}\s]+/gu, " ");
|
|
40
|
+
// Collapse whitespace
|
|
41
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
42
|
+
return s;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Compute a normalized Jaccard similarity between two route artifacts.
|
|
46
|
+
* Returns a value between 0 and 1.
|
|
47
|
+
*/
|
|
48
|
+
function computeNormalizedSimilarity(a, b, routeName) {
|
|
49
|
+
const tokensA = new Set(normalizeRouteArtifact(a, routeName).split(" ").filter(Boolean));
|
|
50
|
+
const tokensB = new Set(normalizeRouteArtifact(b, routeName).split(" ").filter(Boolean));
|
|
51
|
+
if (tokensA.size === 0 && tokensB.size === 0)
|
|
52
|
+
return 0;
|
|
53
|
+
const intersection = new Set([...tokensA].filter((t) => tokensB.has(t)));
|
|
54
|
+
const union = new Set([...tokensA, ...tokensB]);
|
|
55
|
+
return union.size === 0 ? 0 : intersection.size / union.size;
|
|
56
|
+
}
|
|
16
57
|
/**
|
|
17
58
|
* Get the last git modification date of a file.
|
|
18
59
|
* Returns null if the file is untracked or git is unavailable.
|
|
@@ -123,7 +164,7 @@ function collectDirs(dir, root, eligibilityOpts = {}, result = { eligible: [], i
|
|
|
123
164
|
/**
|
|
124
165
|
* Validate a single directory's POLARIS.md.
|
|
125
166
|
*/
|
|
126
|
-
function validateDir(relDir, repoRoot, allRoutes) {
|
|
167
|
+
function validateDir(relDir, repoRoot, allRoutes, similarityThreshold = DEFAULT_PAIRWISE_DRIFT_THRESHOLD) {
|
|
127
168
|
const absDir = (0, node_path_1.resolve)(repoRoot, relDir);
|
|
128
169
|
const polarisFile = (0, node_path_1.join)(absDir, "POLARIS.md");
|
|
129
170
|
const polarisRel = (0, node_path_1.relative)(repoRoot, polarisFile).replace(/\\/g, "/");
|
|
@@ -248,6 +289,7 @@ function validateDir(relDir, repoRoot, allRoutes) {
|
|
|
248
289
|
else {
|
|
249
290
|
// Signal 6: SUMMARY.md doctrine-bleed scan
|
|
250
291
|
const summaryContent = (0, node_fs_1.readFileSync)(summaryFile, "utf-8");
|
|
292
|
+
const summaryRel = (0, node_path_1.relative)(repoRoot, summaryFile).replace(/\\/g, "/");
|
|
251
293
|
const modalVerbs = ["must", "never", "always"];
|
|
252
294
|
const lines = summaryContent.split("\n");
|
|
253
295
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -261,6 +303,21 @@ function validateDir(relDir, repoRoot, allRoutes) {
|
|
|
261
303
|
}
|
|
262
304
|
}
|
|
263
305
|
}
|
|
306
|
+
// Signal 7: pairwise POLARIS.md / SUMMARY.md drift
|
|
307
|
+
const routeName = relDir === "." ? undefined : (0, node_path_1.basename)(relDir);
|
|
308
|
+
const similarity = computeNormalizedSimilarity(content, summaryContent, routeName);
|
|
309
|
+
if (content === summaryContent) {
|
|
310
|
+
findings.push({
|
|
311
|
+
severity: "ERROR",
|
|
312
|
+
message: `Route ${relDir || "."}: POLARIS.md and SUMMARY.md are exact duplicates (${polarisRel}, ${summaryRel})`,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
else if (similarity >= similarityThreshold) {
|
|
316
|
+
findings.push({
|
|
317
|
+
severity: "WARN",
|
|
318
|
+
message: `Route ${relDir || "."}: POLARIS.md and SUMMARY.md normalized similarity ${similarity.toFixed(2)} exceeds threshold ${similarityThreshold} (${polarisRel}, ${summaryRel})`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
264
321
|
}
|
|
265
322
|
if (findings.length === 0) {
|
|
266
323
|
return { dir: relDir, polarisFile: polarisRel, status: "OK", findings: [] };
|
|
@@ -302,9 +359,10 @@ function validateInstructions(opts = {}) {
|
|
|
302
359
|
});
|
|
303
360
|
dirsToCheck = [".", ...dirs];
|
|
304
361
|
}
|
|
362
|
+
const threshold = opts.similarityThreshold ?? DEFAULT_PAIRWISE_DRIFT_THRESHOLD;
|
|
305
363
|
const results = [];
|
|
306
364
|
for (const relDir of dirsToCheck) {
|
|
307
|
-
const result = validateDir(relDir, repoRoot, allRoutes);
|
|
365
|
+
const result = validateDir(relDir, repoRoot, allRoutes, threshold);
|
|
308
366
|
// Upgrade MISSING → ERROR for required dirs
|
|
309
367
|
if (result.status === "MISSING" && requiredDirs.includes(relDir)) {
|
|
310
368
|
result.status = "ERROR";
|