@lsctech/polaris 0.4.4 → 0.4.5
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-smartdocs.js +16 -0
- package/dist/cognition/librarian-packet.js +16 -7
- package/dist/loop/adapters/cli-subtask-bridge.js +1 -0
- package/dist/loop/adapters/terminal-cli.js +1 -0
- package/dist/loop/body-parser.js +40 -1
- package/dist/smartdocs-engine/canon-check.js +80 -0
- package/dist/smartdocs-engine/doctrine.js +231 -0
- package/dist/smartdocs-engine/index.js +45 -2
- package/dist/smartdocs-engine/seed-instructions.js +206 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scaffoldBundleRoot = scaffoldBundleRoot;
|
|
3
4
|
exports.migrateSmartDocs = migrateSmartDocs;
|
|
4
5
|
const node_child_process_1 = require("node:child_process");
|
|
5
6
|
const node_fs_1 = require("node:fs");
|
|
@@ -110,6 +111,20 @@ function normalizeStatus(step, status, completedAt) {
|
|
|
110
111
|
error: status === "completed" || status === "skipped" ? undefined : step.error,
|
|
111
112
|
};
|
|
112
113
|
}
|
|
114
|
+
const SMARTDOCS_BUNDLE_INDEX_CONTENT = `---\nokf_version: "0.1"\n---\n\n# SmartDocs — Polaris Cognition Bundle\n\n# Governance\n\n- [Docs authority model](specs/active/docs-authority-model.md)\n- [Doctrine](doctrine/active/)\n\n# Routes\n\n<!-- Routes placeholder: dynamic file-routes.json generation deferred. -->\n`;
|
|
115
|
+
const SMARTDOCS_BUNDLE_LOG_CONTENT = "# SmartDocs — Change Log\n";
|
|
116
|
+
function scaffoldBundleRoot(repoRoot) {
|
|
117
|
+
const smartdocsDir = (0, node_path_1.join)(repoRoot, "smartdocs");
|
|
118
|
+
(0, node_fs_1.mkdirSync)(smartdocsDir, { recursive: true });
|
|
119
|
+
const indexPath = (0, node_path_1.join)(smartdocsDir, "index.md");
|
|
120
|
+
if (!(0, node_fs_1.existsSync)(indexPath)) {
|
|
121
|
+
(0, node_fs_1.writeFileSync)(indexPath, SMARTDOCS_BUNDLE_INDEX_CONTENT, "utf-8");
|
|
122
|
+
}
|
|
123
|
+
const logPath = (0, node_path_1.join)(smartdocsDir, "log.md");
|
|
124
|
+
if (!(0, node_fs_1.existsSync)(logPath)) {
|
|
125
|
+
(0, node_fs_1.writeFileSync)(logPath, SMARTDOCS_BUNDLE_LOG_CONTENT, "utf-8");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
113
128
|
function migrateSmartDocs(plan, repoRoot = (0, node_path_1.resolve)(process.cwd())) {
|
|
114
129
|
const effectivePlan = loadPlan(repoRoot, plan);
|
|
115
130
|
const provenanceRecords = [];
|
|
@@ -194,6 +209,7 @@ function migrateSmartDocs(plan, repoRoot = (0, node_path_1.resolve)(process.cwd(
|
|
|
194
209
|
seenRecords.add(recordKey(record));
|
|
195
210
|
}
|
|
196
211
|
}
|
|
212
|
+
scaffoldBundleRoot(repoRoot);
|
|
197
213
|
savePlan(repoRoot, effectivePlan);
|
|
198
214
|
saveProvenance(repoRoot, provenanceRecords);
|
|
199
215
|
Object.assign(plan, effectivePlan);
|
|
@@ -90,6 +90,7 @@ function generateLibrarianPacket(options) {
|
|
|
90
90
|
// Prohibited: anything that is not documentation or cognition
|
|
91
91
|
const prohibitedWritePaths = [
|
|
92
92
|
stateFile,
|
|
93
|
+
node_path_1.default.join(repoRoot, ".polaris", "clusters", clusterId, "cluster-state.json"),
|
|
93
94
|
node_path_1.default.join(repoRoot, ".polaris", "clusters", clusterId, "state.json"),
|
|
94
95
|
node_path_1.default.join(repoRoot, ".taskchain_artifacts"),
|
|
95
96
|
node_path_1.default.join(repoRoot, ".polaris", "runs"),
|
|
@@ -143,16 +144,24 @@ function generateLibrarianPacket(options) {
|
|
|
143
144
|
}
|
|
144
145
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
145
146
|
function resolveStateFile(repoRoot, clusterId) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
// Check current convention: cluster-state.json
|
|
148
|
+
const clusterState = node_path_1.default.join(repoRoot, ".polaris", "clusters", clusterId, "cluster-state.json");
|
|
149
|
+
if (node_fs_1.default.existsSync(clusterState))
|
|
150
|
+
return clusterState;
|
|
151
|
+
// Check legacy convention: state.json
|
|
152
|
+
const legacyState = node_path_1.default.join(repoRoot, ".polaris", "clusters", clusterId, "state.json");
|
|
153
|
+
if (node_fs_1.default.existsSync(legacyState))
|
|
154
|
+
return legacyState;
|
|
155
|
+
// Check taskchain artifacts fallback
|
|
149
156
|
const taskchain = node_path_1.default.join(repoRoot, ".taskchain_artifacts", "polaris-run", "current-state.json");
|
|
150
157
|
if (node_fs_1.default.existsSync(taskchain))
|
|
151
158
|
return taskchain;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
// Check legacy runs fallback
|
|
160
|
+
const legacyRuns = node_path_1.default.join(repoRoot, ".polaris", "runs", "current-state.json");
|
|
161
|
+
if (node_fs_1.default.existsSync(legacyRuns))
|
|
162
|
+
return legacyRuns;
|
|
163
|
+
// Return current convention path as default
|
|
164
|
+
return clusterState;
|
|
156
165
|
}
|
|
157
166
|
function findResultFile(resultsDir, childId) {
|
|
158
167
|
try {
|
|
@@ -65,6 +65,7 @@ function writeSealedResultFromSummary(packet, parsedSummary) {
|
|
|
65
65
|
const validation = parsedSummary["validation"] ?? parsedSummary["validation_summary"];
|
|
66
66
|
const sealedResult = {
|
|
67
67
|
run_id: packet.run_id,
|
|
68
|
+
cluster_id: packet.cluster_id,
|
|
68
69
|
child_id: packet.active_child,
|
|
69
70
|
status: normalizeSealedStatus(parsedSummary["status"]),
|
|
70
71
|
};
|
|
@@ -320,6 +320,7 @@ class TerminalCliAdapter {
|
|
|
320
320
|
const sealedResult = {
|
|
321
321
|
...normalized,
|
|
322
322
|
run_id: packet.run_id,
|
|
323
|
+
cluster_id: packet.cluster_id,
|
|
323
324
|
child_id: String(normalized["child_id"] ?? packet.active_child),
|
|
324
325
|
status: effectiveStatus,
|
|
325
326
|
commit: typeof normalized["commit"] === "string"
|
package/dist/loop/body-parser.js
CHANGED
|
@@ -123,6 +123,10 @@ function parseSections(body) {
|
|
|
123
123
|
/**
|
|
124
124
|
* Extracts bullet list items from the given text and returns them as trimmed strings.
|
|
125
125
|
*
|
|
126
|
+
* This is a generic list parser used by all section types (Validation, Acceptance
|
|
127
|
+
* Criteria, Ordering, Non-goals, etc.). It does NOT strip parenthetical annotations
|
|
128
|
+
* — that logic is scope-specific and handled separately.
|
|
129
|
+
*
|
|
126
130
|
* @param text - Section content to scan for bullet list lines (lines starting with `- ` or `* `)
|
|
127
131
|
* @returns The extracted list item strings, trimmed of whitespace; empty items are omitted.
|
|
128
132
|
*/
|
|
@@ -133,9 +137,25 @@ function parseListItems(text) {
|
|
|
133
137
|
.map((line) => line.replace(/^\s*[-*]\s+/, '').trim())
|
|
134
138
|
.filter((s) => s.length > 0);
|
|
135
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Removes trailing parenthetical annotations from a string.
|
|
142
|
+
*
|
|
143
|
+
* Used exclusively for Scope section items to strip annotations like "(new)" or
|
|
144
|
+
* "(thread flag through...)" so that allowed_scope entries in worker packets
|
|
145
|
+
* contain only bare file paths or valid glob patterns.
|
|
146
|
+
*
|
|
147
|
+
* @param s - The string to process
|
|
148
|
+
* @returns The input string with trailing `(...)` removed, re-trimmed
|
|
149
|
+
*/
|
|
150
|
+
function stripTrailingParenthetical(s) {
|
|
151
|
+
return s.replace(/\s*\([^)]*\)\s*$/, '').trim();
|
|
152
|
+
}
|
|
136
153
|
/**
|
|
137
154
|
* Extracts list items from the first section whose header matches a provided set.
|
|
138
155
|
*
|
|
156
|
+
* This is a generic section parser that does NOT apply any parenthetical stripping.
|
|
157
|
+
* For Scope sections, use `findScopeSection` instead.
|
|
158
|
+
*
|
|
139
159
|
* @param sections - Map of normalized header names to their section content; iteration follows the map's order
|
|
140
160
|
* @param headers - Set of normalized header names to match against section headers
|
|
141
161
|
* @returns An array of parsed list-item strings from the first matching section, or an empty array if no match is found
|
|
@@ -148,6 +168,25 @@ function findSection(sections, headers) {
|
|
|
148
168
|
}
|
|
149
169
|
return [];
|
|
150
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Extracts list items from the Scope section, stripping trailing parenthetical annotations.
|
|
173
|
+
*
|
|
174
|
+
* Scope section items often include human-readable annotations like "(new)" or
|
|
175
|
+
* "(thread flag through...)". These are stripped so that allowed_scope entries in
|
|
176
|
+
* worker packets contain only bare file paths or valid glob patterns.
|
|
177
|
+
*
|
|
178
|
+
* @param sections - Map of normalized header names to their section content
|
|
179
|
+
* @param headers - Set of normalized Scope header variants (e.g., SCOPE_HEADERS)
|
|
180
|
+
* @returns An array of scope paths with parentheticals removed, or an empty array if no Scope section is found
|
|
181
|
+
*/
|
|
182
|
+
function findScopeSection(sections, headers) {
|
|
183
|
+
for (const [header, content] of sections) {
|
|
184
|
+
if (headers.has(header)) {
|
|
185
|
+
return parseListItems(content).map(stripTrailingParenthetical).filter((s) => s.length > 0);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
151
190
|
/**
|
|
152
191
|
* Returns trimmed prose text from the first section matching a header set.
|
|
153
192
|
* Used for single-paragraph sections (Objective, Context, Goal).
|
|
@@ -189,7 +228,7 @@ function parseIssueBody(body) {
|
|
|
189
228
|
};
|
|
190
229
|
}
|
|
191
230
|
const sections = parseSections(body);
|
|
192
|
-
const rawScope =
|
|
231
|
+
const rawScope = findScopeSection(sections, SCOPE_HEADERS);
|
|
193
232
|
const scopeBlocked = rawScope.length > 0 && rawScope.every((item) => TBD_BLOCKED_RE.test(item));
|
|
194
233
|
const filteredScope = rawScope.filter((item) => !TBD_BLOCKED_RE.test(item));
|
|
195
234
|
return {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.runCanonCheck = runCanonCheck;
|
|
4
|
+
exports.runSmartDocsLinkCheck = runSmartDocsLinkCheck;
|
|
4
5
|
const node_fs_1 = require("node:fs");
|
|
5
6
|
const node_path_1 = require("node:path");
|
|
7
|
+
const doctrine_js_1 = require("./doctrine.js");
|
|
6
8
|
// Modal verbs that indicate behavioral assertions in doctrine/spec files
|
|
7
9
|
const MODAL_VERBS = /\b(must|never|always|should|required|requires|shall)\b/i;
|
|
8
10
|
// Domains matched by filename keywords
|
|
@@ -338,3 +340,81 @@ function writeCandidateDraftDocs(conflicts, repoRoot, childId) {
|
|
|
338
340
|
// Non-fatal
|
|
339
341
|
}
|
|
340
342
|
}
|
|
343
|
+
/** Subdirectories of smartdocs/ that are subject to strict link checking */
|
|
344
|
+
const STRICT_SMARTDOCS_DIRS = [
|
|
345
|
+
(0, node_path_1.join)("smartdocs", "doctrine", "candidate"),
|
|
346
|
+
(0, node_path_1.join)("smartdocs", "doctrine", "active"),
|
|
347
|
+
(0, node_path_1.join)("smartdocs", "specs", "candidate"),
|
|
348
|
+
(0, node_path_1.join)("smartdocs", "specs", "active"),
|
|
349
|
+
];
|
|
350
|
+
/**
|
|
351
|
+
* Recursively walk a directory and return all .md file paths.
|
|
352
|
+
*
|
|
353
|
+
* @param dir - Directory to walk
|
|
354
|
+
* @returns Array of absolute paths to all .md files found recursively
|
|
355
|
+
*/
|
|
356
|
+
function walkMarkdownFiles(dir) {
|
|
357
|
+
const results = [];
|
|
358
|
+
let entries;
|
|
359
|
+
try {
|
|
360
|
+
entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return results;
|
|
364
|
+
}
|
|
365
|
+
for (const entry of entries) {
|
|
366
|
+
const fullPath = (0, node_path_1.join)(dir, entry.name);
|
|
367
|
+
if (entry.isDirectory()) {
|
|
368
|
+
results.push(...walkMarkdownFiles(fullPath));
|
|
369
|
+
}
|
|
370
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
371
|
+
results.push(fullPath);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return results;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Walk the strict-tier smartdocs directories (candidate/ and active/ under doctrine/ and specs/)
|
|
378
|
+
* and check every .md file for broken cross-links into smartdocs/.
|
|
379
|
+
*
|
|
380
|
+
* Recursively traverses subdirectories to find nested documents.
|
|
381
|
+
* Files in raw/ are never checked (OKF §5.3 permissive default).
|
|
382
|
+
* Broken links are reported as SpecConflict entries with type "stale-assumption".
|
|
383
|
+
*
|
|
384
|
+
* @param options - Options including repoRoot, runId, and telemetryFile
|
|
385
|
+
* @returns Object with all detected conflicts and the count of files checked
|
|
386
|
+
*/
|
|
387
|
+
function runSmartDocsLinkCheck(options) {
|
|
388
|
+
const { repoRoot, runId, telemetryFile, childId } = options;
|
|
389
|
+
const root = (0, node_path_1.resolve)(repoRoot);
|
|
390
|
+
const allConflicts = [];
|
|
391
|
+
let filesChecked = 0;
|
|
392
|
+
for (const subdir of STRICT_SMARTDOCS_DIRS) {
|
|
393
|
+
const dir = (0, node_path_1.join)(root, subdir);
|
|
394
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
395
|
+
continue;
|
|
396
|
+
const markdownFiles = walkMarkdownFiles(dir);
|
|
397
|
+
for (const filePath of markdownFiles) {
|
|
398
|
+
let content;
|
|
399
|
+
try {
|
|
400
|
+
content = (0, node_fs_1.readFileSync)(filePath, "utf-8");
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
filesChecked++;
|
|
406
|
+
const conflicts = (0, doctrine_js_1.checkSmartDocsLinks)(filePath, content, root);
|
|
407
|
+
allConflicts.push(...conflicts);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
appendTelemetry(telemetryFile, {
|
|
411
|
+
event: "smartdocs-link-check-result",
|
|
412
|
+
run_id: runId,
|
|
413
|
+
child_id: childId ?? null,
|
|
414
|
+
files_checked: filesChecked,
|
|
415
|
+
broken_links: allConflicts.length,
|
|
416
|
+
conflicts: allConflicts,
|
|
417
|
+
timestamp: new Date().toISOString(),
|
|
418
|
+
});
|
|
419
|
+
return { conflicts: allConflicts, filesChecked };
|
|
420
|
+
}
|
|
@@ -7,6 +7,8 @@ exports.stampIngestFrontMatter = stampIngestFrontMatter;
|
|
|
7
7
|
exports.doctrineDraft = doctrineDraft;
|
|
8
8
|
exports.doctrinePromote = doctrinePromote;
|
|
9
9
|
exports.doctrineDeprecate = doctrineDeprecate;
|
|
10
|
+
exports.detectDoctrineSupersession = detectDoctrineSupersession;
|
|
11
|
+
exports.checkSmartDocsLinks = checkSmartDocsLinks;
|
|
10
12
|
exports.specPromote = specPromote;
|
|
11
13
|
exports.migrateProvenance = migrateProvenance;
|
|
12
14
|
const node_fs_1 = require("node:fs");
|
|
@@ -186,6 +188,59 @@ function appendLifecycle(lifecyclePath, event) {
|
|
|
186
188
|
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(lifecyclePath), { recursive: true });
|
|
187
189
|
(0, node_fs_1.appendFileSync)(lifecyclePath, JSON.stringify(event) + "\n", "utf-8");
|
|
188
190
|
}
|
|
191
|
+
const DIRECTORY_LOG_HEADER = "# Directory Update Log";
|
|
192
|
+
/**
|
|
193
|
+
* Append a dated prose entry to a per-directory log.md file, following the OKF convention:
|
|
194
|
+
* date-grouped YYYY-MM-DD headings, newest-first, with a bold verb prefix.
|
|
195
|
+
*
|
|
196
|
+
* Creates the file with the canonical header if it does not yet exist. If the
|
|
197
|
+
* newest heading already matches today's date, the entry is inserted under that
|
|
198
|
+
* heading; otherwise a new today's heading is prepended before the existing entries.
|
|
199
|
+
*
|
|
200
|
+
* This function is best-effort: failures during read/write are caught and logged
|
|
201
|
+
* as warnings but do not propagate to the caller, ensuring log.md update failures
|
|
202
|
+
* do not block directory transitions.
|
|
203
|
+
*
|
|
204
|
+
* @param directory - Directory that contains (or will contain) log.md
|
|
205
|
+
* @param verb - Lifecycle verb rendered as the bold prefix (Draft, Promote, Deprecate)
|
|
206
|
+
* @param reason - Human-readable prose entry for this change
|
|
207
|
+
*/
|
|
208
|
+
function logDirectoryChange(directory, verb, reason) {
|
|
209
|
+
try {
|
|
210
|
+
const logPath = (0, node_path_1.join)(directory, "log.md");
|
|
211
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
212
|
+
const entry = `**${verb}**: ${reason}`;
|
|
213
|
+
let content = (0, node_fs_1.existsSync)(logPath) ? (0, node_fs_1.readFileSync)(logPath, "utf-8") : "";
|
|
214
|
+
content = content.replace(/\r\n/g, "\n");
|
|
215
|
+
const todayHeading = `## ${today}`;
|
|
216
|
+
// Use anchored regex to match heading at start of line, not substring in prose.
|
|
217
|
+
const firstHeadingMatch = content.match(/^## (\d{4}-\d{2}-\d{2})/m);
|
|
218
|
+
if (firstHeadingMatch && firstHeadingMatch[1] === today) {
|
|
219
|
+
// Find the actual heading line position using anchored search.
|
|
220
|
+
const headingMatch = content.match(new RegExp(`^${todayHeading}`, 'm'));
|
|
221
|
+
if (headingMatch && headingMatch.index !== undefined) {
|
|
222
|
+
const headingIndex = headingMatch.index;
|
|
223
|
+
const afterHeading = headingIndex + todayHeading.length;
|
|
224
|
+
const newContent = `${content.slice(0, afterHeading)}\n${entry}${content.slice(afterHeading)}`;
|
|
225
|
+
(0, node_fs_1.writeFileSync)(logPath, newContent, "utf-8");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else if (content.trim().startsWith(DIRECTORY_LOG_HEADER)) {
|
|
229
|
+
const afterHeader = content.indexOf(DIRECTORY_LOG_HEADER) + DIRECTORY_LOG_HEADER.length;
|
|
230
|
+
const tail = content.slice(afterHeader).replace(/^\n*/, "\n\n");
|
|
231
|
+
const newContent = `${DIRECTORY_LOG_HEADER}\n\n${todayHeading}\n${entry}${tail}`;
|
|
232
|
+
(0, node_fs_1.writeFileSync)(logPath, newContent, "utf-8");
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const existing = content.trim() ? `${content}\n\n` : "";
|
|
236
|
+
(0, node_fs_1.writeFileSync)(logPath, `${DIRECTORY_LOG_HEADER}\n\n${todayHeading}\n${entry}\n${existing}`, "utf-8");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
// Best-effort: log warning but do not propagate failure.
|
|
241
|
+
console.warn(`[warn] Failed to update log.md in ${directory}: ${err instanceof Error ? err.message : String(err)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
189
244
|
/**
|
|
190
245
|
* Resolve a filesystem path against a repository root and return an absolute path.
|
|
191
246
|
*
|
|
@@ -236,6 +291,8 @@ function doctrineDraft(path, options) {
|
|
|
236
291
|
destination,
|
|
237
292
|
timestamp: new Date().toISOString(),
|
|
238
293
|
});
|
|
294
|
+
const reason = options.reason ?? `${(0, node_path_1.basename)(destination)} drafted to doctrine/candidate/`;
|
|
295
|
+
logDirectoryChange((0, node_path_1.dirname)(destination), "Draft", reason);
|
|
239
296
|
return { source, destination, runId, lifecyclePath };
|
|
240
297
|
}
|
|
241
298
|
/**
|
|
@@ -322,6 +379,8 @@ function doctrinePromote(path, options) {
|
|
|
322
379
|
destination,
|
|
323
380
|
timestamp: new Date().toISOString(),
|
|
324
381
|
});
|
|
382
|
+
const reason = options.reason ?? `${(0, node_path_1.basename)(destination)} promoted to doctrine/active/`;
|
|
383
|
+
logDirectoryChange((0, node_path_1.dirname)(destination), "Promote", reason);
|
|
325
384
|
return { source, destination, runId, lifecyclePath };
|
|
326
385
|
}
|
|
327
386
|
/**
|
|
@@ -371,6 +430,8 @@ function doctrineDeprecate(path, options) {
|
|
|
371
430
|
deprecated_at: deprecatedAt,
|
|
372
431
|
timestamp: deprecatedAt,
|
|
373
432
|
});
|
|
433
|
+
const reason = options.reason ?? `${(0, node_path_1.basename)(destination)} deprecated to doctrine/deprecated/`;
|
|
434
|
+
logDirectoryChange((0, node_path_1.dirname)(destination), "Deprecate", reason);
|
|
374
435
|
return { source, destination, runId, lifecyclePath };
|
|
375
436
|
}
|
|
376
437
|
// ── Spec verb-keyword conflict detection ──────────────────────────────────────
|
|
@@ -390,6 +451,174 @@ function extractSpecKeywords(content, pattern) {
|
|
|
390
451
|
}
|
|
391
452
|
return result;
|
|
392
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* Compute the Jaccard similarity between two keyword sets.
|
|
456
|
+
* Returns a value in [0, 1]; 0 means no overlap, 1 means identical.
|
|
457
|
+
*/
|
|
458
|
+
function jaccardSimilarity(a, b) {
|
|
459
|
+
if (a.size === 0 && b.size === 0)
|
|
460
|
+
return 0;
|
|
461
|
+
let intersection = 0;
|
|
462
|
+
for (const kw of a) {
|
|
463
|
+
if (b.has(kw))
|
|
464
|
+
intersection++;
|
|
465
|
+
}
|
|
466
|
+
return intersection / (a.size + b.size - intersection);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Extract a combined keyword set from a document using both REQUIRES and PROHIBITS patterns.
|
|
470
|
+
* Reuses the existing extractSpecKeywords function — no second algorithm.
|
|
471
|
+
*/
|
|
472
|
+
function extractAllKeywords(content) {
|
|
473
|
+
const requires = extractSpecKeywords(content, MODAL_REQUIRES);
|
|
474
|
+
const prohibits = extractSpecKeywords(content, MODAL_PROHIBITS);
|
|
475
|
+
return new Set([...requires, ...prohibits]);
|
|
476
|
+
}
|
|
477
|
+
// Jaccard threshold above which an active doc is considered a supersession candidate.
|
|
478
|
+
// 0.3 is deliberately permissive: better to surface an advisory than to miss it.
|
|
479
|
+
const SUPERSESSION_THRESHOLD = 0.3;
|
|
480
|
+
/**
|
|
481
|
+
* Detect whether an incoming doctrine-candidate document has high content overlap with
|
|
482
|
+
* any existing active doctrine document. When overlap exceeds the threshold, the active
|
|
483
|
+
* doc is returned as a suggested-supersession advisory conflict.
|
|
484
|
+
*
|
|
485
|
+
* This is report-only: it never writes to `supersedes`/`superseded_by` frontmatter.
|
|
486
|
+
* The caller (or the user via CLI) decides whether to act on the suggestion.
|
|
487
|
+
*
|
|
488
|
+
* @param candidatePath - Absolute or repo-relative path to the candidate document
|
|
489
|
+
* @param options - Doctrine options (must include `repoRoot`)
|
|
490
|
+
* @returns Array of SpecConflict entries with type "suggested-supersession"; empty when
|
|
491
|
+
* no active docs exist or no overlap exceeds the threshold
|
|
492
|
+
*/
|
|
493
|
+
function detectDoctrineSupersession(candidatePath, options) {
|
|
494
|
+
const repoRoot = (0, node_path_1.resolve)(options.repoRoot);
|
|
495
|
+
const source = resolvePath(candidatePath, repoRoot);
|
|
496
|
+
if (!(0, node_fs_1.existsSync)(source))
|
|
497
|
+
return [];
|
|
498
|
+
let candidateContent;
|
|
499
|
+
try {
|
|
500
|
+
candidateContent = (0, node_fs_1.readFileSync)(source, "utf-8");
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
const candidateKeywords = extractAllKeywords(candidateContent);
|
|
506
|
+
// No keywords in candidate → no meaningful overlap can be computed
|
|
507
|
+
if (candidateKeywords.size === 0)
|
|
508
|
+
return [];
|
|
509
|
+
const activeDir = (0, node_path_1.resolve)(repoRoot, "smartdocs", "doctrine", "active");
|
|
510
|
+
if (!(0, node_fs_1.existsSync)(activeDir))
|
|
511
|
+
return [];
|
|
512
|
+
const conflicts = [];
|
|
513
|
+
let activeFiles;
|
|
514
|
+
try {
|
|
515
|
+
activeFiles = (0, node_fs_1.readdirSync)(activeDir).filter((f) => f.endsWith(".md"));
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
return [];
|
|
519
|
+
}
|
|
520
|
+
for (const file of activeFiles) {
|
|
521
|
+
let activeContent;
|
|
522
|
+
try {
|
|
523
|
+
activeContent = (0, node_fs_1.readFileSync)((0, node_path_1.join)(activeDir, file), "utf-8");
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const activeKeywords = extractAllKeywords(activeContent);
|
|
529
|
+
if (activeKeywords.size === 0)
|
|
530
|
+
continue;
|
|
531
|
+
const score = jaccardSimilarity(candidateKeywords, activeKeywords);
|
|
532
|
+
if (score >= SUPERSESSION_THRESHOLD) {
|
|
533
|
+
conflicts.push({
|
|
534
|
+
type: "suggested-supersession",
|
|
535
|
+
conflictingFile: file,
|
|
536
|
+
detail: `candidate has ${Math.round(score * 100)}% keyword overlap with active doc "${file}" — consider adding supersedes: ${file.replace(/\.md$/, "")} to frontmatter`,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return conflicts;
|
|
541
|
+
}
|
|
542
|
+
// Regex to extract markdown links: [text](href)
|
|
543
|
+
const MD_LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g;
|
|
544
|
+
// Tiers that are subject to strict link checking (raw/ is permissive per OKF §5.3)
|
|
545
|
+
const STRICT_LINK_TIERS = ["candidate", "active"];
|
|
546
|
+
/**
|
|
547
|
+
* Determine whether a file path is in a strict-check tier (candidate/ or active/)
|
|
548
|
+
* under smartdocs/. Returns false for raw/ or anything outside smartdocs/.
|
|
549
|
+
*/
|
|
550
|
+
function isStrictTier(filePath, repoRoot) {
|
|
551
|
+
const rel = (0, node_path_1.relative)((0, node_path_1.resolve)(repoRoot), (0, node_path_1.resolve)(filePath)).replace(/\\/g, "/");
|
|
552
|
+
if (!rel.startsWith("smartdocs/"))
|
|
553
|
+
return false;
|
|
554
|
+
return STRICT_LINK_TIERS.some((tier) => rel.includes(`/${tier}/`) || rel.includes(`/${tier}\\`));
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Resolve a markdown link href found in `sourceFile` to an absolute filesystem path.
|
|
558
|
+
*
|
|
559
|
+
* Supports two forms:
|
|
560
|
+
* - Bundle-relative: `/smartdocs/...` — resolved from repoRoot
|
|
561
|
+
* - Relative: `./foo.md`, `../foo.md`, `foo.md` — resolved from the source file's directory
|
|
562
|
+
*
|
|
563
|
+
* Returns null if the href does not point into smartdocs/ (e.g. external URLs, src/ links).
|
|
564
|
+
*/
|
|
565
|
+
function resolveSmartDocsLink(href, sourceFile, repoRoot) {
|
|
566
|
+
// Strip anchors
|
|
567
|
+
const bare = href.split("#")[0];
|
|
568
|
+
if (!bare.endsWith(".md"))
|
|
569
|
+
return null;
|
|
570
|
+
let abs;
|
|
571
|
+
if (bare.startsWith("/")) {
|
|
572
|
+
// Bundle-relative: treat / as repoRoot
|
|
573
|
+
abs = (0, node_path_1.join)((0, node_path_1.resolve)(repoRoot), bare.slice(1));
|
|
574
|
+
}
|
|
575
|
+
else if (bare.startsWith("http://") || bare.startsWith("https://")) {
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
// Relative to source file's directory
|
|
580
|
+
abs = (0, node_path_1.join)((0, node_path_1.dirname)((0, node_path_1.resolve)(sourceFile)), bare);
|
|
581
|
+
}
|
|
582
|
+
// Only check links that land inside smartdocs/
|
|
583
|
+
const rel = (0, node_path_1.relative)((0, node_path_1.resolve)(repoRoot), abs).replace(/\\/g, "/");
|
|
584
|
+
if (!rel.startsWith("smartdocs/"))
|
|
585
|
+
return null;
|
|
586
|
+
return abs;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Check all markdown cross-links in a strict-tier (candidate/ or active/) SmartDoc.
|
|
590
|
+
*
|
|
591
|
+
* For each link whose href resolves into smartdocs/**, verifies the target exists.
|
|
592
|
+
* Missing targets are returned as SpecConflict entries with type "stale-assumption".
|
|
593
|
+
* Files from raw/ are never checked — pass isRaw=true or detect via filePath.
|
|
594
|
+
*
|
|
595
|
+
* @param filePath - Absolute path to the source document
|
|
596
|
+
* @param content - File content (already read by caller)
|
|
597
|
+
* @param repoRoot - Repository root directory
|
|
598
|
+
* @returns Array of stale-assumption conflicts for every broken smartdocs/ link
|
|
599
|
+
*/
|
|
600
|
+
function checkSmartDocsLinks(filePath, content, repoRoot) {
|
|
601
|
+
if (!isStrictTier(filePath, repoRoot))
|
|
602
|
+
return [];
|
|
603
|
+
const conflicts = [];
|
|
604
|
+
const re = new RegExp(MD_LINK_RE.source, MD_LINK_RE.flags);
|
|
605
|
+
for (const match of content.matchAll(re)) {
|
|
606
|
+
const href = match[2];
|
|
607
|
+
if (!href)
|
|
608
|
+
continue;
|
|
609
|
+
const target = resolveSmartDocsLink(href, filePath, repoRoot);
|
|
610
|
+
if (target === null)
|
|
611
|
+
continue; // not a smartdocs/ link — skip
|
|
612
|
+
if (!(0, node_fs_1.existsSync)(target)) {
|
|
613
|
+
conflicts.push({
|
|
614
|
+
type: "stale-assumption",
|
|
615
|
+
conflictingFile: (0, node_path_1.relative)((0, node_path_1.resolve)(repoRoot), (0, node_path_1.resolve)(filePath)).replace(/\\/g, "/"),
|
|
616
|
+
detail: `broken link: "${href}" → target not found: ${(0, node_path_1.relative)((0, node_path_1.resolve)(repoRoot), target).replace(/\\/g, "/")}`,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return conflicts;
|
|
621
|
+
}
|
|
393
622
|
/**
|
|
394
623
|
* Promotes a spec file from smartdocs/raw/ into smartdocs/specs/active/.
|
|
395
624
|
*
|
|
@@ -529,6 +758,8 @@ function specPromote(path, options) {
|
|
|
529
758
|
approved: options.approve ?? false,
|
|
530
759
|
timestamp: new Date().toISOString(),
|
|
531
760
|
});
|
|
761
|
+
const reason = options.reason ?? `${(0, node_path_1.basename)(destination)} promoted to specs/active/`;
|
|
762
|
+
logDirectoryChange((0, node_path_1.dirname)(destination), "Promote", reason);
|
|
532
763
|
return { source, destination, runId, lifecyclePath, conflicts, halted: false, report };
|
|
533
764
|
}
|
|
534
765
|
/**
|
|
@@ -402,6 +402,42 @@ function createDocsCommand(options = {}) {
|
|
|
402
402
|
console.log(`skipped (draft exists): ${pathArg}/SUMMARY.md`);
|
|
403
403
|
}
|
|
404
404
|
});
|
|
405
|
+
docs
|
|
406
|
+
.command("seed-index [path]")
|
|
407
|
+
.description("Generate OKF-conformant index.md files for smartdocs/")
|
|
408
|
+
.option("-r, --repo-root <path>", "Repository root", defaultRepoRoot)
|
|
409
|
+
.option("--all", "Generate index.md for all smartdocs directories lacking one")
|
|
410
|
+
.option("--dry-run", "Print what would be written without writing files")
|
|
411
|
+
.action((pathArg, options) => {
|
|
412
|
+
if (options.all) {
|
|
413
|
+
const { written, skippedExists, skippedDraft, } = (0, seed_instructions_js_1.seedIndexAll)(options.repoRoot, {
|
|
414
|
+
dryRun: options.dryRun,
|
|
415
|
+
});
|
|
416
|
+
for (const dir of written) {
|
|
417
|
+
console.log(`${options.dryRun ? "[dry-run] would write" : "written"}: ${dir}/index.md`);
|
|
418
|
+
}
|
|
419
|
+
for (const dir of skippedExists) {
|
|
420
|
+
console.log(`skipped (human-edited): ${dir}/index.md`);
|
|
421
|
+
}
|
|
422
|
+
for (const dir of skippedDraft) {
|
|
423
|
+
console.log(`skipped (draft exists): ${dir}/index.md`);
|
|
424
|
+
}
|
|
425
|
+
console.log(`\nDone. ${written.length} written, ${skippedExists.length} skipped (exists), ${skippedDraft.length} skipped (draft).`);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const targetPath = pathArg || "smartdocs";
|
|
429
|
+
const result = (0, seed_instructions_js_1.seedIndex)(targetPath, options.repoRoot, { dryRun: options.dryRun });
|
|
430
|
+
if (result === "written") {
|
|
431
|
+
const label = options.dryRun ? "[dry-run] would write" : "written";
|
|
432
|
+
console.log(`${label}: ${targetPath}/index.md`);
|
|
433
|
+
}
|
|
434
|
+
else if (result === "skipped-exists") {
|
|
435
|
+
console.warn(`warning: ${targetPath}/index.md already exists (no draft marker) — skipped`);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
console.log(`skipped (draft exists): ${targetPath}/index.md`);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
405
441
|
docs
|
|
406
442
|
.command("audit")
|
|
407
443
|
.description("Scan repo for files at risk of recursive ingestion")
|
|
@@ -464,9 +500,10 @@ function createDoctrineCommand() {
|
|
|
464
500
|
.description("Move a doc from smartdocs/raw/ or smartdocs/doctrine/raw/ to docs/doctrine/candidate/")
|
|
465
501
|
.option("-r, --repo-root <path>", "Repository root", process.cwd())
|
|
466
502
|
.option("--run-id <id>", "Override the generated doctrine run ID")
|
|
503
|
+
.option("--reason <text>", "Reason for this draft")
|
|
467
504
|
.action((path, options) => {
|
|
468
505
|
try {
|
|
469
|
-
const result = (0, doctrine_js_1.doctrineDraft)(path, { repoRoot: options.repoRoot, runId: options.runId });
|
|
506
|
+
const result = (0, doctrine_js_1.doctrineDraft)(path, { repoRoot: options.repoRoot, runId: options.runId, reason: options.reason });
|
|
470
507
|
console.log(`drafted: ${result.destination}`);
|
|
471
508
|
console.log(`provenance: ${result.lifecyclePath}`);
|
|
472
509
|
}
|
|
@@ -480,11 +517,13 @@ function createDoctrineCommand() {
|
|
|
480
517
|
.description("Move a doc from smartdocs/doctrine/candidate/ to smartdocs/doctrine/active/")
|
|
481
518
|
.option("-r, --repo-root <path>", "Repository root", process.cwd())
|
|
482
519
|
.option("--run-id <id>", "Override the generated doctrine run ID")
|
|
520
|
+
.option("--reason <text>", "Reason for this promotion")
|
|
483
521
|
.action((path, options) => {
|
|
484
522
|
try {
|
|
485
523
|
const result = (0, doctrine_js_1.doctrinePromote)(path, {
|
|
486
524
|
repoRoot: options.repoRoot,
|
|
487
|
-
runId: options.runId
|
|
525
|
+
runId: options.runId,
|
|
526
|
+
reason: options.reason,
|
|
488
527
|
});
|
|
489
528
|
console.log(`promoted: ${result.destination}`);
|
|
490
529
|
console.log(`provenance: ${result.lifecyclePath}`);
|
|
@@ -499,11 +538,13 @@ function createDoctrineCommand() {
|
|
|
499
538
|
.description("Move a doc from smartdocs/doctrine/active/ to smartdocs/doctrine/deprecated/")
|
|
500
539
|
.option("-r, --repo-root <path>", "Repository root", process.cwd())
|
|
501
540
|
.option("--run-id <id>", "Override the generated doctrine run ID")
|
|
541
|
+
.option("--reason <text>", "Reason for this deprecation")
|
|
502
542
|
.action((path, options) => {
|
|
503
543
|
try {
|
|
504
544
|
const result = (0, doctrine_js_1.doctrineDeprecate)(path, {
|
|
505
545
|
repoRoot: options.repoRoot,
|
|
506
546
|
runId: options.runId,
|
|
547
|
+
reason: options.reason,
|
|
507
548
|
});
|
|
508
549
|
console.log(`deprecated: ${result.destination}`);
|
|
509
550
|
console.log(`provenance: ${result.lifecyclePath}`);
|
|
@@ -519,12 +560,14 @@ function createDoctrineCommand() {
|
|
|
519
560
|
.option("-r, --repo-root <path>", "Repository root", process.cwd())
|
|
520
561
|
.option("--run-id <id>", "Override the generated run ID")
|
|
521
562
|
.option("--approve", "Proceed despite detected conflicts")
|
|
563
|
+
.option("--reason <text>", "Reason for this promotion")
|
|
522
564
|
.action((path, options) => {
|
|
523
565
|
try {
|
|
524
566
|
const result = (0, doctrine_js_1.specPromote)(path, {
|
|
525
567
|
repoRoot: options.repoRoot,
|
|
526
568
|
runId: options.runId,
|
|
527
569
|
approve: options.approve,
|
|
570
|
+
reason: options.reason,
|
|
528
571
|
});
|
|
529
572
|
console.log(result.report);
|
|
530
573
|
if (result.halted) {
|
|
@@ -4,15 +4,20 @@ exports.DRAFT_MARKER = void 0;
|
|
|
4
4
|
exports.hasDraftMarker = hasDraftMarker;
|
|
5
5
|
exports.generateDraft = generateDraft;
|
|
6
6
|
exports.generateSummaryDraft = generateSummaryDraft;
|
|
7
|
+
exports.generateBundleRootIndex = generateBundleRootIndex;
|
|
8
|
+
exports.generateDirectoryIndex = generateDirectoryIndex;
|
|
7
9
|
exports.seedInstructions = seedInstructions;
|
|
8
10
|
exports.seedSummary = seedSummary;
|
|
9
11
|
exports.seedInstructionsAll = seedInstructionsAll;
|
|
10
12
|
exports.seedSummaryAll = seedSummaryAll;
|
|
13
|
+
exports.seedIndex = seedIndex;
|
|
14
|
+
exports.seedIndexAll = seedIndexAll;
|
|
11
15
|
const node_fs_1 = require("node:fs");
|
|
12
16
|
const node_path_1 = require("node:path");
|
|
13
17
|
const loader_js_1 = require("../config/loader.js");
|
|
14
18
|
const atlas_js_1 = require("../map/atlas.js");
|
|
15
19
|
const smartdoc_ignore_js_1 = require("./smartdoc-ignore.js");
|
|
20
|
+
const doctrine_js_1 = require("./doctrine.js");
|
|
16
21
|
exports.DRAFT_MARKER = "<!-- polaris:draft -->";
|
|
17
22
|
function hasDraftMarker(filePath) {
|
|
18
23
|
try {
|
|
@@ -176,6 +181,132 @@ function generateSummaryDraft(targetDir, repoRoot, _allRoutes) {
|
|
|
176
181
|
];
|
|
177
182
|
return lines.join("\n");
|
|
178
183
|
}
|
|
184
|
+
const RESERVED_INDEX_NAMES = new Set(["index.md", "POLARIS.md", "SUMMARY.md", "log.md"]);
|
|
185
|
+
function isReservedIndexName(name) {
|
|
186
|
+
return RESERVED_INDEX_NAMES.has(name);
|
|
187
|
+
}
|
|
188
|
+
function listConceptFiles(dir) {
|
|
189
|
+
try {
|
|
190
|
+
return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })
|
|
191
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md") && !isReservedIndexName(e.name))
|
|
192
|
+
.map((e) => (0, node_path_1.join)(dir, e.name))
|
|
193
|
+
.sort();
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function conceptLabel(filePath) {
|
|
200
|
+
try {
|
|
201
|
+
const content = (0, node_fs_1.readFileSync)(filePath, "utf-8");
|
|
202
|
+
const fm = (0, doctrine_js_1.parseFrontMatter)(content);
|
|
203
|
+
const base = (0, node_path_1.basename)(filePath);
|
|
204
|
+
return fm.description || fm.title || base.replace(/\.md$/, "");
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return (0, node_path_1.basename)(filePath).replace(/\.md$/, "");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function collectSmartDocsDirs(dir, root, result = []) {
|
|
211
|
+
try {
|
|
212
|
+
const rel = (0, node_path_1.relative)(root, dir).replace(/\\/g, "/");
|
|
213
|
+
if (rel.split("/").some((s) => s === "raw" || s.startsWith("."))) {
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
result.push(rel);
|
|
217
|
+
for (const entry of (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })) {
|
|
218
|
+
if (!entry.isDirectory())
|
|
219
|
+
continue;
|
|
220
|
+
if (entry.name.startsWith(".") || entry.name === "raw")
|
|
221
|
+
continue;
|
|
222
|
+
const full = (0, node_path_1.join)(dir, entry.name);
|
|
223
|
+
collectSmartDocsDirs(full, root, result);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// ignore unreadable dirs
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
function generateBundleRootIndex(repoRoot, allRoutes) {
|
|
232
|
+
const lines = [
|
|
233
|
+
"---",
|
|
234
|
+
"okf_version: \"0.1\"",
|
|
235
|
+
"---",
|
|
236
|
+
"",
|
|
237
|
+
exports.DRAFT_MARKER,
|
|
238
|
+
"# SmartDocs — Polaris Cognition Bundle",
|
|
239
|
+
"",
|
|
240
|
+
"> Polaris draft — review and remove the `<!-- polaris:draft -->` marker to promote.",
|
|
241
|
+
"",
|
|
242
|
+
"## Governance",
|
|
243
|
+
"",
|
|
244
|
+
];
|
|
245
|
+
const doctrineDir = (0, node_path_1.join)(repoRoot, "smartdocs", "doctrine", "active");
|
|
246
|
+
const doctrineFiles = listConceptFiles(doctrineDir);
|
|
247
|
+
if (doctrineFiles.length > 0) {
|
|
248
|
+
for (const file of doctrineFiles) {
|
|
249
|
+
const relPath = (0, node_path_1.relative)((0, node_path_1.join)(repoRoot, "smartdocs"), file).replace(/\\/g, "/");
|
|
250
|
+
lines.push(`- [${conceptLabel(file)}](${relPath})`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
lines.push("- [Doctrine — Active](doctrine/active/)");
|
|
255
|
+
}
|
|
256
|
+
lines.push("");
|
|
257
|
+
lines.push("## Specs", "");
|
|
258
|
+
const specsDir = (0, node_path_1.join)(repoRoot, "smartdocs", "specs", "active");
|
|
259
|
+
const specsFiles = listConceptFiles(specsDir);
|
|
260
|
+
if (specsFiles.length > 0) {
|
|
261
|
+
for (const file of specsFiles) {
|
|
262
|
+
const relPath = (0, node_path_1.relative)((0, node_path_1.join)(repoRoot, "smartdocs"), file).replace(/\\/g, "/");
|
|
263
|
+
lines.push(`- [${conceptLabel(file)}](${relPath})`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
lines.push("- [Specs — Active](specs/active/)");
|
|
268
|
+
}
|
|
269
|
+
lines.push("");
|
|
270
|
+
lines.push("## Routes", "");
|
|
271
|
+
const instructionFiles = [
|
|
272
|
+
...new Set(Object.values(allRoutes).map((e) => e.instructionFile).filter(Boolean)),
|
|
273
|
+
].sort();
|
|
274
|
+
if (instructionFiles.length > 0) {
|
|
275
|
+
for (const file of instructionFiles) {
|
|
276
|
+
lines.push(`- [${file}](../${file})`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
lines.push("<!-- Route POLARIS.md files will be listed here from atlas signals. -->");
|
|
281
|
+
}
|
|
282
|
+
lines.push("");
|
|
283
|
+
return lines.join("\n");
|
|
284
|
+
}
|
|
285
|
+
function generateDirectoryIndex(targetDir, repoRoot) {
|
|
286
|
+
const absDir = (0, node_path_1.resolve)(repoRoot, targetDir);
|
|
287
|
+
const dirLabel = (0, node_path_1.basename)(absDir) || "SmartDocs";
|
|
288
|
+
const files = listConceptFiles(absDir);
|
|
289
|
+
const lines = [
|
|
290
|
+
exports.DRAFT_MARKER,
|
|
291
|
+
`# ${dirLabel}`,
|
|
292
|
+
"",
|
|
293
|
+
"> Polaris draft — review and remove the `<!-- polaris:draft -->` marker to promote.",
|
|
294
|
+
"",
|
|
295
|
+
"## Concepts",
|
|
296
|
+
"",
|
|
297
|
+
];
|
|
298
|
+
if (files.length > 0) {
|
|
299
|
+
for (const file of files) {
|
|
300
|
+
const relPath = (0, node_path_1.relative)(absDir, file).replace(/\\/g, "/");
|
|
301
|
+
lines.push(`- [${conceptLabel(file)}](${relPath})`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
lines.push("<!-- No concept files in this directory yet. -->");
|
|
306
|
+
}
|
|
307
|
+
lines.push("");
|
|
308
|
+
return lines.join("\n");
|
|
309
|
+
}
|
|
179
310
|
function seedInstructions(targetPath, repoRoot, opts = {}) {
|
|
180
311
|
const absTarget = (0, node_path_1.resolve)(repoRoot, targetPath);
|
|
181
312
|
// Path traversal check: ensure absTarget is within repoRoot
|
|
@@ -333,3 +464,78 @@ function seedSummaryAll(repoRoot, opts = {}) {
|
|
|
333
464
|
}
|
|
334
465
|
return { written, skippedExists, skippedDraft, skippedIneligible, skippedRoot };
|
|
335
466
|
}
|
|
467
|
+
function seedIndex(targetPath, repoRoot, opts = {}) {
|
|
468
|
+
const absTarget = (0, node_path_1.resolve)(repoRoot, targetPath);
|
|
469
|
+
const relCheck = (0, node_path_1.relative)(repoRoot, absTarget);
|
|
470
|
+
if (relCheck.startsWith("..") || relCheck.startsWith("/")) {
|
|
471
|
+
throw new Error(`Path traversal detected: target path is outside repo root`);
|
|
472
|
+
}
|
|
473
|
+
// Reject targets outside smartdocs/
|
|
474
|
+
const relTarget = relCheck.replace(/\\/g, "/");
|
|
475
|
+
if (!relTarget.startsWith("smartdocs/") && relTarget !== "smartdocs") {
|
|
476
|
+
throw new Error(`seedIndex only writes index.md under smartdocs/; rejecting target: ${relTarget}`);
|
|
477
|
+
}
|
|
478
|
+
const outFile = (0, node_path_1.join)(absTarget, "index.md");
|
|
479
|
+
if ((0, node_fs_1.existsSync)(outFile)) {
|
|
480
|
+
if (hasDraftMarker(outFile)) {
|
|
481
|
+
return "skipped-draft";
|
|
482
|
+
}
|
|
483
|
+
return "skipped-exists";
|
|
484
|
+
}
|
|
485
|
+
const config = (0, loader_js_1.loadConfig)(repoRoot);
|
|
486
|
+
const atlasPath = (0, node_path_1.resolve)(repoRoot, config.repo.sidecarOutputPath ?? ".polaris/map");
|
|
487
|
+
const allRoutes = {
|
|
488
|
+
...(0, atlas_js_1.readFileRoutes)(atlasPath),
|
|
489
|
+
...(0, atlas_js_1.readNeedsReview)(atlasPath),
|
|
490
|
+
};
|
|
491
|
+
const isBundleRoot = relTarget === "smartdocs";
|
|
492
|
+
const content = isBundleRoot
|
|
493
|
+
? generateBundleRootIndex(repoRoot, allRoutes)
|
|
494
|
+
: generateDirectoryIndex(relTarget, repoRoot);
|
|
495
|
+
if (!opts.dryRun) {
|
|
496
|
+
(0, node_fs_1.writeFileSync)(outFile, content, "utf-8");
|
|
497
|
+
}
|
|
498
|
+
return "written";
|
|
499
|
+
}
|
|
500
|
+
function seedIndexAll(repoRoot, opts = {}) {
|
|
501
|
+
const config = (0, loader_js_1.loadConfig)(repoRoot);
|
|
502
|
+
const atlasPath = (0, node_path_1.resolve)(repoRoot, config.repo.sidecarOutputPath ?? ".polaris/map");
|
|
503
|
+
const allRoutes = {
|
|
504
|
+
...(0, atlas_js_1.readFileRoutes)(atlasPath),
|
|
505
|
+
...(0, atlas_js_1.readNeedsReview)(atlasPath),
|
|
506
|
+
};
|
|
507
|
+
const smartdocsRoot = (0, node_path_1.resolve)(repoRoot, "smartdocs");
|
|
508
|
+
if (!(0, node_fs_1.existsSync)(smartdocsRoot)) {
|
|
509
|
+
return { written: [], skippedExists: [], skippedDraft: [], skippedIneligible: [] };
|
|
510
|
+
}
|
|
511
|
+
const dirs = collectSmartDocsDirs(smartdocsRoot, repoRoot);
|
|
512
|
+
const smartdocsRel = "smartdocs";
|
|
513
|
+
if (!dirs.includes(smartdocsRel)) {
|
|
514
|
+
dirs.unshift(smartdocsRel);
|
|
515
|
+
}
|
|
516
|
+
const written = [];
|
|
517
|
+
const skippedExists = [];
|
|
518
|
+
const skippedDraft = [];
|
|
519
|
+
for (const relDir of dirs) {
|
|
520
|
+
const absDir = (0, node_path_1.resolve)(repoRoot, relDir);
|
|
521
|
+
const outFile = (0, node_path_1.join)(absDir, "index.md");
|
|
522
|
+
if ((0, node_fs_1.existsSync)(outFile)) {
|
|
523
|
+
if (hasDraftMarker(outFile)) {
|
|
524
|
+
skippedDraft.push(relDir);
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
skippedExists.push(relDir);
|
|
528
|
+
}
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const isBundleRoot = relDir === "smartdocs";
|
|
532
|
+
const content = isBundleRoot
|
|
533
|
+
? generateBundleRootIndex(repoRoot, allRoutes)
|
|
534
|
+
: generateDirectoryIndex(relDir, repoRoot);
|
|
535
|
+
if (!opts.dryRun) {
|
|
536
|
+
(0, node_fs_1.writeFileSync)(outFile, content, "utf-8");
|
|
537
|
+
}
|
|
538
|
+
written.push(relDir);
|
|
539
|
+
}
|
|
540
|
+
return { written, skippedExists, skippedDraft, skippedIneligible: [] };
|
|
541
|
+
}
|