@lsctech/polaris 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -12,6 +12,7 @@ const seed_instructions_js_1 = require("./seed-instructions.js");
|
|
|
12
12
|
const validate_instructions_js_1 = require("./validate-instructions.js");
|
|
13
13
|
const doctrine_js_1 = require("./doctrine.js");
|
|
14
14
|
const audit_js_1 = require("./audit.js");
|
|
15
|
+
const triage_js_1 = require("./triage.js");
|
|
15
16
|
/**
|
|
16
17
|
* Build and return the top-level "docs" Commander command group for Polaris docs lifecycle workflows.
|
|
17
18
|
*
|
|
@@ -151,6 +152,27 @@ function createDocsCommand(options = {}) {
|
|
|
151
152
|
process.exit(1);
|
|
152
153
|
}
|
|
153
154
|
});
|
|
155
|
+
docs
|
|
156
|
+
.command("triage")
|
|
157
|
+
.description("Detect contradictions, duplicates, and stale code references among candidate docs")
|
|
158
|
+
.option("-r, --repo-root <path>", "Repository root", process.cwd())
|
|
159
|
+
.option("--batch-size <n>", "Docs per LLM call", "10")
|
|
160
|
+
.option("--resume", "Resume from last checkpoint (auto-detected by default)")
|
|
161
|
+
.option("--dry-run", "Plan batches and print cost estimate without calling the LLM")
|
|
162
|
+
.action(async (options) => {
|
|
163
|
+
try {
|
|
164
|
+
await (0, triage_js_1.runTriage)({
|
|
165
|
+
repoRoot: options.repoRoot,
|
|
166
|
+
batchSize: parseInt(options.batchSize, 10) || 10,
|
|
167
|
+
resume: options.resume,
|
|
168
|
+
dryRun: options.dryRun,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
console.error(`polaris docs triage: ${err instanceof Error ? err.message : String(err)}`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
154
176
|
docs
|
|
155
177
|
.command("migrate")
|
|
156
178
|
.description("Find scattered markdown files, move them to smartdocs/raw/, and produce an ingest cluster list")
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.clusterCandidates = clusterCandidates;
|
|
4
|
+
exports.extractSymbols = extractSymbols;
|
|
5
|
+
exports.loadDocMeta = loadDocMeta;
|
|
6
|
+
exports.loadAllDocMeta = loadAllDocMeta;
|
|
7
|
+
exports.readTriageCheckpoint = readTriageCheckpoint;
|
|
8
|
+
exports.writeTriageCheckpoint = writeTriageCheckpoint;
|
|
9
|
+
exports.deleteTriageCheckpoint = deleteTriageCheckpoint;
|
|
10
|
+
exports.writeTriageQueue = writeTriageQueue;
|
|
11
|
+
exports.resolveTriageModel = resolveTriageModel;
|
|
12
|
+
exports.runBatchComparison = runBatchComparison;
|
|
13
|
+
exports.runGraphCheck = runGraphCheck;
|
|
14
|
+
exports.runTriage = runTriage;
|
|
15
|
+
const node_fs_1 = require("node:fs");
|
|
16
|
+
const node_path_1 = require("node:path");
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// clusterCandidates
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* Groups candidate docs into named clusters using metadata overlap with canonicals.
|
|
22
|
+
* Candidates with no signal match go into the "general" bucket.
|
|
23
|
+
*/
|
|
24
|
+
function clusterCandidates(candidates, canonicals) {
|
|
25
|
+
const clusters = {};
|
|
26
|
+
// Build cluster names from canonicals
|
|
27
|
+
for (const canonical of canonicals) {
|
|
28
|
+
const names = clusterNamesFor(canonical);
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
if (!clusters[name]) {
|
|
31
|
+
clusters[name] = { candidates: [], canonicals: [] };
|
|
32
|
+
}
|
|
33
|
+
if (!clusters[name].canonicals.includes(canonical)) {
|
|
34
|
+
clusters[name].canonicals.push(canonical);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Assign each candidate to its best cluster
|
|
39
|
+
for (const candidate of candidates) {
|
|
40
|
+
const candidateNames = clusterNamesFor(candidate);
|
|
41
|
+
let assigned = false;
|
|
42
|
+
for (const name of candidateNames) {
|
|
43
|
+
if (clusters[name]) {
|
|
44
|
+
clusters[name].candidates.push(candidate);
|
|
45
|
+
assigned = true;
|
|
46
|
+
break; // assign to first matching cluster only — multi-cluster candidates would inflate batch sizes
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!assigned) {
|
|
50
|
+
if (!clusters["general"]) {
|
|
51
|
+
clusters["general"] = { candidates: [], canonicals: [] };
|
|
52
|
+
}
|
|
53
|
+
clusters["general"].candidates.push(candidate);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Ensure general bucket exists
|
|
57
|
+
if (!clusters["general"]) {
|
|
58
|
+
clusters["general"] = { candidates: [], canonicals: [] };
|
|
59
|
+
}
|
|
60
|
+
return clusters;
|
|
61
|
+
}
|
|
62
|
+
function clusterNamesFor(doc) {
|
|
63
|
+
const names = [];
|
|
64
|
+
// Tags take priority
|
|
65
|
+
for (const tag of doc.tags) {
|
|
66
|
+
if (tag.trim())
|
|
67
|
+
names.push(tag.toLowerCase().trim());
|
|
68
|
+
}
|
|
69
|
+
// Cluster membership
|
|
70
|
+
for (const c of doc.clusterMembership) {
|
|
71
|
+
if (c.trim())
|
|
72
|
+
names.push(c.toLowerCase().trim());
|
|
73
|
+
}
|
|
74
|
+
// Filename prefix (e.g. "ADR", "EVOlearn")
|
|
75
|
+
for (const p of doc.filenamePrefixes) {
|
|
76
|
+
if (p.trim())
|
|
77
|
+
names.push(p.toLowerCase().trim());
|
|
78
|
+
}
|
|
79
|
+
// Type as fallback
|
|
80
|
+
if (doc.type.trim())
|
|
81
|
+
names.push(doc.type.toLowerCase().trim());
|
|
82
|
+
return names;
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// extractSymbols
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
const BACKTICK_RE = /`([A-Za-z_][A-Za-z0-9_]{3,})`/g;
|
|
88
|
+
const CAMEL_PASCAL_RE = /(?<![`\w])([A-Z][a-z]+(?:[A-Z][a-z]+)+|[a-z][a-z]+(?:[A-Z][a-z0-9]+)+)(?![`\w])/g;
|
|
89
|
+
/**
|
|
90
|
+
* Extracts likely code symbol names from markdown text.
|
|
91
|
+
* Returns deduplicated symbol names of 4+ characters.
|
|
92
|
+
*/
|
|
93
|
+
function extractSymbols(text) {
|
|
94
|
+
const found = new Set();
|
|
95
|
+
let m;
|
|
96
|
+
BACKTICK_RE.lastIndex = 0;
|
|
97
|
+
while ((m = BACKTICK_RE.exec(text)) !== null) {
|
|
98
|
+
found.add(m[1]);
|
|
99
|
+
}
|
|
100
|
+
CAMEL_PASCAL_RE.lastIndex = 0;
|
|
101
|
+
while ((m = CAMEL_PASCAL_RE.exec(text)) !== null) {
|
|
102
|
+
found.add(m[1]);
|
|
103
|
+
}
|
|
104
|
+
return Array.from(found);
|
|
105
|
+
}
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// DocMeta loader
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
/**
|
|
110
|
+
* Parses YAML frontmatter from a markdown file and returns DocMeta.
|
|
111
|
+
* Does not throw — returns empty arrays on any parse failure.
|
|
112
|
+
*/
|
|
113
|
+
function loadDocMeta(filePath) {
|
|
114
|
+
let content = "";
|
|
115
|
+
try {
|
|
116
|
+
content = (0, node_fs_1.readFileSync)(filePath, "utf-8");
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return emptyMeta(filePath);
|
|
120
|
+
}
|
|
121
|
+
const frontmatter = parseFrontmatter(content);
|
|
122
|
+
const filenamePrefixes = extractFilenamePrefixes((0, node_path_1.basename)(filePath, (0, node_path_1.extname)(filePath)));
|
|
123
|
+
return {
|
|
124
|
+
path: filePath,
|
|
125
|
+
tags: toStringArray(frontmatter["Tags"] ?? frontmatter["tags"]),
|
|
126
|
+
type: String(frontmatter["Type"] ?? frontmatter["type"] ?? "").trim(),
|
|
127
|
+
clusterMembership: toStringArray(frontmatter["Member Of Concept Cluster"]),
|
|
128
|
+
relatedNotes: toStringArray(frontmatter["Related Notes"]),
|
|
129
|
+
filenamePrefixes,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Reads all .md files from a directory and returns their DocMeta.
|
|
134
|
+
*/
|
|
135
|
+
function loadAllDocMeta(dir) {
|
|
136
|
+
let entries = [];
|
|
137
|
+
try {
|
|
138
|
+
entries = (0, node_fs_1.readdirSync)(dir).filter((f) => f.endsWith(".md"));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
return entries.map((f) => loadDocMeta((0, node_path_1.join)(dir, f)));
|
|
144
|
+
}
|
|
145
|
+
function emptyMeta(filePath) {
|
|
146
|
+
return {
|
|
147
|
+
path: filePath,
|
|
148
|
+
tags: [],
|
|
149
|
+
type: "",
|
|
150
|
+
clusterMembership: [],
|
|
151
|
+
relatedNotes: [],
|
|
152
|
+
filenamePrefixes: extractFilenamePrefixes((0, node_path_1.basename)(filePath, (0, node_path_1.extname)(filePath))),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function parseFrontmatter(content) {
|
|
156
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
157
|
+
if (!match)
|
|
158
|
+
return {};
|
|
159
|
+
const result = {};
|
|
160
|
+
const lines = match[1].split("\n");
|
|
161
|
+
let currentKey = null;
|
|
162
|
+
const listBuffer = [];
|
|
163
|
+
const flushList = () => {
|
|
164
|
+
if (currentKey && listBuffer.length > 0) {
|
|
165
|
+
result[currentKey] = [...listBuffer];
|
|
166
|
+
listBuffer.length = 0;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
// Inline array: Tags: [a, b]
|
|
171
|
+
const inlineMatch = line.match(/^([^:]+):\s*\[(.*)\]$/);
|
|
172
|
+
if (inlineMatch) {
|
|
173
|
+
flushList();
|
|
174
|
+
currentKey = null;
|
|
175
|
+
const key = inlineMatch[1].trim();
|
|
176
|
+
result[key] = inlineMatch[2]
|
|
177
|
+
.split(",")
|
|
178
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
// Key with value: Type: Decision
|
|
183
|
+
const kvMatch = line.match(/^([^:]+):\s*(.+)$/);
|
|
184
|
+
if (kvMatch && !line.startsWith(" ")) {
|
|
185
|
+
flushList();
|
|
186
|
+
currentKey = kvMatch[1].trim();
|
|
187
|
+
const val = kvMatch[2].trim();
|
|
188
|
+
if (val !== "") {
|
|
189
|
+
result[currentKey] = val.replace(/^["']|["']$/g, "");
|
|
190
|
+
currentKey = null;
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// Key with no inline value (list follows)
|
|
195
|
+
const keyOnlyMatch = line.match(/^([^:]+):\s*$/);
|
|
196
|
+
if (keyOnlyMatch && !line.startsWith(" ")) {
|
|
197
|
+
flushList();
|
|
198
|
+
currentKey = keyOnlyMatch[1].trim();
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
// List item — wikilink style: - "[[ADR-002|ADR-002]]"
|
|
202
|
+
const listItemMatch = line.match(/^\s+-\s+"?\[\[([^\]|]+)(?:\|[^\]]+)?\]\]"?$/);
|
|
203
|
+
if (listItemMatch && currentKey) {
|
|
204
|
+
listBuffer.push(listItemMatch[1].trim());
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// List item — plain
|
|
208
|
+
const simpleItemMatch = line.match(/^\s+-\s+(.+)$/);
|
|
209
|
+
if (simpleItemMatch && currentKey) {
|
|
210
|
+
listBuffer.push(simpleItemMatch[1].trim().replace(/^["']|["']$/g, ""));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
flushList();
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
function toStringArray(value) {
|
|
217
|
+
if (!value)
|
|
218
|
+
return [];
|
|
219
|
+
if (Array.isArray(value))
|
|
220
|
+
return value.map(String).filter(Boolean);
|
|
221
|
+
if (typeof value === "string" && value.trim())
|
|
222
|
+
return [value.trim()];
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
function extractFilenamePrefixes(stem) {
|
|
226
|
+
// "ADR-001 - Some Title" → ["ADR"]
|
|
227
|
+
// "EVOlearn_Governance" → ["EVOlearn"]
|
|
228
|
+
const parts = stem.split(/[-_ ]/);
|
|
229
|
+
return parts.slice(0, 2).filter((p) => p.length >= 3 && /[A-Za-z]/.test(p));
|
|
230
|
+
}
|
|
231
|
+
const CHECKPOINT_FILENAME = "_triage-checkpoint.json";
|
|
232
|
+
function readTriageCheckpoint(outputDir) {
|
|
233
|
+
const p = (0, node_path_1.join)(outputDir, CHECKPOINT_FILENAME);
|
|
234
|
+
if (!(0, node_fs_1.existsSync)(p))
|
|
235
|
+
return null;
|
|
236
|
+
try {
|
|
237
|
+
return JSON.parse((0, node_fs_1.readFileSync)(p, "utf-8"));
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function writeTriageCheckpoint(checkpoint, outputDir) {
|
|
244
|
+
(0, node_fs_1.mkdirSync)(outputDir, { recursive: true });
|
|
245
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(outputDir, CHECKPOINT_FILENAME), JSON.stringify(checkpoint, null, 2), "utf-8");
|
|
246
|
+
}
|
|
247
|
+
function deleteTriageCheckpoint(outputDir) {
|
|
248
|
+
const p = (0, node_path_1.join)(outputDir, CHECKPOINT_FILENAME);
|
|
249
|
+
if ((0, node_fs_1.existsSync)(p))
|
|
250
|
+
(0, node_fs_1.unlinkSync)(p);
|
|
251
|
+
}
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Triage queue writer
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
function writeTriageQueue(packets, outputDir) {
|
|
256
|
+
(0, node_fs_1.mkdirSync)(outputDir, { recursive: true });
|
|
257
|
+
const queueFile = {
|
|
258
|
+
generated_at: new Date().toISOString(),
|
|
259
|
+
run_id: `triage-${Date.now()}`,
|
|
260
|
+
packets,
|
|
261
|
+
};
|
|
262
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(outputDir, "_triage-queue.json"), JSON.stringify(queueFile, null, 2), "utf-8");
|
|
263
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(outputDir, "_triage-report.md"), renderTriageReport(packets), "utf-8");
|
|
264
|
+
}
|
|
265
|
+
function renderTriageReport(packets) {
|
|
266
|
+
const contradictions = packets.filter((p) => p.triageFlag === "contradiction");
|
|
267
|
+
const duplicates = packets.filter((p) => p.triageFlag === "duplicate");
|
|
268
|
+
const stale = packets.filter((p) => p.triageFlag === "stale-reference");
|
|
269
|
+
const lines = [
|
|
270
|
+
"# Polaris Docs Triage Report",
|
|
271
|
+
"",
|
|
272
|
+
`Generated: ${new Date().toISOString()}`,
|
|
273
|
+
"",
|
|
274
|
+
"## Summary",
|
|
275
|
+
"",
|
|
276
|
+
`| Flag | Count |`,
|
|
277
|
+
`|------|-------|`,
|
|
278
|
+
`| contradiction | ${contradictions.length} |`,
|
|
279
|
+
`| duplicate | ${duplicates.length} |`,
|
|
280
|
+
`| stale-reference | ${stale.length} |`,
|
|
281
|
+
`| **total** | **${packets.length}** |`,
|
|
282
|
+
"",
|
|
283
|
+
"## Flagged Documents",
|
|
284
|
+
"",
|
|
285
|
+
"This file is display-only. Edit `_triage-queue.json` to record decisions,",
|
|
286
|
+
"or run `polaris docs review` to walk through them interactively.",
|
|
287
|
+
"",
|
|
288
|
+
];
|
|
289
|
+
for (const p of packets) {
|
|
290
|
+
lines.push(`### ${p.triageFlag.toUpperCase()} · ${p.sourcePath}`);
|
|
291
|
+
lines.push("");
|
|
292
|
+
lines.push(`**Reason:** ${p.outcomeReason}`);
|
|
293
|
+
if (p.relatedCanonical) {
|
|
294
|
+
lines.push(`**Conflicts with:** ${p.relatedCanonical}`);
|
|
295
|
+
}
|
|
296
|
+
if (p.staleSymbols && p.staleSymbols.length > 0) {
|
|
297
|
+
lines.push(`**Stale symbols:** ${p.staleSymbols.join(", ")}`);
|
|
298
|
+
}
|
|
299
|
+
lines.push(`**Review decision:** ${p.reviewDecision ?? "← pending"}`);
|
|
300
|
+
lines.push("");
|
|
301
|
+
}
|
|
302
|
+
return lines.join("\n");
|
|
303
|
+
}
|
|
304
|
+
function resolveTriageModel(configured) {
|
|
305
|
+
return (process.env["POLARIS_TRIAGE_MODEL"] ??
|
|
306
|
+
configured ??
|
|
307
|
+
"claude-haiku-4-5-20251001");
|
|
308
|
+
}
|
|
309
|
+
async function runBatchComparison(candidates, canonicals, options) {
|
|
310
|
+
const client = options.llmClient ?? (await buildDefaultLlmClient());
|
|
311
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
312
|
+
try {
|
|
313
|
+
return await client.compare(candidates, canonicals, options.model);
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
process.stderr.write(`[triage] LLM batch failed (attempt ${attempt + 1})${attempt === 0 ? ", retrying" : ", skipping batch"}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return [];
|
|
320
|
+
}
|
|
321
|
+
async function buildDefaultLlmClient() {
|
|
322
|
+
const apiKey = process.env["ANTHROPIC_API_KEY"] ?? "";
|
|
323
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
324
|
+
// @ts-ignore: @anthropic-ai/sdk may not be installed as a dependency
|
|
325
|
+
const mod = await import("@anthropic-ai/sdk");
|
|
326
|
+
const Anthropic = mod.default ?? mod.Anthropic;
|
|
327
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
328
|
+
const sdkClient = new Anthropic({ apiKey });
|
|
329
|
+
return {
|
|
330
|
+
async compare(candidates, canonicals, model) {
|
|
331
|
+
const candidateSummaries = candidates
|
|
332
|
+
.map((c, i) => `[${i}] ${(0, node_path_1.basename)(c.path)} (${c.type || "unknown type"}) tags: ${c.tags.join(", ") || "none"}`)
|
|
333
|
+
.join("\n");
|
|
334
|
+
const canonicalSummaries = canonicals
|
|
335
|
+
.map((c) => `- ${(0, node_path_1.basename)(c.path)} (${c.type || "unknown type"}) tags: ${c.tags.join(", ") || "none"}`)
|
|
336
|
+
.join("\n");
|
|
337
|
+
const prompt = [
|
|
338
|
+
"You are comparing candidate documentation files against canonical documentation.",
|
|
339
|
+
"Identify any candidates that CONTRADICT or DUPLICATE a canonical.",
|
|
340
|
+
'Return ONLY a JSON array. If no issues found, return [].',
|
|
341
|
+
'Each item must be: { "candidatePath": string, "flagType": "contradiction" | "duplicate", "canonicalPath"?: string, "reason": string }',
|
|
342
|
+
"",
|
|
343
|
+
"CANDIDATES:",
|
|
344
|
+
candidateSummaries,
|
|
345
|
+
"",
|
|
346
|
+
"CANONICALS:",
|
|
347
|
+
canonicalSummaries,
|
|
348
|
+
"",
|
|
349
|
+
"Respond with JSON only. No prose.",
|
|
350
|
+
].join("\n");
|
|
351
|
+
const response = await sdkClient.messages.create({
|
|
352
|
+
model,
|
|
353
|
+
max_tokens: 1024,
|
|
354
|
+
messages: [{ role: "user", content: prompt }],
|
|
355
|
+
});
|
|
356
|
+
const text = response.content
|
|
357
|
+
.filter((b) => b.type === "text")
|
|
358
|
+
.map((b) => b.text)
|
|
359
|
+
.join("");
|
|
360
|
+
const cleaned = text.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "").trim();
|
|
361
|
+
return JSON.parse(cleaned);
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const STALE_SYMBOL_THRESHOLD = 2;
|
|
366
|
+
const GRAPH_SYMBOL_MINIMUM = 1000;
|
|
367
|
+
function runGraphCheck(candidates, options) {
|
|
368
|
+
const stats = options.graphStats();
|
|
369
|
+
if (stats.symbolCount < GRAPH_SYMBOL_MINIMUM) {
|
|
370
|
+
process.stderr.write(`[triage] Graph coverage too low for doc-vs-code check (${stats.symbolCount} symbols indexed).\n` +
|
|
371
|
+
` Run: polaris-cli graph build\n`);
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
const flags = [];
|
|
375
|
+
for (const candidate of candidates) {
|
|
376
|
+
let content = "";
|
|
377
|
+
try {
|
|
378
|
+
content = options.getContent(candidate.path);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const symbols = extractSymbols(content);
|
|
384
|
+
const missing = symbols.filter((s) => !options.symbolLookup(s));
|
|
385
|
+
if (missing.length >= STALE_SYMBOL_THRESHOLD) {
|
|
386
|
+
flags.push({
|
|
387
|
+
candidatePath: candidate.path,
|
|
388
|
+
flagType: "stale-reference",
|
|
389
|
+
staleSymbols: missing,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return flags;
|
|
394
|
+
}
|
|
395
|
+
async function runTriage(options) {
|
|
396
|
+
const { repoRoot, batchSize = 10, dryRun = false, output = (m) => process.stdout.write(m + "\n"), llmClient, symbolLookup, graphStats, } = options;
|
|
397
|
+
const activeDir = (0, node_path_1.join)(repoRoot, "smartdocs", "doctrine", "active");
|
|
398
|
+
const candidateDir = (0, node_path_1.join)(repoRoot, "smartdocs", "doctrine", "candidate");
|
|
399
|
+
const outputDir = (0, node_path_1.join)(repoRoot, "smartdocs", "raw");
|
|
400
|
+
output(`Loading canonical docs from ${activeDir}...`);
|
|
401
|
+
const canonicals = loadAllDocMeta(activeDir);
|
|
402
|
+
output(`Loading candidate docs from ${candidateDir}...`);
|
|
403
|
+
const candidates = loadAllDocMeta(candidateDir);
|
|
404
|
+
output(`Clustering ${candidates.length} candidates against ${canonicals.length} canonicals...`);
|
|
405
|
+
const clusterMap = clusterCandidates(candidates, canonicals);
|
|
406
|
+
const clusterNames = Object.keys(clusterMap).filter((k) => k !== "general");
|
|
407
|
+
if (clusterMap["general"])
|
|
408
|
+
clusterNames.push("general");
|
|
409
|
+
if (dryRun) {
|
|
410
|
+
const batchCount = clusterNames.reduce((sum, name) => {
|
|
411
|
+
return sum + Math.ceil((clusterMap[name].candidates.length || 0) / batchSize);
|
|
412
|
+
}, 0);
|
|
413
|
+
output(` ${clusterNames.length} clusters identified`);
|
|
414
|
+
output(` Estimated batches: ${batchCount}`);
|
|
415
|
+
output(` Estimated tokens: ~${batchCount * 3000} input / ~${batchCount * 200} output`);
|
|
416
|
+
output(` Model: ${resolveTriageModel(options.model)} (configured)`);
|
|
417
|
+
output(`\nRun without --dry-run to execute.`);
|
|
418
|
+
return { flagCount: 0, outputDir };
|
|
419
|
+
}
|
|
420
|
+
let checkpoint = readTriageCheckpoint(outputDir);
|
|
421
|
+
const completedClusters = new Set(checkpoint?.completedClusters ?? []);
|
|
422
|
+
const accumulatedFlags = [...(checkpoint?.flags ?? [])];
|
|
423
|
+
if (completedClusters.size > 0) {
|
|
424
|
+
output(`Resuming triage from checkpoint (${completedClusters.size}/${clusterNames.length} clusters complete)...`);
|
|
425
|
+
}
|
|
426
|
+
const model = resolveTriageModel(options.model);
|
|
427
|
+
// Phase 1: doc-vs-doc
|
|
428
|
+
for (const clusterName of clusterNames) {
|
|
429
|
+
if (completedClusters.has(clusterName))
|
|
430
|
+
continue;
|
|
431
|
+
const cluster = clusterMap[clusterName];
|
|
432
|
+
if (cluster.candidates.length === 0) {
|
|
433
|
+
completedClusters.add(clusterName);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
output(` Comparing cluster "${clusterName}" (${cluster.candidates.length} candidates, ${cluster.canonicals.length} canonicals)...`);
|
|
437
|
+
for (let i = 0; i < cluster.candidates.length; i += batchSize) {
|
|
438
|
+
const batch = cluster.candidates.slice(i, i + batchSize);
|
|
439
|
+
const flags = await runBatchComparison(batch, cluster.canonicals, { model, llmClient });
|
|
440
|
+
accumulatedFlags.push(...flags);
|
|
441
|
+
}
|
|
442
|
+
completedClusters.add(clusterName);
|
|
443
|
+
writeTriageCheckpoint({ completedClusters: Array.from(completedClusters), flags: accumulatedFlags }, outputDir);
|
|
444
|
+
}
|
|
445
|
+
// Phase 2: doc-vs-code
|
|
446
|
+
output(`Running doc-vs-code graph check...`);
|
|
447
|
+
const graphCheckOptions = {
|
|
448
|
+
getContent: (p) => (0, node_fs_1.readFileSync)(p, "utf-8"),
|
|
449
|
+
symbolLookup: symbolLookup ?? ((name) => {
|
|
450
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
451
|
+
const { lookupSymbol } = require("../graph/query/index.js");
|
|
452
|
+
return lookupSymbol(name) !== null;
|
|
453
|
+
}),
|
|
454
|
+
graphStats: graphStats ?? (() => {
|
|
455
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
456
|
+
const { getGraphStats } = require("../graph/query/index.js");
|
|
457
|
+
return getGraphStats();
|
|
458
|
+
}),
|
|
459
|
+
};
|
|
460
|
+
const graphFlags = runGraphCheck(candidates, graphCheckOptions);
|
|
461
|
+
const allPackets = [
|
|
462
|
+
...accumulatedFlags.map((f) => ({
|
|
463
|
+
sourcePath: f.candidatePath,
|
|
464
|
+
proposedDestination: f.candidatePath,
|
|
465
|
+
classificationConfidence: 0,
|
|
466
|
+
destinationCertainty: 0,
|
|
467
|
+
authorityRisk: "medium",
|
|
468
|
+
reasoning: [f.reason],
|
|
469
|
+
conflicts: f.canonicalPath ? [f.canonicalPath] : [],
|
|
470
|
+
recommendation: "defer",
|
|
471
|
+
outcomeReason: f.reason,
|
|
472
|
+
triageFlag: f.flagType,
|
|
473
|
+
relatedCanonical: f.canonicalPath,
|
|
474
|
+
})),
|
|
475
|
+
...graphFlags.map((f) => ({
|
|
476
|
+
sourcePath: f.candidatePath,
|
|
477
|
+
proposedDestination: f.candidatePath,
|
|
478
|
+
classificationConfidence: 0,
|
|
479
|
+
destinationCertainty: 0,
|
|
480
|
+
authorityRisk: "low",
|
|
481
|
+
reasoning: [`Stale symbol references: ${f.staleSymbols.join(", ")}`],
|
|
482
|
+
conflicts: [],
|
|
483
|
+
recommendation: "defer",
|
|
484
|
+
outcomeReason: `${f.staleSymbols.length} code symbols not found in graph: ${f.staleSymbols.join(", ")}`,
|
|
485
|
+
triageFlag: "stale-reference",
|
|
486
|
+
staleSymbols: f.staleSymbols,
|
|
487
|
+
})),
|
|
488
|
+
];
|
|
489
|
+
writeTriageQueue(allPackets, outputDir);
|
|
490
|
+
deleteTriageCheckpoint(outputDir);
|
|
491
|
+
output(`\nTriage complete: ${allPackets.length} flags written to ${outputDir}/_triage-queue.json`);
|
|
492
|
+
output(`Run \`polaris docs review\` to walk through decisions.`);
|
|
493
|
+
return { flagCount: allPackets.length, outputDir };
|
|
494
|
+
}
|