@okf-harness/core 0.1.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.
- package/README.md +14 -0
- package/dist/index.d.ts +539 -0
- package/dist/index.js +2717 -0
- package/package.json +54 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2717 @@
|
|
|
1
|
+
// src/config/index.ts
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
// src/paths/index.ts
|
|
7
|
+
import { realpath } from "fs/promises";
|
|
8
|
+
import path from "path";
|
|
9
|
+
var PATH_OUTSIDE_WORKSPACE = "PATH_OUTSIDE_WORKSPACE";
|
|
10
|
+
var WorkspacePathError = class extends Error {
|
|
11
|
+
constructor(message, workspaceRoot, input) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.workspaceRoot = workspaceRoot;
|
|
14
|
+
this.input = input;
|
|
15
|
+
this.name = "WorkspacePathError";
|
|
16
|
+
}
|
|
17
|
+
workspaceRoot;
|
|
18
|
+
input;
|
|
19
|
+
code = PATH_OUTSIDE_WORKSPACE;
|
|
20
|
+
};
|
|
21
|
+
function toPosixPath(input) {
|
|
22
|
+
return input.replace(/\\/g, "/");
|
|
23
|
+
}
|
|
24
|
+
function toPosixRelativePath(from, to) {
|
|
25
|
+
return toPosixPath(path.relative(from, to));
|
|
26
|
+
}
|
|
27
|
+
async function safeResolveWorkspacePath(workspaceRoot, input) {
|
|
28
|
+
if (input.trim().length === 0) {
|
|
29
|
+
throw new WorkspacePathError("Workspace path input must not be empty.", workspaceRoot, input);
|
|
30
|
+
}
|
|
31
|
+
const resolvedWorkspaceRoot = await realpathOrResolve(workspaceRoot);
|
|
32
|
+
const candidate = path.isAbsolute(input) ? path.resolve(input) : path.resolve(resolvedWorkspaceRoot, input);
|
|
33
|
+
const resolvedCandidate = await realpathExistingPrefix(candidate);
|
|
34
|
+
if (!isPathInside(resolvedWorkspaceRoot, resolvedCandidate)) {
|
|
35
|
+
throw new WorkspacePathError(
|
|
36
|
+
`Path resolves outside workspace: ${input}`,
|
|
37
|
+
resolvedWorkspaceRoot,
|
|
38
|
+
input
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
workspaceRoot: resolvedWorkspaceRoot,
|
|
43
|
+
absolutePath: resolvedCandidate,
|
|
44
|
+
relativePath: toPosixRelativePath(resolvedWorkspaceRoot, resolvedCandidate)
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function isPathInside(root, candidate) {
|
|
48
|
+
const relative = path.relative(root, candidate);
|
|
49
|
+
return relative.length === 0 || !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
50
|
+
}
|
|
51
|
+
async function realpathOrResolve(input) {
|
|
52
|
+
try {
|
|
53
|
+
return await realpath(input);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (errorCode(error) === "ENOENT") {
|
|
56
|
+
return path.resolve(input);
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function realpathExistingPrefix(candidate) {
|
|
62
|
+
const missingSegments = [];
|
|
63
|
+
let current = candidate;
|
|
64
|
+
while (true) {
|
|
65
|
+
try {
|
|
66
|
+
const existing = await realpath(current);
|
|
67
|
+
return path.join(existing, ...missingSegments.reverse());
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (errorCode(error) !== "ENOENT") {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
const parent = path.dirname(current);
|
|
73
|
+
if (parent === current) {
|
|
74
|
+
return path.resolve(candidate);
|
|
75
|
+
}
|
|
76
|
+
missingSegments.push(path.basename(current));
|
|
77
|
+
current = parent;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function errorCode(error) {
|
|
82
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
83
|
+
return void 0;
|
|
84
|
+
}
|
|
85
|
+
const code = error.code;
|
|
86
|
+
return typeof code === "string" ? code : void 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/config/index.ts
|
|
90
|
+
var CONFIG_INVALID = "CONFIG_INVALID";
|
|
91
|
+
var configRelativePathSchema = z.string().min(1).refine((value) => isSafeConfigRelativePath(value), {
|
|
92
|
+
message: "Path must be a non-empty workspace-relative POSIX path without traversal."
|
|
93
|
+
});
|
|
94
|
+
var workspaceConfigSchema = z.object({
|
|
95
|
+
version: z.union([z.literal(0.1), z.literal("0.1")]).transform(() => "0.1"),
|
|
96
|
+
workspace: z.object({
|
|
97
|
+
name: z.string().min(1),
|
|
98
|
+
created_at: z.string().min(1),
|
|
99
|
+
platform: z.literal("macos")
|
|
100
|
+
}).strict(),
|
|
101
|
+
okf: z.object({
|
|
102
|
+
bundle_root: configRelativePathSchema,
|
|
103
|
+
profile: z.string().min(1)
|
|
104
|
+
}).strict(),
|
|
105
|
+
agents: z.object({
|
|
106
|
+
tier1: z.object({
|
|
107
|
+
claude: z.boolean(),
|
|
108
|
+
codex: z.boolean()
|
|
109
|
+
}).strict(),
|
|
110
|
+
tier2: z.object({
|
|
111
|
+
pi: z.boolean(),
|
|
112
|
+
opencode: z.boolean()
|
|
113
|
+
}).strict()
|
|
114
|
+
}).strict(),
|
|
115
|
+
paths: z.object({
|
|
116
|
+
raw_inbox: configRelativePathSchema,
|
|
117
|
+
raw_sources: configRelativePathSchema,
|
|
118
|
+
wiki_root: configRelativePathSchema,
|
|
119
|
+
manifest: configRelativePathSchema
|
|
120
|
+
}).strict(),
|
|
121
|
+
safety: z.object({
|
|
122
|
+
raw_sources_immutable: z.boolean(),
|
|
123
|
+
require_git_checkpoint_before_agent_write: z.boolean(),
|
|
124
|
+
max_files_changed_per_ingest: z.number().int().positive()
|
|
125
|
+
}).strict()
|
|
126
|
+
}).strict().refine((config) => config.okf.bundle_root === config.paths.wiki_root, {
|
|
127
|
+
path: ["paths", "wiki_root"],
|
|
128
|
+
message: "paths.wiki_root must match okf.bundle_root."
|
|
129
|
+
});
|
|
130
|
+
var WorkspaceConfigError = class extends Error {
|
|
131
|
+
constructor(issues) {
|
|
132
|
+
super(issues.map((issue) => issue.message).join("; "));
|
|
133
|
+
this.issues = issues;
|
|
134
|
+
this.name = "WorkspaceConfigError";
|
|
135
|
+
}
|
|
136
|
+
issues;
|
|
137
|
+
code = CONFIG_INVALID;
|
|
138
|
+
};
|
|
139
|
+
function parseWorkspaceConfig(source) {
|
|
140
|
+
let rawConfig;
|
|
141
|
+
try {
|
|
142
|
+
rawConfig = parseYaml(source);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
issues: [
|
|
147
|
+
{
|
|
148
|
+
code: CONFIG_INVALID,
|
|
149
|
+
path: "<yaml>",
|
|
150
|
+
message: error instanceof Error ? error.message : "Invalid YAML."
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const parsed = workspaceConfigSchema.safeParse(rawConfig);
|
|
156
|
+
if (!parsed.success) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
issues: parsed.error.issues.map((issue) => ({
|
|
160
|
+
code: CONFIG_INVALID,
|
|
161
|
+
path: issue.path.length > 0 ? issue.path.join(".") : "<root>",
|
|
162
|
+
message: issue.message
|
|
163
|
+
}))
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return { ok: true, config: parsed.data };
|
|
167
|
+
}
|
|
168
|
+
async function readWorkspaceConfig(workspaceRoot) {
|
|
169
|
+
let configPath;
|
|
170
|
+
try {
|
|
171
|
+
configPath = (await safeResolveWorkspacePath(workspaceRoot, "okfh.config.yaml")).absolutePath;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
issues: [
|
|
176
|
+
{
|
|
177
|
+
code: CONFIG_INVALID,
|
|
178
|
+
path: "okfh.config.yaml",
|
|
179
|
+
message: error instanceof Error ? error.message : "Could not resolve workspace config path."
|
|
180
|
+
}
|
|
181
|
+
]
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
return parseWorkspaceConfig(await readFile(configPath, "utf8"));
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
issues: [
|
|
190
|
+
{
|
|
191
|
+
code: CONFIG_INVALID,
|
|
192
|
+
path: "okfh.config.yaml",
|
|
193
|
+
message: error instanceof Error ? error.message : "Could not read workspace config."
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function loadWorkspaceConfig(workspaceRoot) {
|
|
200
|
+
const result = await readWorkspaceConfig(workspaceRoot);
|
|
201
|
+
if (!result.ok) {
|
|
202
|
+
throw new WorkspaceConfigError(result.issues);
|
|
203
|
+
}
|
|
204
|
+
return result.config;
|
|
205
|
+
}
|
|
206
|
+
function isSafeConfigRelativePath(value) {
|
|
207
|
+
if (value.startsWith("/") || value.includes("\\")) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const segments = value.split("/");
|
|
211
|
+
return segments.every((segment) => segment.length > 0 && segment !== "..");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/graph/index.ts
|
|
215
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
216
|
+
import path4 from "path";
|
|
217
|
+
|
|
218
|
+
// src/okf/concepts.ts
|
|
219
|
+
import { readdir, readFile as readFile2 } from "fs/promises";
|
|
220
|
+
import path2 from "path";
|
|
221
|
+
|
|
222
|
+
// src/okf/frontmatter.ts
|
|
223
|
+
import matter from "gray-matter";
|
|
224
|
+
function parseMarkdownFrontmatter(markdown) {
|
|
225
|
+
if (!markdown.startsWith("---\n") && !markdown.startsWith("---\r\n")) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
hasFrontmatter: false,
|
|
229
|
+
error: "missing",
|
|
230
|
+
message: "Markdown file is missing YAML frontmatter."
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const parsed = matter(markdown);
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
hasFrontmatter: true,
|
|
238
|
+
data: parsed.data,
|
|
239
|
+
body: parsed.content,
|
|
240
|
+
raw: parsed.matter
|
|
241
|
+
};
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
hasFrontmatter: true,
|
|
246
|
+
error: "invalid",
|
|
247
|
+
message: error instanceof Error ? error.message : "Invalid YAML frontmatter."
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/okf/concepts.ts
|
|
253
|
+
var RESERVED_OKF_FILENAMES = /* @__PURE__ */ new Set(["index.md", "log.md"]);
|
|
254
|
+
var SCAN_FAILED = "SCAN_FAILED";
|
|
255
|
+
var ConceptScanError = class extends Error {
|
|
256
|
+
constructor(message, details = {}) {
|
|
257
|
+
super(message);
|
|
258
|
+
this.details = details;
|
|
259
|
+
this.name = "ConceptScanError";
|
|
260
|
+
}
|
|
261
|
+
details;
|
|
262
|
+
code = SCAN_FAILED;
|
|
263
|
+
};
|
|
264
|
+
async function scanConcepts(workspaceRoot, config) {
|
|
265
|
+
let wikiRoot;
|
|
266
|
+
let markdownFiles;
|
|
267
|
+
try {
|
|
268
|
+
wikiRoot = await safeResolveWorkspacePath(workspaceRoot, config.okf.bundle_root);
|
|
269
|
+
const files = await scanMarkdownFiles(wikiRoot.absolutePath);
|
|
270
|
+
markdownFiles = await Promise.all(
|
|
271
|
+
files.map(async (absolutePath) => {
|
|
272
|
+
const bundlePath = toPosixRelativePath(wikiRoot.absolutePath, absolutePath);
|
|
273
|
+
const markdown = await readFile2(absolutePath, "utf8");
|
|
274
|
+
return {
|
|
275
|
+
absolutePath,
|
|
276
|
+
workspacePath: toPosixRelativePath(wikiRoot.workspaceRoot, absolutePath),
|
|
277
|
+
bundlePath,
|
|
278
|
+
conceptId: conceptIdFromPath(bundlePath),
|
|
279
|
+
isReserved: isReservedOkfFile(bundlePath),
|
|
280
|
+
markdown,
|
|
281
|
+
frontmatter: parseMarkdownFrontmatter(markdown)
|
|
282
|
+
};
|
|
283
|
+
})
|
|
284
|
+
);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throw new ConceptScanError(
|
|
287
|
+
error instanceof Error ? error.message : "Could not scan OKF wiki.",
|
|
288
|
+
{
|
|
289
|
+
wikiRoot: config.okf.bundle_root
|
|
290
|
+
}
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const concepts = markdownFiles.flatMap((file) => {
|
|
294
|
+
if (file.isReserved || !file.frontmatter.ok) {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
const conceptType = stringValue(file.frontmatter.data.type);
|
|
298
|
+
if (conceptType === void 0) {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
const concept = {
|
|
302
|
+
id: file.conceptId,
|
|
303
|
+
absolutePath: file.absolutePath,
|
|
304
|
+
workspacePath: file.workspacePath,
|
|
305
|
+
bundlePath: file.bundlePath,
|
|
306
|
+
type: conceptType,
|
|
307
|
+
tags: stringArrayValue(file.frontmatter.data.tags),
|
|
308
|
+
frontmatter: file.frontmatter.data,
|
|
309
|
+
body: file.frontmatter.body
|
|
310
|
+
};
|
|
311
|
+
const title = stringValue(file.frontmatter.data.title);
|
|
312
|
+
if (title !== void 0) {
|
|
313
|
+
concept.title = title;
|
|
314
|
+
}
|
|
315
|
+
const description = stringValue(file.frontmatter.data.description);
|
|
316
|
+
if (description !== void 0) {
|
|
317
|
+
concept.description = description;
|
|
318
|
+
}
|
|
319
|
+
const timestamp = stringValue(file.frontmatter.data.timestamp);
|
|
320
|
+
if (timestamp !== void 0) {
|
|
321
|
+
concept.timestamp = timestamp;
|
|
322
|
+
}
|
|
323
|
+
return [concept];
|
|
324
|
+
});
|
|
325
|
+
return {
|
|
326
|
+
workspaceRoot: wikiRoot.workspaceRoot,
|
|
327
|
+
wikiRoot: wikiRoot.absolutePath,
|
|
328
|
+
files: markdownFiles.sort((left, right) => left.bundlePath.localeCompare(right.bundlePath)),
|
|
329
|
+
concepts: concepts.sort((left, right) => left.id.localeCompare(right.id))
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function conceptIdFromPath(markdownPath) {
|
|
333
|
+
const normalized = toPosixPath(markdownPath);
|
|
334
|
+
if (!normalized.endsWith(".md")) {
|
|
335
|
+
throw new Error(`Concept path must end with .md: ${markdownPath}`);
|
|
336
|
+
}
|
|
337
|
+
const withoutWikiPrefix = normalized.startsWith("wiki/") ? normalized.slice("wiki/".length) : normalized;
|
|
338
|
+
return withoutWikiPrefix.slice(0, -".md".length);
|
|
339
|
+
}
|
|
340
|
+
function isReservedOkfFile(bundlePath) {
|
|
341
|
+
return RESERVED_OKF_FILENAMES.has(path2.posix.basename(toPosixPath(bundlePath)));
|
|
342
|
+
}
|
|
343
|
+
async function scanMarkdownFiles(root) {
|
|
344
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
345
|
+
const nested = await Promise.all(
|
|
346
|
+
entries.map(async (entry) => {
|
|
347
|
+
const absolutePath = path2.join(root, entry.name);
|
|
348
|
+
if (entry.isDirectory()) {
|
|
349
|
+
return scanMarkdownFiles(absolutePath);
|
|
350
|
+
}
|
|
351
|
+
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
352
|
+
return [absolutePath];
|
|
353
|
+
}
|
|
354
|
+
return [];
|
|
355
|
+
})
|
|
356
|
+
);
|
|
357
|
+
return nested.flat().sort((left, right) => left.localeCompare(right));
|
|
358
|
+
}
|
|
359
|
+
function stringValue(value) {
|
|
360
|
+
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
|
361
|
+
}
|
|
362
|
+
function stringArrayValue(value) {
|
|
363
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/okf/links.ts
|
|
367
|
+
import path3 from "path";
|
|
368
|
+
function parseMarkdownLinks(markdown) {
|
|
369
|
+
const links = [];
|
|
370
|
+
const lines = markdown.split(/\r?\n/);
|
|
371
|
+
lines.forEach((line, index) => {
|
|
372
|
+
for (const match of line.matchAll(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g)) {
|
|
373
|
+
const raw = match[0];
|
|
374
|
+
const text = match[1];
|
|
375
|
+
const target = match[2];
|
|
376
|
+
if (raw === void 0 || text === void 0 || target === void 0) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const link = {
|
|
380
|
+
text,
|
|
381
|
+
target,
|
|
382
|
+
raw,
|
|
383
|
+
line: index + 1
|
|
384
|
+
};
|
|
385
|
+
const title = match[3];
|
|
386
|
+
if (title !== void 0) {
|
|
387
|
+
link.title = title;
|
|
388
|
+
}
|
|
389
|
+
links.push(link);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
return links;
|
|
393
|
+
}
|
|
394
|
+
function resolveOkfLinkTarget(target, fromBundlePath) {
|
|
395
|
+
if (target.startsWith("http://") || target.startsWith("https://") || target.startsWith("#")) {
|
|
396
|
+
return void 0;
|
|
397
|
+
}
|
|
398
|
+
const withoutFragment = target.split("#", 1)[0] ?? "";
|
|
399
|
+
if (withoutFragment.length === 0 || !withoutFragment.endsWith(".md")) {
|
|
400
|
+
return void 0;
|
|
401
|
+
}
|
|
402
|
+
if (withoutFragment.startsWith("/")) {
|
|
403
|
+
return conceptIdFromPath(withoutFragment.slice(1));
|
|
404
|
+
}
|
|
405
|
+
if (withoutFragment.startsWith("wiki/")) {
|
|
406
|
+
return conceptIdFromPath(withoutFragment);
|
|
407
|
+
}
|
|
408
|
+
const normalized = path3.posix.normalize(
|
|
409
|
+
path3.posix.join(path3.posix.dirname(fromBundlePath), withoutFragment)
|
|
410
|
+
);
|
|
411
|
+
if (normalized.startsWith("../")) {
|
|
412
|
+
return void 0;
|
|
413
|
+
}
|
|
414
|
+
return conceptIdFromPath(normalized);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/graph/index.ts
|
|
418
|
+
var GRAPH_WRITE_FAILED = "GRAPH_WRITE_FAILED";
|
|
419
|
+
var GraphWorkspaceError = class extends Error {
|
|
420
|
+
constructor(message, code, details = {}) {
|
|
421
|
+
super(message);
|
|
422
|
+
this.code = code;
|
|
423
|
+
this.details = details;
|
|
424
|
+
this.name = "GraphWorkspaceError";
|
|
425
|
+
}
|
|
426
|
+
code;
|
|
427
|
+
details;
|
|
428
|
+
};
|
|
429
|
+
async function buildWorkspaceGraph(options) {
|
|
430
|
+
const workspaceRoot = path4.resolve(options.workspaceRoot);
|
|
431
|
+
const config = await loadWorkspaceConfig(workspaceRoot);
|
|
432
|
+
const scanResult = await scanConcepts(workspaceRoot, config);
|
|
433
|
+
const nodes = scanResult.files.filter((file) => !file.isReserved).map(graphNodeFromFile);
|
|
434
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
435
|
+
const { edges, missingTargets, issues } = graphEdgesFromFiles(
|
|
436
|
+
scanResult.files.filter((file) => !file.isReserved),
|
|
437
|
+
nodeIds
|
|
438
|
+
);
|
|
439
|
+
const backlinks = backlinksFromEdges(edges);
|
|
440
|
+
const backlinksPath = path4.join(workspaceRoot, ".okfh/backlinks.json");
|
|
441
|
+
const htmlPath = path4.join(workspaceRoot, ".okfh/reports/graph.html");
|
|
442
|
+
const data = {
|
|
443
|
+
generatedAt: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
444
|
+
workspaceRoot,
|
|
445
|
+
nodes,
|
|
446
|
+
edges,
|
|
447
|
+
backlinks,
|
|
448
|
+
issues,
|
|
449
|
+
missingTargets
|
|
450
|
+
};
|
|
451
|
+
try {
|
|
452
|
+
await mkdir(path4.dirname(backlinksPath), { recursive: true });
|
|
453
|
+
await mkdir(path4.dirname(htmlPath), { recursive: true });
|
|
454
|
+
await writeFile(backlinksPath, `${JSON.stringify(data, null, 2)}
|
|
455
|
+
`, "utf8");
|
|
456
|
+
await writeFile(htmlPath, renderGraphHtml(data), "utf8");
|
|
457
|
+
} catch (error) {
|
|
458
|
+
throw new GraphWorkspaceError(
|
|
459
|
+
error instanceof Error ? error.message : "Could not write graph artifacts.",
|
|
460
|
+
GRAPH_WRITE_FAILED,
|
|
461
|
+
{ backlinksPath, htmlPath }
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
workspaceRoot,
|
|
466
|
+
report: {
|
|
467
|
+
backlinksPath,
|
|
468
|
+
htmlPath
|
|
469
|
+
},
|
|
470
|
+
stats: {
|
|
471
|
+
nodes: nodes.length,
|
|
472
|
+
conceptEdges: edges.filter((edge) => edge.kind === "link").length,
|
|
473
|
+
evidenceEdges: edges.filter((edge) => edge.kind === "citation").length,
|
|
474
|
+
missingTargets: missingTargets.length
|
|
475
|
+
},
|
|
476
|
+
issues,
|
|
477
|
+
missingTargets
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function graphNodeFromFile(file) {
|
|
481
|
+
return {
|
|
482
|
+
id: file.conceptId,
|
|
483
|
+
path: file.workspacePath,
|
|
484
|
+
title: file.frontmatter.ok ? stringValue2(file.frontmatter.data.title) ?? firstHeading(file.markdown) ?? file.conceptId : firstHeading(file.markdown) ?? file.conceptId,
|
|
485
|
+
type: file.frontmatter.ok ? stringValue2(file.frontmatter.data.type) ?? "Unknown" : "Unknown",
|
|
486
|
+
tags: file.frontmatter.ok ? stringArrayValue2(file.frontmatter.data.tags) : []
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function graphEdgesFromFiles(files, nodeIds) {
|
|
490
|
+
const edges = /* @__PURE__ */ new Map();
|
|
491
|
+
const missingTargets = [];
|
|
492
|
+
const issues = [];
|
|
493
|
+
for (const file of files) {
|
|
494
|
+
const body = file.frontmatter.ok ? file.frontmatter.body : stripFrontmatterFence(file.markdown);
|
|
495
|
+
const targets = [
|
|
496
|
+
...parseMarkdownLinks(body).map((link) => ({ target: link.target, kind: "link" })),
|
|
497
|
+
...bareReferenceTargets(body).map((target) => ({ target, kind: "citation" }))
|
|
498
|
+
];
|
|
499
|
+
for (const target of targets) {
|
|
500
|
+
const conceptId = resolveOkfLinkTarget(target.target, file.bundlePath);
|
|
501
|
+
if (conceptId === void 0) {
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (!nodeIds.has(conceptId)) {
|
|
505
|
+
missingTargets.push({
|
|
506
|
+
from: file.conceptId,
|
|
507
|
+
target: target.target,
|
|
508
|
+
path: file.workspacePath
|
|
509
|
+
});
|
|
510
|
+
issues.push({
|
|
511
|
+
code: "MISSING_TARGET",
|
|
512
|
+
path: file.workspacePath,
|
|
513
|
+
message: `Graph link target does not exist: ${target.target}`
|
|
514
|
+
});
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (conceptId === file.conceptId) {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const edge = {
|
|
521
|
+
from: file.conceptId,
|
|
522
|
+
to: conceptId,
|
|
523
|
+
kind: target.kind
|
|
524
|
+
};
|
|
525
|
+
edges.set(`${edge.from}\0${edge.to}\0${edge.kind}`, edge);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
edges: [...edges.values()].sort(
|
|
530
|
+
(left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to) || left.kind.localeCompare(right.kind)
|
|
531
|
+
),
|
|
532
|
+
missingTargets,
|
|
533
|
+
issues
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function bareReferenceTargets(markdown) {
|
|
537
|
+
return [...markdown.matchAll(/(^|\s)(\/?(?:wiki\/)?references\/[^\s)]+\.md)\b/gm)].map((match) => match[2]).filter((target) => target !== void 0);
|
|
538
|
+
}
|
|
539
|
+
function backlinksFromEdges(edges) {
|
|
540
|
+
const backlinks = {};
|
|
541
|
+
for (const edge of edges) {
|
|
542
|
+
backlinks[edge.to] = [...backlinks[edge.to] ?? [], edge.from].sort();
|
|
543
|
+
}
|
|
544
|
+
return backlinks;
|
|
545
|
+
}
|
|
546
|
+
function renderGraphHtml(data) {
|
|
547
|
+
const safeJson = JSON.stringify({
|
|
548
|
+
nodes: data.nodes,
|
|
549
|
+
edges: data.edges,
|
|
550
|
+
issues: data.issues,
|
|
551
|
+
missingTargets: data.missingTargets
|
|
552
|
+
}).replace(/</g, "\\u003c");
|
|
553
|
+
return `<!doctype html>
|
|
554
|
+
<html lang="en">
|
|
555
|
+
<head>
|
|
556
|
+
<meta charset="utf-8">
|
|
557
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
558
|
+
<title>OKF Harness Graph</title>
|
|
559
|
+
<style>
|
|
560
|
+
body { margin: 0; font: 14px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #172026; background: #f7f8f5; }
|
|
561
|
+
header { display: flex; gap: 12px; align-items: center; padding: 14px 18px; border-bottom: 1px solid #d8ddd2; background: #ffffff; }
|
|
562
|
+
h1 { font-size: 16px; margin: 0; }
|
|
563
|
+
input, select { font: inherit; padding: 6px 8px; border: 1px solid #bac3b4; border-radius: 6px; background: #fff; }
|
|
564
|
+
main { display: grid; grid-template-columns: minmax(0, 1fr) 320px; min-height: calc(100vh - 58px); }
|
|
565
|
+
svg { width: 100%; height: calc(100vh - 58px); background: #f7f8f5; }
|
|
566
|
+
aside { border-left: 1px solid #d8ddd2; padding: 16px; background: #ffffff; overflow: auto; }
|
|
567
|
+
.node { cursor: pointer; }
|
|
568
|
+
.edge { stroke: #83907b; stroke-width: 1.4; opacity: .75; }
|
|
569
|
+
.node circle { fill: #2f6f73; stroke: #fff; stroke-width: 2; }
|
|
570
|
+
.node.reference circle { fill: #8b5e34; }
|
|
571
|
+
.node text { paint-order: stroke; stroke: #f7f8f5; stroke-width: 4; fill: #172026; font-size: 12px; }
|
|
572
|
+
.muted { color: #667064; }
|
|
573
|
+
@media (max-width: 760px) { main { grid-template-columns: 1fr; } aside { border-left: 0; border-top: 1px solid #d8ddd2; } svg { height: 62vh; } }
|
|
574
|
+
</style>
|
|
575
|
+
</head>
|
|
576
|
+
<body>
|
|
577
|
+
<header>
|
|
578
|
+
<h1>OKF Harness Graph</h1>
|
|
579
|
+
<input id="search" type="search" placeholder="Search nodes" aria-label="Search nodes">
|
|
580
|
+
<select id="type" aria-label="Filter by type"><option value="">All types</option></select>
|
|
581
|
+
</header>
|
|
582
|
+
<main>
|
|
583
|
+
<svg id="graph" role="img" aria-label="OKF concept graph"></svg>
|
|
584
|
+
<aside id="details"><p class="muted">Select a node to inspect links and metadata.</p></aside>
|
|
585
|
+
</main>
|
|
586
|
+
<script>
|
|
587
|
+
const graph = ${safeJson};
|
|
588
|
+
const svg = document.querySelector("#graph");
|
|
589
|
+
const details = document.querySelector("#details");
|
|
590
|
+
const search = document.querySelector("#search");
|
|
591
|
+
const type = document.querySelector("#type");
|
|
592
|
+
const types = [...new Set(graph.nodes.map((node) => node.type))].sort();
|
|
593
|
+
for (const item of types) {
|
|
594
|
+
const option = document.createElement("option");
|
|
595
|
+
option.value = item;
|
|
596
|
+
option.textContent = item;
|
|
597
|
+
type.appendChild(option);
|
|
598
|
+
}
|
|
599
|
+
function visibleNodes() {
|
|
600
|
+
const q = search.value.trim().toLowerCase();
|
|
601
|
+
return graph.nodes.filter((node) => {
|
|
602
|
+
const matchesType = !type.value || node.type === type.value;
|
|
603
|
+
const haystack = [node.id, node.title, node.path, node.type].join(" ").toLowerCase();
|
|
604
|
+
return matchesType && (!q || haystack.includes(q));
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
function render() {
|
|
608
|
+
const nodes = visibleNodes();
|
|
609
|
+
const ids = new Set(nodes.map((node) => node.id));
|
|
610
|
+
const edges = graph.edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to));
|
|
611
|
+
const width = svg.clientWidth || 900;
|
|
612
|
+
const height = svg.clientHeight || 620;
|
|
613
|
+
const radius = Math.max(120, Math.min(width, height) * 0.34);
|
|
614
|
+
const cx = width / 2;
|
|
615
|
+
const cy = height / 2;
|
|
616
|
+
const positioned = nodes.map((node, index) => {
|
|
617
|
+
const angle = nodes.length <= 1 ? 0 : (Math.PI * 2 * index) / nodes.length - Math.PI / 2;
|
|
618
|
+
return { ...node, x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius };
|
|
619
|
+
});
|
|
620
|
+
const byId = new Map(positioned.map((node) => [node.id, node]));
|
|
621
|
+
svg.replaceChildren();
|
|
622
|
+
for (const edge of edges) {
|
|
623
|
+
const from = byId.get(edge.from);
|
|
624
|
+
const to = byId.get(edge.to);
|
|
625
|
+
if (!from || !to) continue;
|
|
626
|
+
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
|
627
|
+
line.setAttribute("class", "edge");
|
|
628
|
+
line.setAttribute("x1", from.x);
|
|
629
|
+
line.setAttribute("y1", from.y);
|
|
630
|
+
line.setAttribute("x2", to.x);
|
|
631
|
+
line.setAttribute("y2", to.y);
|
|
632
|
+
svg.appendChild(line);
|
|
633
|
+
}
|
|
634
|
+
for (const node of positioned) {
|
|
635
|
+
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
636
|
+
group.setAttribute("class", "node " + node.type.toLowerCase());
|
|
637
|
+
group.addEventListener("click", () => showDetails(node));
|
|
638
|
+
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
|
639
|
+
circle.setAttribute("cx", node.x);
|
|
640
|
+
circle.setAttribute("cy", node.y);
|
|
641
|
+
circle.setAttribute("r", "18");
|
|
642
|
+
const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
643
|
+
label.setAttribute("x", node.x + 24);
|
|
644
|
+
label.setAttribute("y", node.y + 4);
|
|
645
|
+
label.textContent = node.title;
|
|
646
|
+
group.append(circle, label);
|
|
647
|
+
svg.appendChild(group);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
function showDetails(node) {
|
|
651
|
+
const outgoing = graph.edges.filter((edge) => edge.from === node.id);
|
|
652
|
+
const incoming = graph.edges.filter((edge) => edge.to === node.id);
|
|
653
|
+
details.innerHTML = [
|
|
654
|
+
"<h2>" + escapeHtml(node.title) + "</h2>",
|
|
655
|
+
"<p><strong>Path:</strong> " + escapeHtml(node.path) + "</p>",
|
|
656
|
+
"<p><strong>Type:</strong> " + escapeHtml(node.type) + "</p>",
|
|
657
|
+
"<p><strong>Tags:</strong> " + escapeHtml(node.tags.join(", ")) + "</p>",
|
|
658
|
+
"<h3>Outgoing</h3>",
|
|
659
|
+
listEdges(outgoing, "to"),
|
|
660
|
+
"<h3>Backlinks</h3>",
|
|
661
|
+
listEdges(incoming, "from")
|
|
662
|
+
].join("");
|
|
663
|
+
}
|
|
664
|
+
function listEdges(edges, key) {
|
|
665
|
+
if (edges.length === 0) return '<p class="muted">None</p>';
|
|
666
|
+
return "<ul>" + edges.map((edge) => "<li>" + escapeHtml(edge[key]) + " <span class=\\"muted\\">" + edge.kind + "</span></li>").join("") + "</ul>";
|
|
667
|
+
}
|
|
668
|
+
function escapeHtml(value) {
|
|
669
|
+
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
|
670
|
+
}
|
|
671
|
+
search.addEventListener("input", render);
|
|
672
|
+
type.addEventListener("change", render);
|
|
673
|
+
addEventListener("resize", render);
|
|
674
|
+
render();
|
|
675
|
+
</script>
|
|
676
|
+
</body>
|
|
677
|
+
</html>
|
|
678
|
+
`;
|
|
679
|
+
}
|
|
680
|
+
function firstHeading(markdown) {
|
|
681
|
+
return /^#\s+(.+?)\s*$/m.exec(markdown)?.[1];
|
|
682
|
+
}
|
|
683
|
+
function stripFrontmatterFence(markdown) {
|
|
684
|
+
if (!markdown.startsWith("---")) {
|
|
685
|
+
return markdown;
|
|
686
|
+
}
|
|
687
|
+
const end = markdown.indexOf("\n---", 3);
|
|
688
|
+
return end === -1 ? markdown : markdown.slice(end + "\n---".length);
|
|
689
|
+
}
|
|
690
|
+
function stringValue2(value) {
|
|
691
|
+
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
|
692
|
+
}
|
|
693
|
+
function stringArrayValue2(value) {
|
|
694
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/lint/index.ts
|
|
698
|
+
import { createHash as createHash2 } from "crypto";
|
|
699
|
+
import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
|
|
700
|
+
import path6 from "path";
|
|
701
|
+
|
|
702
|
+
// src/source/index.ts
|
|
703
|
+
import { createHash } from "crypto";
|
|
704
|
+
import { access, appendFile, mkdir as mkdir2, readFile as readFile3, rm, stat, writeFile as writeFile2 } from "fs/promises";
|
|
705
|
+
import path5 from "path";
|
|
706
|
+
var MANIFEST_INVALID = "MANIFEST_INVALID";
|
|
707
|
+
var SOURCE_REGISTRATION_FAILED = "SOURCE_REGISTRATION_FAILED";
|
|
708
|
+
var SOURCE_INPUT_NOT_FOUND = "SOURCE_INPUT_NOT_FOUND";
|
|
709
|
+
var SOURCE_INPUT_UNSUPPORTED = "SOURCE_INPUT_UNSUPPORTED";
|
|
710
|
+
var SOURCE_NOT_REGISTERED = "SOURCE_NOT_REGISTERED";
|
|
711
|
+
var SourceManagementError = class extends Error {
|
|
712
|
+
constructor(message, code, workspaceRoot) {
|
|
713
|
+
super(message);
|
|
714
|
+
this.code = code;
|
|
715
|
+
this.workspaceRoot = workspaceRoot;
|
|
716
|
+
this.name = "SourceManagementError";
|
|
717
|
+
}
|
|
718
|
+
code;
|
|
719
|
+
workspaceRoot;
|
|
720
|
+
};
|
|
721
|
+
async function addSource(options) {
|
|
722
|
+
const workspaceRoot = path5.resolve(options.workspaceRoot);
|
|
723
|
+
const config = await loadWorkspaceConfig(workspaceRoot);
|
|
724
|
+
const manifest = await readSourceManifest(workspaceRoot, config);
|
|
725
|
+
if (manifest.issues.length > 0) {
|
|
726
|
+
throw new SourceManagementError("Source manifest contains invalid rows.", MANIFEST_INVALID);
|
|
727
|
+
}
|
|
728
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
729
|
+
const url = parseHttpUrl(options.input);
|
|
730
|
+
if (url !== void 0) {
|
|
731
|
+
return addUrlSource({
|
|
732
|
+
workspaceRoot,
|
|
733
|
+
config,
|
|
734
|
+
input: options.input,
|
|
735
|
+
url,
|
|
736
|
+
now,
|
|
737
|
+
options,
|
|
738
|
+
manifest
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
return addFileSource({ workspaceRoot, config, input: options.input, now, options, manifest });
|
|
742
|
+
}
|
|
743
|
+
async function listSources(options) {
|
|
744
|
+
const workspaceRoot = path5.resolve(options.workspaceRoot);
|
|
745
|
+
const config = await loadWorkspaceConfig(workspaceRoot);
|
|
746
|
+
const manifest = await readSourceManifest(workspaceRoot, config);
|
|
747
|
+
if (manifest.issues.length > 0) {
|
|
748
|
+
throw new SourceManagementError("Source manifest contains invalid rows.", MANIFEST_INVALID);
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
workspaceRoot,
|
|
752
|
+
sources: manifest.entries
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
async function createIngestPlan(options) {
|
|
756
|
+
const workspaceRoot = path5.resolve(options.workspaceRoot);
|
|
757
|
+
const config = await loadWorkspaceConfig(workspaceRoot);
|
|
758
|
+
const manifest = await readSourceManifest(workspaceRoot, config);
|
|
759
|
+
if (manifest.issues.length > 0) {
|
|
760
|
+
throw new SourceManagementError("Source manifest contains invalid rows.", MANIFEST_INVALID);
|
|
761
|
+
}
|
|
762
|
+
const source = findRegisteredSource(manifest.entries, options.source);
|
|
763
|
+
if (source === void 0) {
|
|
764
|
+
throw new SourceManagementError(
|
|
765
|
+
`Source is not registered: ${options.source}`,
|
|
766
|
+
SOURCE_NOT_REGISTERED,
|
|
767
|
+
workspaceRoot
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
const scanResult = await scanConcepts(workspaceRoot, config);
|
|
771
|
+
const sourceTokens = tokenizeSourceMetadata(source);
|
|
772
|
+
const candidateConcepts = scanResult.concepts.filter((concept) => !concept.id.startsWith("references/")).map((concept) => {
|
|
773
|
+
const conceptTokens = tokenize([
|
|
774
|
+
concept.id,
|
|
775
|
+
concept.title ?? "",
|
|
776
|
+
concept.type,
|
|
777
|
+
concept.tags.join(" ")
|
|
778
|
+
]);
|
|
779
|
+
const matches = [...sourceTokens].filter((token) => conceptTokens.has(token));
|
|
780
|
+
return {
|
|
781
|
+
concept,
|
|
782
|
+
matches
|
|
783
|
+
};
|
|
784
|
+
}).filter((candidate) => candidate.matches.length > 0).map(({ concept, matches }) => {
|
|
785
|
+
const candidate = {
|
|
786
|
+
id: concept.id,
|
|
787
|
+
path: concept.workspacePath,
|
|
788
|
+
type: concept.type,
|
|
789
|
+
score: matches.length,
|
|
790
|
+
reason: `metadata token match: ${matches.sort().join(", ")}`
|
|
791
|
+
};
|
|
792
|
+
if (concept.title !== void 0) {
|
|
793
|
+
candidate.title = concept.title;
|
|
794
|
+
}
|
|
795
|
+
return candidate;
|
|
796
|
+
}).sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)).slice(0, 10);
|
|
797
|
+
const recommendedReferencePath = source.reference_concept ?? `wiki/references/${safeSlug(referenceTitle(source)) || source.id}.md`;
|
|
798
|
+
return {
|
|
799
|
+
workspaceRoot,
|
|
800
|
+
source,
|
|
801
|
+
recommendedReferencePath,
|
|
802
|
+
candidateConcepts,
|
|
803
|
+
checklist: [
|
|
804
|
+
`Read the full registered source at ${source.path} before writing wiki content.`,
|
|
805
|
+
`Create or update exactly one reference document at ${recommendedReferencePath}.`,
|
|
806
|
+
"Update only affected topic, entity, project, decision, or question concept documents.",
|
|
807
|
+
"Preserve uncertainty and contradictions; do not invent claims or citations.",
|
|
808
|
+
"Run okfh lint --workspace <workspace> --json after wiki edits."
|
|
809
|
+
]
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
async function readSourceManifest(workspaceRootInput, config) {
|
|
813
|
+
const workspaceRoot = path5.resolve(workspaceRootInput);
|
|
814
|
+
const workspaceConfig = config ?? await loadWorkspaceConfig(workspaceRoot);
|
|
815
|
+
const manifestPath = path5.join(workspaceRoot, workspaceConfig.paths.manifest);
|
|
816
|
+
let source = "";
|
|
817
|
+
try {
|
|
818
|
+
source = await readFile3(manifestPath, "utf8");
|
|
819
|
+
} catch (error) {
|
|
820
|
+
if (errorCode2(error) !== "ENOENT") {
|
|
821
|
+
throw error;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const entries = [];
|
|
825
|
+
const issues = [];
|
|
826
|
+
const lines = source.split(/\r?\n/);
|
|
827
|
+
lines.forEach((line, index) => {
|
|
828
|
+
if (line.trim().length === 0) {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
let parsed;
|
|
832
|
+
try {
|
|
833
|
+
parsed = JSON.parse(line);
|
|
834
|
+
} catch (error) {
|
|
835
|
+
issues.push({
|
|
836
|
+
code: MANIFEST_INVALID,
|
|
837
|
+
path: workspaceConfig.paths.manifest,
|
|
838
|
+
line: index + 1,
|
|
839
|
+
message: error instanceof Error ? error.message : "Invalid manifest JSON row."
|
|
840
|
+
});
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const entry = parseManifestEntry(parsed, workspaceConfig);
|
|
844
|
+
if (entry.ok) {
|
|
845
|
+
entries.push(entry.entry);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
issues.push({
|
|
849
|
+
code: MANIFEST_INVALID,
|
|
850
|
+
path: workspaceConfig.paths.manifest,
|
|
851
|
+
line: index + 1,
|
|
852
|
+
message: entry.message
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
return { entries, issues };
|
|
856
|
+
}
|
|
857
|
+
async function addFileSource(context) {
|
|
858
|
+
const sourcePath = path5.resolve(context.input);
|
|
859
|
+
let sourceStat;
|
|
860
|
+
try {
|
|
861
|
+
sourceStat = await stat(sourcePath);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
if (errorCode2(error) === "ENOENT") {
|
|
864
|
+
throw new SourceManagementError(
|
|
865
|
+
`Source file does not exist: ${context.input}`,
|
|
866
|
+
SOURCE_INPUT_NOT_FOUND,
|
|
867
|
+
context.workspaceRoot
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
throw error;
|
|
871
|
+
}
|
|
872
|
+
if (!sourceStat.isFile()) {
|
|
873
|
+
throw new SourceManagementError(
|
|
874
|
+
"Source add supports ordinary files and URLs only.",
|
|
875
|
+
SOURCE_INPUT_UNSUPPORTED,
|
|
876
|
+
context.workspaceRoot
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
const contents = await readFile3(sourcePath);
|
|
880
|
+
const sha256 = sha256Hex(contents);
|
|
881
|
+
const existing = context.manifest.entries.find(
|
|
882
|
+
(entry2) => entry2.kind === "file" && entry2.sha256 === sha256
|
|
883
|
+
);
|
|
884
|
+
if (existing !== void 0) {
|
|
885
|
+
return {
|
|
886
|
+
workspaceRoot: context.workspaceRoot,
|
|
887
|
+
input: context.input,
|
|
888
|
+
action: "reused",
|
|
889
|
+
dryRun: context.options.dryRun === true,
|
|
890
|
+
source: existing
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
const original = path5.basename(sourcePath);
|
|
894
|
+
const rawPath = await nextRawSourcePath({
|
|
895
|
+
workspaceRoot: context.workspaceRoot,
|
|
896
|
+
config: context.config,
|
|
897
|
+
now: context.now,
|
|
898
|
+
original,
|
|
899
|
+
extension: path5.extname(original),
|
|
900
|
+
entries: context.manifest.entries
|
|
901
|
+
});
|
|
902
|
+
const entry = {
|
|
903
|
+
id: nextSourceId(context.manifest.entries, context.now),
|
|
904
|
+
kind: "file",
|
|
905
|
+
original,
|
|
906
|
+
path: rawPath,
|
|
907
|
+
sha256,
|
|
908
|
+
added_at: context.now.toISOString(),
|
|
909
|
+
status: "registered",
|
|
910
|
+
mime: mimeFromFilename(original),
|
|
911
|
+
title: titleFromFilename(original)
|
|
912
|
+
};
|
|
913
|
+
if (context.options.dryRun === true) {
|
|
914
|
+
return {
|
|
915
|
+
workspaceRoot: context.workspaceRoot,
|
|
916
|
+
input: context.input,
|
|
917
|
+
action: "planned",
|
|
918
|
+
dryRun: true,
|
|
919
|
+
source: entry
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
const absoluteRawPath = path5.join(context.workspaceRoot, entry.path);
|
|
923
|
+
await mkdir2(path5.dirname(absoluteRawPath), { recursive: true });
|
|
924
|
+
await writeFile2(absoluteRawPath, contents, { flag: "wx" });
|
|
925
|
+
try {
|
|
926
|
+
await appendManifestEntry(context.workspaceRoot, context.config, entry);
|
|
927
|
+
} catch (error) {
|
|
928
|
+
await rm(absoluteRawPath, { force: true });
|
|
929
|
+
throw new SourceManagementError(
|
|
930
|
+
error instanceof Error ? error.message : "Could not append source manifest entry.",
|
|
931
|
+
SOURCE_REGISTRATION_FAILED,
|
|
932
|
+
context.workspaceRoot
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
workspaceRoot: context.workspaceRoot,
|
|
937
|
+
input: context.input,
|
|
938
|
+
action: "registered",
|
|
939
|
+
dryRun: false,
|
|
940
|
+
source: entry
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
async function addUrlSource(context) {
|
|
944
|
+
const original = context.url.href;
|
|
945
|
+
const existing = context.manifest.entries.find(
|
|
946
|
+
(entry2) => entry2.kind === "url" && entry2.original === original
|
|
947
|
+
);
|
|
948
|
+
if (existing !== void 0) {
|
|
949
|
+
return {
|
|
950
|
+
workspaceRoot: context.workspaceRoot,
|
|
951
|
+
input: context.input,
|
|
952
|
+
action: "reused",
|
|
953
|
+
dryRun: context.options.dryRun === true,
|
|
954
|
+
source: existing
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
const metadata = urlMetadataContents(original, context.now);
|
|
958
|
+
const rawPath = await nextRawSourcePath({
|
|
959
|
+
workspaceRoot: context.workspaceRoot,
|
|
960
|
+
config: context.config,
|
|
961
|
+
now: context.now,
|
|
962
|
+
original: urlSlugSource(context.url),
|
|
963
|
+
extension: ".url.md",
|
|
964
|
+
entries: context.manifest.entries
|
|
965
|
+
});
|
|
966
|
+
const entry = {
|
|
967
|
+
id: nextSourceId(context.manifest.entries, context.now),
|
|
968
|
+
kind: "url",
|
|
969
|
+
original,
|
|
970
|
+
path: rawPath,
|
|
971
|
+
sha256: sha256Hex(metadata),
|
|
972
|
+
added_at: context.now.toISOString(),
|
|
973
|
+
status: "registered",
|
|
974
|
+
mime: "text/markdown",
|
|
975
|
+
title: titleFromUrl(context.url)
|
|
976
|
+
};
|
|
977
|
+
if (context.options.dryRun === true) {
|
|
978
|
+
return {
|
|
979
|
+
workspaceRoot: context.workspaceRoot,
|
|
980
|
+
input: context.input,
|
|
981
|
+
action: "planned",
|
|
982
|
+
dryRun: true,
|
|
983
|
+
source: entry
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
const absoluteRawPath = path5.join(context.workspaceRoot, entry.path);
|
|
987
|
+
await mkdir2(path5.dirname(absoluteRawPath), { recursive: true });
|
|
988
|
+
await writeFile2(absoluteRawPath, metadata, { flag: "wx" });
|
|
989
|
+
try {
|
|
990
|
+
await appendManifestEntry(context.workspaceRoot, context.config, entry);
|
|
991
|
+
} catch (error) {
|
|
992
|
+
await rm(absoluteRawPath, { force: true });
|
|
993
|
+
throw new SourceManagementError(
|
|
994
|
+
error instanceof Error ? error.message : "Could not append source manifest entry.",
|
|
995
|
+
SOURCE_REGISTRATION_FAILED,
|
|
996
|
+
context.workspaceRoot
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
return {
|
|
1000
|
+
workspaceRoot: context.workspaceRoot,
|
|
1001
|
+
input: context.input,
|
|
1002
|
+
action: "registered",
|
|
1003
|
+
dryRun: false,
|
|
1004
|
+
source: entry
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
async function appendManifestEntry(workspaceRoot, config, entry) {
|
|
1008
|
+
const manifestPath = path5.join(workspaceRoot, config.paths.manifest);
|
|
1009
|
+
await mkdir2(path5.dirname(manifestPath), { recursive: true });
|
|
1010
|
+
await appendFile(manifestPath, `${JSON.stringify(entry)}
|
|
1011
|
+
`, "utf8");
|
|
1012
|
+
}
|
|
1013
|
+
async function nextRawSourcePath(options) {
|
|
1014
|
+
const dateParts = sourceDateParts(options.now);
|
|
1015
|
+
const directory = `${options.config.paths.raw_sources}/${dateParts.year}/${dateParts.month}`;
|
|
1016
|
+
const extension = options.extension.length > 0 ? options.extension.toLowerCase() : "";
|
|
1017
|
+
const stem = extension.length > 0 && options.original.toLowerCase().endsWith(extension) ? options.original.slice(0, -extension.length) : options.original;
|
|
1018
|
+
const slug = safeSlug(stem) || "source";
|
|
1019
|
+
const registeredPaths = new Set(options.entries.map((entry) => entry.path));
|
|
1020
|
+
for (let suffix = 1; ; suffix += 1) {
|
|
1021
|
+
const suffixText = suffix === 1 ? "" : `-${suffix}`;
|
|
1022
|
+
const candidate = `${directory}/${slug}${suffixText}${extension}`;
|
|
1023
|
+
if (registeredPaths.has(candidate)) {
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
if (!await pathExists(path5.join(options.workspaceRoot, candidate))) {
|
|
1027
|
+
return candidate;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function parseManifestEntry(value, config) {
|
|
1032
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1033
|
+
return { ok: false, message: "Manifest row must be a JSON object." };
|
|
1034
|
+
}
|
|
1035
|
+
const row = value;
|
|
1036
|
+
const requiredStringFields = ["id", "kind", "original", "path", "sha256", "added_at", "status"];
|
|
1037
|
+
for (const field of requiredStringFields) {
|
|
1038
|
+
if (typeof row[field] !== "string" || row[field].trim().length === 0) {
|
|
1039
|
+
return { ok: false, message: `Manifest row is missing a non-empty ${field} field.` };
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (row.kind !== "file" && row.kind !== "url") {
|
|
1043
|
+
return { ok: false, message: "Manifest kind must be file or url." };
|
|
1044
|
+
}
|
|
1045
|
+
if (row.status !== "registered") {
|
|
1046
|
+
return { ok: false, message: "Manifest status must be registered." };
|
|
1047
|
+
}
|
|
1048
|
+
if (!/^src_\d{8}_\d{4}$/.test(String(row.id))) {
|
|
1049
|
+
return { ok: false, message: "Manifest source id must match src_YYYYMMDD_NNNN." };
|
|
1050
|
+
}
|
|
1051
|
+
if (!/^[a-f0-9]{64}$/.test(String(row.sha256))) {
|
|
1052
|
+
return { ok: false, message: "Manifest sha256 must be a lowercase hex digest." };
|
|
1053
|
+
}
|
|
1054
|
+
if (!isSafeRawSourcePath(String(row.path), config)) {
|
|
1055
|
+
return { ok: false, message: "Manifest path must be a safe raw source relative path." };
|
|
1056
|
+
}
|
|
1057
|
+
const entry = {
|
|
1058
|
+
id: String(row.id),
|
|
1059
|
+
kind: row.kind,
|
|
1060
|
+
original: String(row.original),
|
|
1061
|
+
path: toPosixPath(String(row.path)),
|
|
1062
|
+
sha256: String(row.sha256),
|
|
1063
|
+
added_at: String(row.added_at),
|
|
1064
|
+
status: "registered"
|
|
1065
|
+
};
|
|
1066
|
+
for (const optional of ["mime", "title", "reference_concept", "notes"]) {
|
|
1067
|
+
if (typeof row[optional] === "string" && row[optional].trim().length > 0) {
|
|
1068
|
+
entry[optional] = String(row[optional]);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return { ok: true, entry };
|
|
1072
|
+
}
|
|
1073
|
+
function isSafeRawSourcePath(input, config) {
|
|
1074
|
+
const rawPath = toPosixPath(input);
|
|
1075
|
+
if (rawPath.startsWith("/") || rawPath.includes("\\") || rawPath.includes("\0")) {
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
const segments = rawPath.split("/");
|
|
1079
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
1080
|
+
return false;
|
|
1081
|
+
}
|
|
1082
|
+
return rawPath === config.paths.raw_sources || rawPath.startsWith(`${config.paths.raw_sources}/`);
|
|
1083
|
+
}
|
|
1084
|
+
function parseHttpUrl(input) {
|
|
1085
|
+
try {
|
|
1086
|
+
const url = new URL(input);
|
|
1087
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url : void 0;
|
|
1088
|
+
} catch {
|
|
1089
|
+
return void 0;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
function findRegisteredSource(entries, sourceInput) {
|
|
1093
|
+
const normalizedInput = toPosixPath(sourceInput);
|
|
1094
|
+
return entries.find(
|
|
1095
|
+
(entry) => entry.id === sourceInput || entry.path === normalizedInput || path5.posix.basename(entry.path) === normalizedInput
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
function tokenizeSourceMetadata(source) {
|
|
1099
|
+
return tokenize([source.id, source.original, source.path, source.title ?? ""]);
|
|
1100
|
+
}
|
|
1101
|
+
function tokenize(values) {
|
|
1102
|
+
return new Set(
|
|
1103
|
+
values.join(" ").normalize("NFKD").toLowerCase().replace(/[\u0300-\u036f]/g, "").split(/[^\p{Letter}\p{Number}]+/u).filter((token) => token.length >= 3 && !metadataStopWords.has(token))
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
function referenceTitle(source) {
|
|
1107
|
+
if (source.title !== void 0) {
|
|
1108
|
+
return source.title;
|
|
1109
|
+
}
|
|
1110
|
+
if (source.kind === "file") {
|
|
1111
|
+
return titleFromFilename(source.original);
|
|
1112
|
+
}
|
|
1113
|
+
return titleFromUrl(new URL(source.original));
|
|
1114
|
+
}
|
|
1115
|
+
function sourceDateParts(now) {
|
|
1116
|
+
const [date] = now.toISOString().split("T");
|
|
1117
|
+
if (date === void 0) {
|
|
1118
|
+
throw new Error("Could not derive source date.");
|
|
1119
|
+
}
|
|
1120
|
+
const [year, month] = date.split("-");
|
|
1121
|
+
if (year === void 0 || month === void 0) {
|
|
1122
|
+
throw new Error("Could not derive source date parts.");
|
|
1123
|
+
}
|
|
1124
|
+
return { year, month, date: date.replace(/-/g, "") };
|
|
1125
|
+
}
|
|
1126
|
+
var metadataStopWords = /* @__PURE__ */ new Set([
|
|
1127
|
+
"raw",
|
|
1128
|
+
"sources",
|
|
1129
|
+
"source",
|
|
1130
|
+
"http",
|
|
1131
|
+
"https",
|
|
1132
|
+
"www",
|
|
1133
|
+
"com",
|
|
1134
|
+
"org",
|
|
1135
|
+
"net"
|
|
1136
|
+
]);
|
|
1137
|
+
function nextSourceId(entries, now) {
|
|
1138
|
+
const { date } = sourceDateParts(now);
|
|
1139
|
+
const maxSequence = entries.reduce((max, entry) => {
|
|
1140
|
+
const match = new RegExp(`^src_${date}_(\\d{4})$`).exec(entry.id);
|
|
1141
|
+
if (match?.[1] === void 0) {
|
|
1142
|
+
return max;
|
|
1143
|
+
}
|
|
1144
|
+
return Math.max(max, Number(match[1]));
|
|
1145
|
+
}, 0);
|
|
1146
|
+
return `src_${date}_${String(maxSequence + 1).padStart(4, "0")}`;
|
|
1147
|
+
}
|
|
1148
|
+
function safeSlug(input) {
|
|
1149
|
+
return input.normalize("NFKD").toLowerCase().replace(/[\u0300-\u036f]/g, "").replace(/[^\p{Letter}\p{Number}]+/gu, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 80).replace(/-+$/g, "");
|
|
1150
|
+
}
|
|
1151
|
+
function titleFromFilename(filename) {
|
|
1152
|
+
const extension = path5.extname(filename);
|
|
1153
|
+
const stem = extension.length > 0 ? filename.slice(0, -extension.length) : filename;
|
|
1154
|
+
return stem.trim().length > 0 ? stem : filename;
|
|
1155
|
+
}
|
|
1156
|
+
function urlSlugSource(url) {
|
|
1157
|
+
const pathname = url.pathname.split("/").filter(Boolean).join("-");
|
|
1158
|
+
return pathname.length > 0 ? `${url.hostname}-${pathname}` : url.hostname;
|
|
1159
|
+
}
|
|
1160
|
+
function titleFromUrl(url) {
|
|
1161
|
+
const pathname = url.pathname.split("/").filter(Boolean).at(-1);
|
|
1162
|
+
return pathname !== void 0 && pathname.length > 0 ? pathname : url.hostname;
|
|
1163
|
+
}
|
|
1164
|
+
function urlMetadataContents(url, now) {
|
|
1165
|
+
return `# URL Source
|
|
1166
|
+
|
|
1167
|
+
URL: ${url}
|
|
1168
|
+
Registered at: ${now.toISOString()}
|
|
1169
|
+
`;
|
|
1170
|
+
}
|
|
1171
|
+
function sha256Hex(contents) {
|
|
1172
|
+
return createHash("sha256").update(contents).digest("hex");
|
|
1173
|
+
}
|
|
1174
|
+
function mimeFromFilename(filename) {
|
|
1175
|
+
switch (path5.extname(filename).toLowerCase()) {
|
|
1176
|
+
case ".md":
|
|
1177
|
+
case ".markdown":
|
|
1178
|
+
return "text/markdown";
|
|
1179
|
+
case ".txt":
|
|
1180
|
+
return "text/plain";
|
|
1181
|
+
case ".pdf":
|
|
1182
|
+
return "application/pdf";
|
|
1183
|
+
case ".html":
|
|
1184
|
+
case ".htm":
|
|
1185
|
+
return "text/html";
|
|
1186
|
+
case ".json":
|
|
1187
|
+
return "application/json";
|
|
1188
|
+
default:
|
|
1189
|
+
return "application/octet-stream";
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
async function pathExists(input) {
|
|
1193
|
+
try {
|
|
1194
|
+
await access(input);
|
|
1195
|
+
return true;
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
if (errorCode2(error) === "ENOENT") {
|
|
1198
|
+
return false;
|
|
1199
|
+
}
|
|
1200
|
+
throw error;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
function errorCode2(error) {
|
|
1204
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
1205
|
+
return void 0;
|
|
1206
|
+
}
|
|
1207
|
+
const code = error.code;
|
|
1208
|
+
return typeof code === "string" ? code : void 0;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// src/lint/index.ts
|
|
1212
|
+
var OKF_MISSING_FRONTMATTER = "OKF_MISSING_FRONTMATTER";
|
|
1213
|
+
var OKF_INVALID_FRONTMATTER = "OKF_INVALID_FRONTMATTER";
|
|
1214
|
+
var OKF_MISSING_TYPE = "OKF_MISSING_TYPE";
|
|
1215
|
+
var RESERVED_FILE_HAS_CONCEPT_FRONTMATTER = "RESERVED_FILE_HAS_CONCEPT_FRONTMATTER";
|
|
1216
|
+
var LOG_INVALID_DATE_HEADING = "LOG_INVALID_DATE_HEADING";
|
|
1217
|
+
var SOURCE_HASH_DRIFT = "SOURCE_HASH_DRIFT";
|
|
1218
|
+
var SOURCE_MISSING = "SOURCE_MISSING";
|
|
1219
|
+
var REFERENCE_SOURCE_MISSING = "REFERENCE_SOURCE_MISSING";
|
|
1220
|
+
var BROKEN_LINK = "BROKEN_LINK";
|
|
1221
|
+
var MISSING_INDEX_ENTRY = "MISSING_INDEX_ENTRY";
|
|
1222
|
+
var MISSING_CITATIONS_SECTION = "MISSING_CITATIONS_SECTION";
|
|
1223
|
+
async function lintWorkspace(workspaceRoot) {
|
|
1224
|
+
const configResult = await readWorkspaceConfig(workspaceRoot);
|
|
1225
|
+
if (!configResult.ok) {
|
|
1226
|
+
return {
|
|
1227
|
+
ok: false,
|
|
1228
|
+
issues: configResult.issues.map((issue) => ({
|
|
1229
|
+
code: CONFIG_INVALID,
|
|
1230
|
+
severity: "error",
|
|
1231
|
+
message: issue.message,
|
|
1232
|
+
path: issue.path
|
|
1233
|
+
}))
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
try {
|
|
1237
|
+
const scanResult = await scanConcepts(workspaceRoot, configResult.config);
|
|
1238
|
+
const sourceManifest = await readSourceManifest(workspaceRoot, configResult.config);
|
|
1239
|
+
const sourceIssues = sourceManifest.issues.length === 0 ? [
|
|
1240
|
+
...await lintRegisteredSources(workspaceRoot, sourceManifest.entries),
|
|
1241
|
+
...lintReferenceSourceIds(scanResult.files, sourceManifest.entries),
|
|
1242
|
+
...await lintUnregisteredRawSources(
|
|
1243
|
+
workspaceRoot,
|
|
1244
|
+
configResult.config.paths.raw_sources,
|
|
1245
|
+
sourceManifest.entries
|
|
1246
|
+
)
|
|
1247
|
+
] : [];
|
|
1248
|
+
const issues = [
|
|
1249
|
+
...scanResult.files.flatMap((file) => lintMarkdownFile(file)),
|
|
1250
|
+
...lintWikiWarnings(scanResult.files),
|
|
1251
|
+
...sourceManifest.issues.map(
|
|
1252
|
+
(issue) => ({
|
|
1253
|
+
code: issue.code,
|
|
1254
|
+
severity: "error",
|
|
1255
|
+
path: issue.path,
|
|
1256
|
+
line: issue.line,
|
|
1257
|
+
message: issue.message
|
|
1258
|
+
})
|
|
1259
|
+
),
|
|
1260
|
+
...sourceIssues
|
|
1261
|
+
];
|
|
1262
|
+
return {
|
|
1263
|
+
ok: issues.every((issue) => issue.severity !== "error"),
|
|
1264
|
+
issues
|
|
1265
|
+
};
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
return {
|
|
1268
|
+
ok: false,
|
|
1269
|
+
issues: [
|
|
1270
|
+
{
|
|
1271
|
+
code: CONFIG_INVALID,
|
|
1272
|
+
severity: "error",
|
|
1273
|
+
path: configResult.config.okf.bundle_root,
|
|
1274
|
+
message: error instanceof Error ? error.message : "Could not scan OKF wiki."
|
|
1275
|
+
}
|
|
1276
|
+
]
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
function lintReferenceSourceIds(files, entries) {
|
|
1281
|
+
const sourceIds = new Set(entries.map((entry) => entry.id));
|
|
1282
|
+
return files.flatMap((file) => {
|
|
1283
|
+
if (file.isReserved || !file.workspacePath.startsWith("wiki/references/")) {
|
|
1284
|
+
return [];
|
|
1285
|
+
}
|
|
1286
|
+
if (!file.frontmatter.ok) {
|
|
1287
|
+
return [];
|
|
1288
|
+
}
|
|
1289
|
+
const sourceId = frontmatterSourceId(file.frontmatter.data);
|
|
1290
|
+
if (sourceId === void 0 || sourceIds.has(sourceId)) {
|
|
1291
|
+
return [];
|
|
1292
|
+
}
|
|
1293
|
+
return [
|
|
1294
|
+
{
|
|
1295
|
+
code: REFERENCE_SOURCE_MISSING,
|
|
1296
|
+
severity: "error",
|
|
1297
|
+
path: file.workspacePath,
|
|
1298
|
+
message: `Reference document points to an unregistered source id: ${sourceId}`
|
|
1299
|
+
}
|
|
1300
|
+
];
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
async function lintUnregisteredRawSources(workspaceRoot, rawSourcesPath, entries) {
|
|
1304
|
+
const rawRoot = path6.join(workspaceRoot, rawSourcesPath);
|
|
1305
|
+
const registeredPaths = new Set(entries.map((entry) => entry.path));
|
|
1306
|
+
const files = await scanRawSourceFiles(rawRoot);
|
|
1307
|
+
return files.flatMap((filePath) => {
|
|
1308
|
+
const workspacePath = path6.relative(workspaceRoot, filePath).split(path6.sep).join(path6.posix.sep);
|
|
1309
|
+
if (registeredPaths.has(workspacePath) || isIgnoredRawSourceFile(workspacePath)) {
|
|
1310
|
+
return [];
|
|
1311
|
+
}
|
|
1312
|
+
return [
|
|
1313
|
+
{
|
|
1314
|
+
code: "UNREGISTERED_RAW_SOURCE",
|
|
1315
|
+
severity: "warning",
|
|
1316
|
+
path: workspacePath,
|
|
1317
|
+
message: `Raw source file is not registered in the manifest: ${workspacePath}`
|
|
1318
|
+
}
|
|
1319
|
+
];
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
async function scanRawSourceFiles(root) {
|
|
1323
|
+
let entries;
|
|
1324
|
+
try {
|
|
1325
|
+
entries = await readdir2(root, { withFileTypes: true });
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
if (errorCode3(error) === "ENOENT") {
|
|
1328
|
+
return [];
|
|
1329
|
+
}
|
|
1330
|
+
throw error;
|
|
1331
|
+
}
|
|
1332
|
+
const nested = await Promise.all(
|
|
1333
|
+
entries.map(async (entry) => {
|
|
1334
|
+
const entryPath = path6.join(root, entry.name);
|
|
1335
|
+
if (entry.isDirectory()) {
|
|
1336
|
+
return scanRawSourceFiles(entryPath);
|
|
1337
|
+
}
|
|
1338
|
+
return entry.isFile() ? [entryPath] : [];
|
|
1339
|
+
})
|
|
1340
|
+
);
|
|
1341
|
+
return nested.flat();
|
|
1342
|
+
}
|
|
1343
|
+
function isIgnoredRawSourceFile(workspacePath) {
|
|
1344
|
+
return workspacePath === "raw/sources/README.md" || workspacePath.endsWith("/.gitkeep");
|
|
1345
|
+
}
|
|
1346
|
+
function frontmatterSourceId(frontmatter) {
|
|
1347
|
+
const okfh = frontmatter.okfh;
|
|
1348
|
+
if (typeof okfh !== "object" || okfh === null || Array.isArray(okfh)) {
|
|
1349
|
+
return void 0;
|
|
1350
|
+
}
|
|
1351
|
+
const sourceId = okfh.source_id;
|
|
1352
|
+
return typeof sourceId === "string" && sourceId.trim().length > 0 ? sourceId : void 0;
|
|
1353
|
+
}
|
|
1354
|
+
async function lintRegisteredSources(workspaceRoot, entries) {
|
|
1355
|
+
const nested = await Promise.all(
|
|
1356
|
+
entries.map(async (entry) => {
|
|
1357
|
+
const absolutePath = path6.join(workspaceRoot, entry.path);
|
|
1358
|
+
let contents;
|
|
1359
|
+
try {
|
|
1360
|
+
contents = await readFile4(absolutePath);
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
if (errorCode3(error) === "ENOENT") {
|
|
1363
|
+
return [
|
|
1364
|
+
{
|
|
1365
|
+
code: SOURCE_MISSING,
|
|
1366
|
+
severity: "error",
|
|
1367
|
+
path: entry.path,
|
|
1368
|
+
message: `Registered source is missing: ${entry.path}`
|
|
1369
|
+
}
|
|
1370
|
+
];
|
|
1371
|
+
}
|
|
1372
|
+
throw error;
|
|
1373
|
+
}
|
|
1374
|
+
const actual = createHash2("sha256").update(contents).digest("hex");
|
|
1375
|
+
if (actual === entry.sha256) {
|
|
1376
|
+
return [];
|
|
1377
|
+
}
|
|
1378
|
+
return [
|
|
1379
|
+
{
|
|
1380
|
+
code: SOURCE_HASH_DRIFT,
|
|
1381
|
+
severity: "error",
|
|
1382
|
+
path: entry.path,
|
|
1383
|
+
message: `Registered source hash changed: ${entry.path}`
|
|
1384
|
+
}
|
|
1385
|
+
];
|
|
1386
|
+
})
|
|
1387
|
+
);
|
|
1388
|
+
return nested.flat();
|
|
1389
|
+
}
|
|
1390
|
+
function lintMarkdownFile(file) {
|
|
1391
|
+
const issues = [];
|
|
1392
|
+
if (file.frontmatter.ok && file.isReserved && hasFrontmatterData(file)) {
|
|
1393
|
+
issues.push({
|
|
1394
|
+
code: RESERVED_FILE_HAS_CONCEPT_FRONTMATTER,
|
|
1395
|
+
severity: "error",
|
|
1396
|
+
path: file.workspacePath,
|
|
1397
|
+
message: `${file.bundlePath} is reserved and must not define concept frontmatter.`
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
if (!file.frontmatter.ok && file.frontmatter.hasFrontmatter) {
|
|
1401
|
+
issues.push({
|
|
1402
|
+
code: OKF_INVALID_FRONTMATTER,
|
|
1403
|
+
severity: "error",
|
|
1404
|
+
path: file.workspacePath,
|
|
1405
|
+
message: file.frontmatter.message
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
if (!file.isReserved) {
|
|
1409
|
+
if (!file.frontmatter.ok && !file.frontmatter.hasFrontmatter) {
|
|
1410
|
+
issues.push({
|
|
1411
|
+
code: OKF_MISSING_FRONTMATTER,
|
|
1412
|
+
severity: "error",
|
|
1413
|
+
path: file.workspacePath,
|
|
1414
|
+
message: `${file.bundlePath} is missing YAML frontmatter.`
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
if (file.frontmatter.ok && !hasNonEmptyType(file.frontmatter.data.type)) {
|
|
1418
|
+
issues.push({
|
|
1419
|
+
code: OKF_MISSING_TYPE,
|
|
1420
|
+
severity: "error",
|
|
1421
|
+
path: file.workspacePath,
|
|
1422
|
+
message: `${file.bundlePath} is missing a non-empty type field.`
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
if (file.bundlePath.split("/").at(-1) === "log.md") {
|
|
1427
|
+
issues.push(...lintLogDateHeadings(file));
|
|
1428
|
+
}
|
|
1429
|
+
return issues;
|
|
1430
|
+
}
|
|
1431
|
+
function lintWikiWarnings(files) {
|
|
1432
|
+
const existingConceptIds = new Set(files.map((file) => file.conceptId));
|
|
1433
|
+
const indexedConceptIds = indexMentionedConceptIds(files);
|
|
1434
|
+
return [
|
|
1435
|
+
...lintBrokenLinks(files, existingConceptIds),
|
|
1436
|
+
...lintMissingIndexEntries(files, indexedConceptIds),
|
|
1437
|
+
...lintMissingCitationSections(files)
|
|
1438
|
+
];
|
|
1439
|
+
}
|
|
1440
|
+
function lintBrokenLinks(files, existingConceptIds) {
|
|
1441
|
+
return files.flatMap(
|
|
1442
|
+
(file) => parseMarkdownLinks(file.markdown).flatMap((link) => {
|
|
1443
|
+
const conceptId = resolveOkfLinkTarget(link.target, file.bundlePath);
|
|
1444
|
+
if (conceptId === void 0 || existingConceptIds.has(conceptId)) {
|
|
1445
|
+
return [];
|
|
1446
|
+
}
|
|
1447
|
+
return [
|
|
1448
|
+
{
|
|
1449
|
+
code: BROKEN_LINK,
|
|
1450
|
+
severity: "warning",
|
|
1451
|
+
path: file.workspacePath,
|
|
1452
|
+
line: link.line,
|
|
1453
|
+
message: `Markdown link target does not exist: ${link.target}`
|
|
1454
|
+
}
|
|
1455
|
+
];
|
|
1456
|
+
})
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
function lintMissingIndexEntries(files, indexedConceptIds) {
|
|
1460
|
+
return files.flatMap((file) => {
|
|
1461
|
+
if (file.isReserved || indexedConceptIds.has(file.conceptId)) {
|
|
1462
|
+
return [];
|
|
1463
|
+
}
|
|
1464
|
+
return [
|
|
1465
|
+
{
|
|
1466
|
+
code: MISSING_INDEX_ENTRY,
|
|
1467
|
+
severity: "warning",
|
|
1468
|
+
path: file.workspacePath,
|
|
1469
|
+
message: `Concept is not linked from a root or directory index: ${file.workspacePath}`
|
|
1470
|
+
}
|
|
1471
|
+
];
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
function lintMissingCitationSections(files) {
|
|
1475
|
+
return files.flatMap((file) => {
|
|
1476
|
+
if (file.isReserved || !file.frontmatter.ok) {
|
|
1477
|
+
return [];
|
|
1478
|
+
}
|
|
1479
|
+
const type = stringValue3(file.frontmatter.data.type)?.toLocaleLowerCase();
|
|
1480
|
+
if (type === void 0 || !(/* @__PURE__ */ new Set(["topic", "entity", "project", "decision"])).has(type) || hasOkfhSources(file.frontmatter.data) || /^#\s+Citations\s*$/im.test(file.frontmatter.body)) {
|
|
1481
|
+
return [];
|
|
1482
|
+
}
|
|
1483
|
+
return [
|
|
1484
|
+
{
|
|
1485
|
+
code: MISSING_CITATIONS_SECTION,
|
|
1486
|
+
severity: "warning",
|
|
1487
|
+
path: file.workspacePath,
|
|
1488
|
+
message: `${file.bundlePath} should include # Citations or okfh.sources.`
|
|
1489
|
+
}
|
|
1490
|
+
];
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
function indexMentionedConceptIds(files) {
|
|
1494
|
+
const indexed = /* @__PURE__ */ new Set();
|
|
1495
|
+
for (const file of files) {
|
|
1496
|
+
if (path6.posix.basename(file.bundlePath) !== "index.md") {
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
for (const link of parseMarkdownLinks(file.markdown)) {
|
|
1500
|
+
const conceptId = resolveOkfLinkTarget(link.target, file.bundlePath);
|
|
1501
|
+
if (conceptId !== void 0) {
|
|
1502
|
+
indexed.add(conceptId);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return indexed;
|
|
1507
|
+
}
|
|
1508
|
+
function lintLogDateHeadings(file) {
|
|
1509
|
+
return file.markdown.split(/\r?\n/).flatMap((line, index) => {
|
|
1510
|
+
const heading = /^(#{2,6})\s+(.+?)\s*$/.exec(line);
|
|
1511
|
+
if (heading === null) {
|
|
1512
|
+
return [];
|
|
1513
|
+
}
|
|
1514
|
+
const headingText = heading[2];
|
|
1515
|
+
if (headingText === void 0) {
|
|
1516
|
+
return [];
|
|
1517
|
+
}
|
|
1518
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(headingText)) {
|
|
1519
|
+
return [];
|
|
1520
|
+
}
|
|
1521
|
+
return [
|
|
1522
|
+
{
|
|
1523
|
+
code: LOG_INVALID_DATE_HEADING,
|
|
1524
|
+
severity: "error",
|
|
1525
|
+
path: file.workspacePath,
|
|
1526
|
+
line: index + 1,
|
|
1527
|
+
message: `Log heading must be YYYY-MM-DD: ${headingText}`
|
|
1528
|
+
}
|
|
1529
|
+
];
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
function hasFrontmatterData(file) {
|
|
1533
|
+
return file.frontmatter.ok && Object.keys(file.frontmatter.data).length > 0;
|
|
1534
|
+
}
|
|
1535
|
+
function hasNonEmptyType(value) {
|
|
1536
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
1537
|
+
}
|
|
1538
|
+
function stringValue3(value) {
|
|
1539
|
+
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
|
1540
|
+
}
|
|
1541
|
+
function hasOkfhSources(frontmatter) {
|
|
1542
|
+
const okfh = frontmatter.okfh;
|
|
1543
|
+
if (typeof okfh !== "object" || okfh === null || Array.isArray(okfh)) {
|
|
1544
|
+
return false;
|
|
1545
|
+
}
|
|
1546
|
+
const sources = okfh.sources;
|
|
1547
|
+
return Array.isArray(sources) && sources.length > 0;
|
|
1548
|
+
}
|
|
1549
|
+
function errorCode3(error) {
|
|
1550
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
1551
|
+
return void 0;
|
|
1552
|
+
}
|
|
1553
|
+
const code = error.code;
|
|
1554
|
+
return typeof code === "string" ? code : void 0;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// src/read/index.ts
|
|
1558
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
1559
|
+
import path7 from "path";
|
|
1560
|
+
import { TextDecoder } from "util";
|
|
1561
|
+
var INVALID_TARGET = "INVALID_TARGET";
|
|
1562
|
+
var TARGET_NOT_FOUND = "TARGET_NOT_FOUND";
|
|
1563
|
+
var AMBIGUOUS_SECTION = "AMBIGUOUS_SECTION";
|
|
1564
|
+
var READ_LIMIT_EXCEEDED = "READ_LIMIT_EXCEEDED";
|
|
1565
|
+
var NON_MARKDOWN_TARGET = "NON_MARKDOWN_TARGET";
|
|
1566
|
+
var NON_UTF8_TARGET = "NON_UTF8_TARGET";
|
|
1567
|
+
var ReadWorkspaceError = class extends Error {
|
|
1568
|
+
constructor(message, code, details = {}) {
|
|
1569
|
+
super(message);
|
|
1570
|
+
this.code = code;
|
|
1571
|
+
this.details = details;
|
|
1572
|
+
this.name = "ReadWorkspaceError";
|
|
1573
|
+
}
|
|
1574
|
+
code;
|
|
1575
|
+
details;
|
|
1576
|
+
};
|
|
1577
|
+
var defaultReadPreviewChars = 12e3;
|
|
1578
|
+
var maxFullReadChars = 1e5;
|
|
1579
|
+
async function readWorkspaceDocument(options) {
|
|
1580
|
+
const workspaceRoot = path7.resolve(options.workspaceRoot);
|
|
1581
|
+
const config = await loadWorkspaceConfig(workspaceRoot);
|
|
1582
|
+
const [scanResult, sourceManifest] = await Promise.all([
|
|
1583
|
+
scanConcepts(workspaceRoot, config),
|
|
1584
|
+
readSourceManifest(workspaceRoot, config)
|
|
1585
|
+
]);
|
|
1586
|
+
const file = resolveReadTarget(scanResult.files, options.target);
|
|
1587
|
+
await assertUtf8Target(file);
|
|
1588
|
+
const sourceEntries = new Map(sourceManifest.entries.map((entry) => [entry.id, entry]));
|
|
1589
|
+
const conceptIds = new Set(
|
|
1590
|
+
scanResult.files.filter((item) => !item.isReserved).map((item) => item.conceptId)
|
|
1591
|
+
);
|
|
1592
|
+
const body = markdownBody(file);
|
|
1593
|
+
const sections = parseSections(body);
|
|
1594
|
+
const citationRange = findCitationsRange(sections, body);
|
|
1595
|
+
const links = parseBodyLinks(file, body, conceptIds, citationRange);
|
|
1596
|
+
const { citations, citationIssues } = parseCitations(
|
|
1597
|
+
file,
|
|
1598
|
+
body,
|
|
1599
|
+
conceptIds,
|
|
1600
|
+
sourceEntries,
|
|
1601
|
+
citationRange
|
|
1602
|
+
);
|
|
1603
|
+
const source = file.frontmatter.ok && isReferenceDocument(file) ? sourceFromFrontmatter(file.frontmatter.data, sourceEntries) : void 0;
|
|
1604
|
+
const result = {
|
|
1605
|
+
workspaceRoot,
|
|
1606
|
+
target: {
|
|
1607
|
+
input: options.target,
|
|
1608
|
+
conceptId: file.conceptId,
|
|
1609
|
+
path: file.workspacePath,
|
|
1610
|
+
bundlePath: file.bundlePath,
|
|
1611
|
+
reserved: file.isReserved
|
|
1612
|
+
},
|
|
1613
|
+
frontmatter: renderFrontmatter(file),
|
|
1614
|
+
metadata: renderMetadata(file),
|
|
1615
|
+
outline: sections,
|
|
1616
|
+
availableSections: sections,
|
|
1617
|
+
links,
|
|
1618
|
+
citations,
|
|
1619
|
+
citationIssues,
|
|
1620
|
+
content: selectContent(body, sections, options),
|
|
1621
|
+
warnings: file.frontmatter.ok ? [] : [
|
|
1622
|
+
{
|
|
1623
|
+
code: "FRONTMATTER_DEGRADED",
|
|
1624
|
+
path: file.workspacePath,
|
|
1625
|
+
message: file.frontmatter.message
|
|
1626
|
+
}
|
|
1627
|
+
]
|
|
1628
|
+
};
|
|
1629
|
+
if (source !== void 0) {
|
|
1630
|
+
result.source = source;
|
|
1631
|
+
}
|
|
1632
|
+
if (file.bundlePath === "index.md") {
|
|
1633
|
+
result.indexLinks = parseIndexLinks(file, body, conceptIds);
|
|
1634
|
+
}
|
|
1635
|
+
if (path7.posix.basename(file.bundlePath) === "log.md") {
|
|
1636
|
+
result.logEntries = parseLogEntries(body);
|
|
1637
|
+
}
|
|
1638
|
+
return result;
|
|
1639
|
+
}
|
|
1640
|
+
async function assertUtf8Target(file) {
|
|
1641
|
+
if (file.markdown.includes("\uFFFD") || file.frontmatter.ok && file.frontmatter.body.includes("\uFFFD")) {
|
|
1642
|
+
throw new ReadWorkspaceError("Read target is not valid UTF-8 markdown.", NON_UTF8_TARGET, {
|
|
1643
|
+
path: file.workspacePath
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
const bytes = await readFile5(file.absolutePath);
|
|
1647
|
+
try {
|
|
1648
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1649
|
+
} catch {
|
|
1650
|
+
throw new ReadWorkspaceError("Read target is not valid UTF-8 markdown.", NON_UTF8_TARGET, {
|
|
1651
|
+
path: file.workspacePath
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
function resolveReadTarget(files, targetInput) {
|
|
1656
|
+
const target = targetInput.trim();
|
|
1657
|
+
if (target.length === 0 || target.includes("\\")) {
|
|
1658
|
+
throw new ReadWorkspaceError("Read target must be a non-empty OKF path.", INVALID_TARGET);
|
|
1659
|
+
}
|
|
1660
|
+
if (/\.[^./]+$/.test(target) && !target.endsWith(".md")) {
|
|
1661
|
+
throw new ReadWorkspaceError("Read target must be a markdown document.", NON_MARKDOWN_TARGET, {
|
|
1662
|
+
target
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
const candidates = targetAliases(target);
|
|
1666
|
+
const file = files.find(
|
|
1667
|
+
(candidate) => candidates.some(
|
|
1668
|
+
(alias) => candidate.conceptId === alias || candidate.workspacePath === alias || candidate.bundlePath === alias || `/${candidate.bundlePath}` === alias
|
|
1669
|
+
)
|
|
1670
|
+
);
|
|
1671
|
+
if (file === void 0) {
|
|
1672
|
+
throw new ReadWorkspaceError(
|
|
1673
|
+
"No OKF concept document matched the read target.",
|
|
1674
|
+
TARGET_NOT_FOUND,
|
|
1675
|
+
{
|
|
1676
|
+
target
|
|
1677
|
+
}
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
return file;
|
|
1681
|
+
}
|
|
1682
|
+
function targetAliases(target) {
|
|
1683
|
+
if (target === "index" || target === "wiki/index.md") {
|
|
1684
|
+
return ["index", "wiki/index.md", "index.md", "/index.md"];
|
|
1685
|
+
}
|
|
1686
|
+
if (target === "log" || target === "wiki/log.md") {
|
|
1687
|
+
return ["log", "wiki/log.md", "log.md", "/log.md"];
|
|
1688
|
+
}
|
|
1689
|
+
const withoutWiki = target.startsWith("wiki/") ? target.slice("wiki/".length) : target;
|
|
1690
|
+
const withoutSlash = withoutWiki.startsWith("/") ? withoutWiki.slice(1) : withoutWiki;
|
|
1691
|
+
const withExtension = withoutSlash.endsWith(".md") ? withoutSlash : `${withoutSlash}.md`;
|
|
1692
|
+
const conceptId = withExtension.slice(0, -".md".length);
|
|
1693
|
+
return [
|
|
1694
|
+
target,
|
|
1695
|
+
withoutSlash,
|
|
1696
|
+
withExtension,
|
|
1697
|
+
conceptId,
|
|
1698
|
+
`wiki/${withExtension}`,
|
|
1699
|
+
`/${withExtension}`
|
|
1700
|
+
];
|
|
1701
|
+
}
|
|
1702
|
+
function selectContent(body, sections, options) {
|
|
1703
|
+
const contentLength = body.length;
|
|
1704
|
+
if (options.full === true) {
|
|
1705
|
+
if (contentLength > maxFullReadChars) {
|
|
1706
|
+
throw new ReadWorkspaceError(
|
|
1707
|
+
"Full read exceeds the current hard cap. Use section or range reads.",
|
|
1708
|
+
READ_LIMIT_EXCEEDED,
|
|
1709
|
+
{ contentLength, maxFullReadChars }
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
return contentForRange(body, 0, body.length, "full");
|
|
1713
|
+
}
|
|
1714
|
+
if (options.sectionId !== void 0 || options.section !== void 0) {
|
|
1715
|
+
const section = resolveSection(sections, options);
|
|
1716
|
+
return contentForRange(body, section.startOffset, section.endOffset, "section");
|
|
1717
|
+
}
|
|
1718
|
+
if (options.offset !== void 0 || options.limit !== void 0) {
|
|
1719
|
+
const startOffset = Math.max(0, Math.trunc(options.offset ?? 0));
|
|
1720
|
+
const length = Math.max(0, Math.trunc(options.limit ?? defaultReadPreviewChars));
|
|
1721
|
+
return contentForRange(body, startOffset, Math.min(body.length, startOffset + length), "range");
|
|
1722
|
+
}
|
|
1723
|
+
return contentForRange(body, 0, Math.min(body.length, defaultReadPreviewChars), "preview");
|
|
1724
|
+
}
|
|
1725
|
+
function resolveSection(sections, options) {
|
|
1726
|
+
if (options.sectionId !== void 0) {
|
|
1727
|
+
const section = sections.find((candidate) => candidate.sectionId === options.sectionId);
|
|
1728
|
+
if (section === void 0) {
|
|
1729
|
+
throw new ReadWorkspaceError(
|
|
1730
|
+
"No section matched the requested section id.",
|
|
1731
|
+
TARGET_NOT_FOUND,
|
|
1732
|
+
{
|
|
1733
|
+
sectionId: options.sectionId
|
|
1734
|
+
}
|
|
1735
|
+
);
|
|
1736
|
+
}
|
|
1737
|
+
return section;
|
|
1738
|
+
}
|
|
1739
|
+
const matches = sections.filter(
|
|
1740
|
+
(section) => section.heading.toLocaleLowerCase() === options.section?.toLocaleLowerCase()
|
|
1741
|
+
);
|
|
1742
|
+
if (matches.length === 1) {
|
|
1743
|
+
return matches[0];
|
|
1744
|
+
}
|
|
1745
|
+
if (matches.length > 1) {
|
|
1746
|
+
throw new ReadWorkspaceError(
|
|
1747
|
+
"Multiple sections matched the requested heading.",
|
|
1748
|
+
AMBIGUOUS_SECTION,
|
|
1749
|
+
{
|
|
1750
|
+
section: options.section,
|
|
1751
|
+
candidates: matches.map((section) => ({
|
|
1752
|
+
sectionId: section.sectionId,
|
|
1753
|
+
headingPath: section.headingPath
|
|
1754
|
+
}))
|
|
1755
|
+
}
|
|
1756
|
+
);
|
|
1757
|
+
}
|
|
1758
|
+
throw new ReadWorkspaceError("No section matched the requested heading.", TARGET_NOT_FOUND, {
|
|
1759
|
+
section: options.section
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
function contentForRange(body, startOffset, endOffset, mode) {
|
|
1763
|
+
const text = body.slice(startOffset, endOffset);
|
|
1764
|
+
return {
|
|
1765
|
+
mode,
|
|
1766
|
+
text,
|
|
1767
|
+
startOffset,
|
|
1768
|
+
endOffset,
|
|
1769
|
+
contentLength: body.length,
|
|
1770
|
+
returnedChars: text.length,
|
|
1771
|
+
truncated: endOffset < body.length
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
function parseSections(body) {
|
|
1775
|
+
const headings = [];
|
|
1776
|
+
const slugCounts = /* @__PURE__ */ new Map();
|
|
1777
|
+
const stack = [];
|
|
1778
|
+
const headingPattern = /^(#{1,6})\s+(.+?)\s*$/gm;
|
|
1779
|
+
let match = headingPattern.exec(body);
|
|
1780
|
+
while (match !== null) {
|
|
1781
|
+
const marker = match[1];
|
|
1782
|
+
const heading = match[2];
|
|
1783
|
+
if (marker === void 0 || heading === void 0) {
|
|
1784
|
+
continue;
|
|
1785
|
+
}
|
|
1786
|
+
const level = marker.length;
|
|
1787
|
+
while (stack.length > 0 && (stack.at(-1)?.level ?? 0) >= level) {
|
|
1788
|
+
stack.pop();
|
|
1789
|
+
}
|
|
1790
|
+
stack.push({ level, heading });
|
|
1791
|
+
const baseSlug = slugify(stack.map((item) => item.heading).join(" "));
|
|
1792
|
+
const count = slugCounts.get(baseSlug) ?? 0;
|
|
1793
|
+
slugCounts.set(baseSlug, count + 1);
|
|
1794
|
+
const sectionId = count === 0 ? baseSlug : `${baseSlug}-${count + 1}`;
|
|
1795
|
+
headings.push({
|
|
1796
|
+
sectionId,
|
|
1797
|
+
headingPath: stack.map((item) => item.heading),
|
|
1798
|
+
heading,
|
|
1799
|
+
level,
|
|
1800
|
+
startOffset: match.index,
|
|
1801
|
+
endOffset: body.length,
|
|
1802
|
+
line: lineNumberAtOffset(body, match.index)
|
|
1803
|
+
});
|
|
1804
|
+
match = headingPattern.exec(body);
|
|
1805
|
+
}
|
|
1806
|
+
return headings.map((heading, index) => {
|
|
1807
|
+
const next = headings.slice(index + 1).find((candidate) => candidate.level <= heading.level);
|
|
1808
|
+
return {
|
|
1809
|
+
sectionId: heading.sectionId,
|
|
1810
|
+
headingPath: heading.headingPath,
|
|
1811
|
+
heading: heading.heading,
|
|
1812
|
+
level: heading.level,
|
|
1813
|
+
startOffset: heading.startOffset,
|
|
1814
|
+
endOffset: next?.startOffset ?? body.length
|
|
1815
|
+
};
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
function parseBodyLinks(file, body, conceptIds, citationRange) {
|
|
1819
|
+
return parseMarkdownLinks(body).filter((link) => !isLineInRange(link.line, citationRange)).flatMap((link) => {
|
|
1820
|
+
const conceptId = resolveOkfLinkTarget(link.target, file.bundlePath);
|
|
1821
|
+
if (conceptId === void 0) {
|
|
1822
|
+
return [];
|
|
1823
|
+
}
|
|
1824
|
+
const readLink = {
|
|
1825
|
+
text: link.text,
|
|
1826
|
+
target: link.target,
|
|
1827
|
+
exists: conceptIds.has(conceptId),
|
|
1828
|
+
line: link.line
|
|
1829
|
+
};
|
|
1830
|
+
if (conceptId !== void 0) {
|
|
1831
|
+
readLink.conceptId = conceptId;
|
|
1832
|
+
}
|
|
1833
|
+
return [readLink];
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
function parseCitations(file, body, conceptIds, sourceEntries, citationRange) {
|
|
1837
|
+
if (citationRange === void 0) {
|
|
1838
|
+
return { citations: [], citationIssues: [] };
|
|
1839
|
+
}
|
|
1840
|
+
const citationMarkdown = body.slice(citationRange.startOffset, citationRange.endOffset);
|
|
1841
|
+
const links = parseMarkdownLinks(citationMarkdown);
|
|
1842
|
+
const citations = [];
|
|
1843
|
+
const citationIssues = [];
|
|
1844
|
+
const linkedTargets = /* @__PURE__ */ new Set();
|
|
1845
|
+
for (const link of links) {
|
|
1846
|
+
linkedTargets.add(link.target);
|
|
1847
|
+
const conceptId = resolveOkfLinkTarget(link.target, file.bundlePath);
|
|
1848
|
+
const exists = conceptId !== void 0 && conceptIds.has(conceptId);
|
|
1849
|
+
const citation = {
|
|
1850
|
+
kind: "reference",
|
|
1851
|
+
target: link.target,
|
|
1852
|
+
exists,
|
|
1853
|
+
line: citationRange.startLine + link.line - 1
|
|
1854
|
+
};
|
|
1855
|
+
if (conceptId !== void 0) {
|
|
1856
|
+
citation.conceptId = conceptId;
|
|
1857
|
+
}
|
|
1858
|
+
citations.push(citation);
|
|
1859
|
+
if (!exists) {
|
|
1860
|
+
citationIssues.push({
|
|
1861
|
+
code: "BROKEN_CITATION_REFERENCE",
|
|
1862
|
+
line: citation.line,
|
|
1863
|
+
message: `Citation reference does not resolve: ${link.target}`
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
const bareReferenceTargets2 = [
|
|
1868
|
+
...citationMarkdown.matchAll(/(^|\s)(\/?(?:wiki\/)?references\/[^\s)]+\.md)\b/gm)
|
|
1869
|
+
];
|
|
1870
|
+
for (const match of bareReferenceTargets2) {
|
|
1871
|
+
const target = match[2];
|
|
1872
|
+
if (target === void 0 || linkedTargets.has(target)) {
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
const conceptId = resolveOkfLinkTarget(target, file.bundlePath);
|
|
1876
|
+
const exists = conceptId !== void 0 && conceptIds.has(conceptId);
|
|
1877
|
+
const citation = {
|
|
1878
|
+
kind: "reference",
|
|
1879
|
+
target,
|
|
1880
|
+
exists,
|
|
1881
|
+
line: citationRange.startLine + lineNumberAtOffset(citationMarkdown, match.index ?? 0) - 1
|
|
1882
|
+
};
|
|
1883
|
+
if (conceptId !== void 0) {
|
|
1884
|
+
citation.conceptId = conceptId;
|
|
1885
|
+
}
|
|
1886
|
+
citations.push(citation);
|
|
1887
|
+
if (!exists) {
|
|
1888
|
+
citationIssues.push({
|
|
1889
|
+
code: "BROKEN_CITATION_REFERENCE",
|
|
1890
|
+
line: citation.line,
|
|
1891
|
+
message: `Citation reference does not resolve: ${target}`
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
const citedSourceIds = [...citationMarkdown.matchAll(/\b(src_\d{8}_\d{4})\b/g)];
|
|
1896
|
+
for (const match of citedSourceIds) {
|
|
1897
|
+
const sourceId = match[1];
|
|
1898
|
+
if (sourceId === void 0) {
|
|
1899
|
+
continue;
|
|
1900
|
+
}
|
|
1901
|
+
const source = sourceEntries.get(sourceId);
|
|
1902
|
+
const citation = {
|
|
1903
|
+
kind: "source",
|
|
1904
|
+
sourceId,
|
|
1905
|
+
exists: source !== void 0,
|
|
1906
|
+
line: citationRange.startLine + lineNumberAtOffset(citationMarkdown, match.index ?? 0) - 1
|
|
1907
|
+
};
|
|
1908
|
+
if (source !== void 0) {
|
|
1909
|
+
citation.source = source;
|
|
1910
|
+
}
|
|
1911
|
+
citations.push(citation);
|
|
1912
|
+
if (source === void 0) {
|
|
1913
|
+
citationIssues.push({
|
|
1914
|
+
code: "BROKEN_CITATION_SOURCE",
|
|
1915
|
+
line: citation.line,
|
|
1916
|
+
message: `Citation source id is not registered: ${sourceId}`
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
return { citations, citationIssues };
|
|
1921
|
+
}
|
|
1922
|
+
function findCitationsRange(sections, body) {
|
|
1923
|
+
const section = sections.find(
|
|
1924
|
+
(candidate) => candidate.level === 1 && candidate.heading.trim().toLocaleLowerCase() === "citations"
|
|
1925
|
+
);
|
|
1926
|
+
if (section === void 0) {
|
|
1927
|
+
return void 0;
|
|
1928
|
+
}
|
|
1929
|
+
return {
|
|
1930
|
+
startOffset: section.startOffset,
|
|
1931
|
+
endOffset: section.endOffset,
|
|
1932
|
+
startLine: lineNumberAtOffset(body, section.startOffset),
|
|
1933
|
+
endLine: lineNumberAtOffset(body, section.endOffset)
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
function isLineInRange(line, range) {
|
|
1937
|
+
return range !== void 0 && line >= range.startLine && line <= range.endLine;
|
|
1938
|
+
}
|
|
1939
|
+
function parseIndexLinks(file, body, conceptIds) {
|
|
1940
|
+
return parseMarkdownLinks(body).map((link) => {
|
|
1941
|
+
const conceptId = resolveOkfLinkTarget(link.target, file.bundlePath);
|
|
1942
|
+
const indexLink = {
|
|
1943
|
+
title: link.text,
|
|
1944
|
+
target: link.target,
|
|
1945
|
+
exists: conceptId !== void 0 && conceptIds.has(conceptId)
|
|
1946
|
+
};
|
|
1947
|
+
return conceptId === void 0 ? indexLink : { ...indexLink, conceptId };
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
function parseLogEntries(body) {
|
|
1951
|
+
const lines = body.split(/\r?\n/);
|
|
1952
|
+
const entries = [];
|
|
1953
|
+
let currentDate;
|
|
1954
|
+
lines.forEach((line, index) => {
|
|
1955
|
+
const heading = /^##\s+(\d{4}-\d{2}-\d{2})\s*$/.exec(line);
|
|
1956
|
+
if (heading?.[1] !== void 0) {
|
|
1957
|
+
currentDate = heading[1];
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
if (currentDate !== void 0 && line.trim().startsWith("- ")) {
|
|
1961
|
+
entries.push({ date: currentDate, line: index + 1, text: line.trim().slice(2) });
|
|
1962
|
+
}
|
|
1963
|
+
});
|
|
1964
|
+
return entries;
|
|
1965
|
+
}
|
|
1966
|
+
function renderFrontmatter(file) {
|
|
1967
|
+
if (file.frontmatter.ok) {
|
|
1968
|
+
return {
|
|
1969
|
+
ok: true,
|
|
1970
|
+
data: file.frontmatter.data
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
return {
|
|
1974
|
+
ok: false,
|
|
1975
|
+
error: file.frontmatter.error,
|
|
1976
|
+
message: file.frontmatter.message
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
function renderMetadata(file) {
|
|
1980
|
+
const title = file.frontmatter.ok ? stringValue4(file.frontmatter.data.title) ?? firstHeading2(file.markdown) ?? file.conceptId : firstHeading2(file.markdown) ?? file.conceptId;
|
|
1981
|
+
const type = file.frontmatter.ok ? stringValue4(file.frontmatter.data.type) ?? "Reserved" : "Unknown";
|
|
1982
|
+
const metadata = {
|
|
1983
|
+
title,
|
|
1984
|
+
type,
|
|
1985
|
+
tags: file.frontmatter.ok ? stringArrayValue3(file.frontmatter.data.tags) : []
|
|
1986
|
+
};
|
|
1987
|
+
if (file.frontmatter.ok) {
|
|
1988
|
+
const description = stringValue4(file.frontmatter.data.description);
|
|
1989
|
+
const timestamp = stringValue4(file.frontmatter.data.timestamp);
|
|
1990
|
+
if (description !== void 0) {
|
|
1991
|
+
metadata.description = description;
|
|
1992
|
+
}
|
|
1993
|
+
if (timestamp !== void 0) {
|
|
1994
|
+
metadata.timestamp = timestamp;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
return metadata;
|
|
1998
|
+
}
|
|
1999
|
+
function sourceFromFrontmatter(frontmatter, sourceEntries) {
|
|
2000
|
+
const okfh = frontmatter.okfh;
|
|
2001
|
+
if (typeof okfh !== "object" || okfh === null || Array.isArray(okfh)) {
|
|
2002
|
+
return void 0;
|
|
2003
|
+
}
|
|
2004
|
+
const sourceId = okfh.source_id;
|
|
2005
|
+
return typeof sourceId === "string" ? sourceEntries.get(sourceId) : void 0;
|
|
2006
|
+
}
|
|
2007
|
+
function isReferenceDocument(file) {
|
|
2008
|
+
return file.workspacePath.startsWith("wiki/references/");
|
|
2009
|
+
}
|
|
2010
|
+
function markdownBody(file) {
|
|
2011
|
+
if (file.frontmatter.ok) {
|
|
2012
|
+
return file.frontmatter.body;
|
|
2013
|
+
}
|
|
2014
|
+
return stripFrontmatterFence2(file.markdown);
|
|
2015
|
+
}
|
|
2016
|
+
function stripFrontmatterFence2(markdown) {
|
|
2017
|
+
if (!markdown.startsWith("---")) {
|
|
2018
|
+
return markdown;
|
|
2019
|
+
}
|
|
2020
|
+
const end = markdown.indexOf("\n---", 3);
|
|
2021
|
+
return end === -1 ? markdown : markdown.slice(end + "\n---".length);
|
|
2022
|
+
}
|
|
2023
|
+
function firstHeading2(markdown) {
|
|
2024
|
+
return /^#\s+(.+?)\s*$/m.exec(markdown)?.[1];
|
|
2025
|
+
}
|
|
2026
|
+
function lineNumberAtOffset(input, offset) {
|
|
2027
|
+
return input.slice(0, offset).split(/\r?\n/).length;
|
|
2028
|
+
}
|
|
2029
|
+
function slugify(input) {
|
|
2030
|
+
const slug = input.trim().toLocaleLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2031
|
+
return slug.length > 0 ? slug : "section";
|
|
2032
|
+
}
|
|
2033
|
+
function stringValue4(value) {
|
|
2034
|
+
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
|
2035
|
+
}
|
|
2036
|
+
function stringArrayValue3(value) {
|
|
2037
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// src/search/index.ts
|
|
2041
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
2042
|
+
import path8 from "path";
|
|
2043
|
+
var defaultLimit = 10;
|
|
2044
|
+
var maxLimit = 50;
|
|
2045
|
+
var maxSearchBodyChars = 2e5;
|
|
2046
|
+
var stopWords = /* @__PURE__ */ new Set(["a", "an", "and", "are", "for", "in", "is", "of", "or", "the", "to"]);
|
|
2047
|
+
async function searchWorkspace(options) {
|
|
2048
|
+
const workspaceRoot = path8.resolve(options.workspaceRoot);
|
|
2049
|
+
const limit = clampLimit(options.limit);
|
|
2050
|
+
const config = await loadWorkspaceConfig(workspaceRoot);
|
|
2051
|
+
const scanResult = await scanConcepts(workspaceRoot, config);
|
|
2052
|
+
const indexMentioned = await readRootIndexMentions(workspaceRoot, config.okf.bundle_root);
|
|
2053
|
+
const parsedQuery = parseSearchQuery(options.query);
|
|
2054
|
+
const warnings = [];
|
|
2055
|
+
const scored = scanResult.files.filter((file) => !file.isReserved).map((file) => {
|
|
2056
|
+
const card = cardFromMarkdownFile(file, indexMentioned.has(file.conceptId));
|
|
2057
|
+
const body = markdownBody2(file);
|
|
2058
|
+
if (!card.frontmatterOk) {
|
|
2059
|
+
warnings.push({
|
|
2060
|
+
code: "FRONTMATTER_DEGRADED",
|
|
2061
|
+
path: file.workspacePath,
|
|
2062
|
+
message: `Search used fallback metadata because frontmatter is invalid: ${file.workspacePath}`
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
if (body.length > maxSearchBodyChars) {
|
|
2066
|
+
warnings.push({
|
|
2067
|
+
code: "SEARCH_BODY_SKIPPED",
|
|
2068
|
+
path: file.workspacePath,
|
|
2069
|
+
message: `Search skipped body scoring for a large markdown file: ${file.workspacePath}`
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
return scoreCard(card, body, parsedQuery);
|
|
2073
|
+
}).filter((candidate) => matchesFilters(candidate.card, parsedQuery.filters)).filter((candidate) => candidate.card.score > 0).sort(compareSearchCards).map((candidate) => candidate.card);
|
|
2074
|
+
return {
|
|
2075
|
+
workspaceRoot,
|
|
2076
|
+
query: options.query,
|
|
2077
|
+
filtersApplied: parsedQuery.filters,
|
|
2078
|
+
limit,
|
|
2079
|
+
totalMatches: scored.length,
|
|
2080
|
+
truncated: scored.length > limit,
|
|
2081
|
+
results: scored.slice(0, limit),
|
|
2082
|
+
warnings
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
function cardFromMarkdownFile(file, indexMentioned) {
|
|
2086
|
+
const title = file.frontmatter.ok ? stringValue5(file.frontmatter.data.title) ?? firstHeading3(file.markdown) ?? file.conceptId : firstHeading3(file.markdown) ?? file.conceptId;
|
|
2087
|
+
const type = file.frontmatter.ok ? stringValue5(file.frontmatter.data.type) ?? "Unknown" : "Unknown";
|
|
2088
|
+
const description = file.frontmatter.ok ? stringValue5(file.frontmatter.data.description) : void 0;
|
|
2089
|
+
const card = {
|
|
2090
|
+
conceptId: file.conceptId,
|
|
2091
|
+
path: file.workspacePath,
|
|
2092
|
+
title,
|
|
2093
|
+
type,
|
|
2094
|
+
tags: file.frontmatter.ok ? stringArrayValue4(file.frontmatter.data.tags) : [],
|
|
2095
|
+
frontmatterOk: file.frontmatter.ok,
|
|
2096
|
+
indexMentioned,
|
|
2097
|
+
score: 0,
|
|
2098
|
+
scoreBreakdown: [],
|
|
2099
|
+
matchedFields: [],
|
|
2100
|
+
bodyHitCount: 0
|
|
2101
|
+
};
|
|
2102
|
+
if (description !== void 0) {
|
|
2103
|
+
card.description = description;
|
|
2104
|
+
}
|
|
2105
|
+
return card;
|
|
2106
|
+
}
|
|
2107
|
+
function scoreCard(card, body, query) {
|
|
2108
|
+
const phrase = query.phrase.toLocaleLowerCase();
|
|
2109
|
+
const title = card.title.toLocaleLowerCase();
|
|
2110
|
+
const conceptId = card.conceptId.toLocaleLowerCase();
|
|
2111
|
+
const pathValue = card.path.toLocaleLowerCase();
|
|
2112
|
+
const typeValue = card.type.toLocaleLowerCase();
|
|
2113
|
+
const description = (card.description ?? "").toLocaleLowerCase();
|
|
2114
|
+
const tags = card.tags.map((tag) => tag.toLocaleLowerCase());
|
|
2115
|
+
const matchedFields = /* @__PURE__ */ new Set();
|
|
2116
|
+
const scoreBreakdown = [];
|
|
2117
|
+
const addScore = (field, reason, score) => {
|
|
2118
|
+
if (score <= 0) {
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
matchedFields.add(field);
|
|
2122
|
+
scoreBreakdown.push({ field, reason, score });
|
|
2123
|
+
};
|
|
2124
|
+
const exactIdentityMatch = phrase.length > 0 && (title === phrase || conceptId === phrase || pathValue === phrase);
|
|
2125
|
+
if (exactIdentityMatch) {
|
|
2126
|
+
addScore("identity", "exact title/id/path match", 100);
|
|
2127
|
+
}
|
|
2128
|
+
const titlePhraseMatch = phrase.length > 0 && title.includes(phrase);
|
|
2129
|
+
if (titlePhraseMatch) {
|
|
2130
|
+
addScore("title", "title phrase match", 60);
|
|
2131
|
+
}
|
|
2132
|
+
if (phrase.length > 0 && (conceptId.includes(phrase) || pathValue.includes(phrase))) {
|
|
2133
|
+
addScore("path", "id/path phrase match", 50);
|
|
2134
|
+
}
|
|
2135
|
+
if (phrase.length > 0 && tags.includes(phrase)) {
|
|
2136
|
+
addScore("tags", "exact tag match", 40);
|
|
2137
|
+
}
|
|
2138
|
+
if (query.filters.type !== void 0 && typeValue === query.filters.type.toLocaleLowerCase()) {
|
|
2139
|
+
addScore("type", "type filter match", 25);
|
|
2140
|
+
}
|
|
2141
|
+
if (phrase.length > 0 && description.includes(phrase)) {
|
|
2142
|
+
addScore("description", "description phrase match", 20);
|
|
2143
|
+
}
|
|
2144
|
+
addTokenScores("title", query.tokens, tokenize2(title), 12, 5, addScore);
|
|
2145
|
+
addTokenScores("path", query.tokens, tokenize2(`${conceptId} ${pathValue}`), 10, 5, addScore);
|
|
2146
|
+
addTokenScores("tags", query.tokens, tokenize2(tags.join(" ")), 8, 5, addScore);
|
|
2147
|
+
addTokenScores("description", query.tokens, tokenize2(description), 4, 5, addScore);
|
|
2148
|
+
const bodyForSearch = body.length > maxSearchBodyChars ? "" : body.toLocaleLowerCase();
|
|
2149
|
+
const bodyPhraseHits = phrase.length > 0 ? countOccurrences(bodyForSearch, phrase) : 0;
|
|
2150
|
+
card.bodyHitCount = Math.min(bodyPhraseHits, 5);
|
|
2151
|
+
addScore("body", "body phrase hits", card.bodyHitCount * 4);
|
|
2152
|
+
const bodyTokenMatches = [...query.tokens].filter((token) => tokenize2(bodyForSearch).has(token));
|
|
2153
|
+
addScore("body", "body unique token hits", Math.min(bodyTokenMatches.length, 10) * 2);
|
|
2154
|
+
card.score = scoreBreakdown.reduce((total, item) => total + item.score, 0);
|
|
2155
|
+
card.scoreBreakdown = scoreBreakdown;
|
|
2156
|
+
card.matchedFields = [...matchedFields].sort();
|
|
2157
|
+
return { card, exactIdentityMatch, titlePhraseMatch };
|
|
2158
|
+
}
|
|
2159
|
+
function addTokenScores(field, queryTokens, fieldTokens, weight, cap, addScore) {
|
|
2160
|
+
const matches = [...queryTokens].filter((token) => fieldTokens.has(token));
|
|
2161
|
+
addScore(field, `${field} token hits`, Math.min(matches.length, cap) * weight);
|
|
2162
|
+
}
|
|
2163
|
+
function compareSearchCards(left, right) {
|
|
2164
|
+
return right.card.score - left.card.score || Number(right.exactIdentityMatch) - Number(left.exactIdentityMatch) || Number(right.titlePhraseMatch) - Number(left.titlePhraseMatch) || left.card.conceptId.localeCompare(right.card.conceptId);
|
|
2165
|
+
}
|
|
2166
|
+
function parseSearchQuery(query) {
|
|
2167
|
+
const filters = {};
|
|
2168
|
+
const terms = [];
|
|
2169
|
+
for (const rawPart of query.split(/\s+/)) {
|
|
2170
|
+
const part = rawPart.trim();
|
|
2171
|
+
if (part.length === 0) {
|
|
2172
|
+
continue;
|
|
2173
|
+
}
|
|
2174
|
+
const filter = /^(type|tag|path):(.+)$/i.exec(part);
|
|
2175
|
+
if (filter?.[1] !== void 0 && filter[2] !== void 0) {
|
|
2176
|
+
filters[filter[1].toLocaleLowerCase()] = filter[2];
|
|
2177
|
+
continue;
|
|
2178
|
+
}
|
|
2179
|
+
terms.push(part);
|
|
2180
|
+
}
|
|
2181
|
+
const phrase = terms.join(" ").trim();
|
|
2182
|
+
return {
|
|
2183
|
+
phrase,
|
|
2184
|
+
tokens: tokenize2(phrase),
|
|
2185
|
+
filters
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
function tokenize2(input) {
|
|
2189
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
2190
|
+
for (const token of input.toLocaleLowerCase().split(/[^\p{L}\p{N}]+/u)) {
|
|
2191
|
+
if (token.length > 0 && !stopWords.has(token)) {
|
|
2192
|
+
tokens.add(token);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
const cjkChars = [...input].filter((char) => /\p{Script=Han}/u.test(char));
|
|
2196
|
+
cjkChars.forEach((char) => {
|
|
2197
|
+
tokens.add(char);
|
|
2198
|
+
});
|
|
2199
|
+
for (let index = 0; index < cjkChars.length - 1; index += 1) {
|
|
2200
|
+
const first = cjkChars[index];
|
|
2201
|
+
const second = cjkChars[index + 1];
|
|
2202
|
+
if (first !== void 0 && second !== void 0) {
|
|
2203
|
+
tokens.add(`${first}${second}`);
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
return tokens;
|
|
2207
|
+
}
|
|
2208
|
+
function matchesFilters(card, filters) {
|
|
2209
|
+
if (filters.type !== void 0 && card.type.toLocaleLowerCase() !== filters.type.toLocaleLowerCase()) {
|
|
2210
|
+
return false;
|
|
2211
|
+
}
|
|
2212
|
+
if (filters.tag !== void 0 && !card.tags.some((tag) => tag.toLocaleLowerCase() === filters.tag?.toLocaleLowerCase())) {
|
|
2213
|
+
return false;
|
|
2214
|
+
}
|
|
2215
|
+
if (filters.path !== void 0 && !card.path.startsWith(normalizePathFilter(filters.path))) {
|
|
2216
|
+
return false;
|
|
2217
|
+
}
|
|
2218
|
+
return true;
|
|
2219
|
+
}
|
|
2220
|
+
function normalizePathFilter(input) {
|
|
2221
|
+
return input.startsWith("wiki/") ? input : `wiki/${input.replace(/^\/+/, "")}`;
|
|
2222
|
+
}
|
|
2223
|
+
function markdownBody2(file) {
|
|
2224
|
+
if (file.frontmatter.ok) {
|
|
2225
|
+
return file.frontmatter.body;
|
|
2226
|
+
}
|
|
2227
|
+
return stripFrontmatterFence3(file.markdown);
|
|
2228
|
+
}
|
|
2229
|
+
function stripFrontmatterFence3(markdown) {
|
|
2230
|
+
if (!markdown.startsWith("---")) {
|
|
2231
|
+
return markdown;
|
|
2232
|
+
}
|
|
2233
|
+
const end = markdown.indexOf("\n---", 3);
|
|
2234
|
+
return end === -1 ? markdown : markdown.slice(end + "\n---".length);
|
|
2235
|
+
}
|
|
2236
|
+
function firstHeading3(markdown) {
|
|
2237
|
+
const heading = /^#\s+(.+?)\s*$/m.exec(markdown);
|
|
2238
|
+
return heading?.[1];
|
|
2239
|
+
}
|
|
2240
|
+
async function readRootIndexMentions(workspaceRoot, wikiRoot) {
|
|
2241
|
+
const indexPath = path8.join(workspaceRoot, wikiRoot, "index.md");
|
|
2242
|
+
try {
|
|
2243
|
+
const indexMarkdown = await readFile6(indexPath, "utf8");
|
|
2244
|
+
return new Set(
|
|
2245
|
+
parseMarkdownLinks(indexMarkdown).map((link) => resolveOkfLinkTarget(link.target, "index.md")).filter((conceptId) => conceptId !== void 0)
|
|
2246
|
+
);
|
|
2247
|
+
} catch (error) {
|
|
2248
|
+
if (errorCode4(error) === "ENOENT") {
|
|
2249
|
+
return /* @__PURE__ */ new Set();
|
|
2250
|
+
}
|
|
2251
|
+
throw error;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
function countOccurrences(input, needle) {
|
|
2255
|
+
if (needle.length === 0) {
|
|
2256
|
+
return 0;
|
|
2257
|
+
}
|
|
2258
|
+
let count = 0;
|
|
2259
|
+
let index = 0;
|
|
2260
|
+
while (true) {
|
|
2261
|
+
const nextIndex = input.indexOf(needle, index);
|
|
2262
|
+
if (nextIndex === -1) {
|
|
2263
|
+
return count;
|
|
2264
|
+
}
|
|
2265
|
+
count += 1;
|
|
2266
|
+
index = nextIndex + needle.length;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
function clampLimit(limit) {
|
|
2270
|
+
if (limit === void 0) {
|
|
2271
|
+
return defaultLimit;
|
|
2272
|
+
}
|
|
2273
|
+
return Math.max(1, Math.min(maxLimit, Math.trunc(limit)));
|
|
2274
|
+
}
|
|
2275
|
+
function stringValue5(value) {
|
|
2276
|
+
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
|
2277
|
+
}
|
|
2278
|
+
function stringArrayValue4(value) {
|
|
2279
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
2280
|
+
}
|
|
2281
|
+
function errorCode4(error) {
|
|
2282
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
2283
|
+
return void 0;
|
|
2284
|
+
}
|
|
2285
|
+
const code = error.code;
|
|
2286
|
+
return typeof code === "string" ? code : void 0;
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// src/workspace/index.ts
|
|
2290
|
+
import { execFile } from "child_process";
|
|
2291
|
+
import { access as access2, mkdir as mkdir3, readdir as readdir3, stat as stat2, writeFile as writeFile3 } from "fs/promises";
|
|
2292
|
+
import path9 from "path";
|
|
2293
|
+
import { promisify } from "util";
|
|
2294
|
+
import { stringify as stringifyYaml } from "yaml";
|
|
2295
|
+
var WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND";
|
|
2296
|
+
var WorkspaceResolutionError = class extends Error {
|
|
2297
|
+
constructor(message, startDir) {
|
|
2298
|
+
super(message);
|
|
2299
|
+
this.startDir = startDir;
|
|
2300
|
+
this.name = "WorkspaceResolutionError";
|
|
2301
|
+
}
|
|
2302
|
+
startDir;
|
|
2303
|
+
code = WORKSPACE_NOT_FOUND;
|
|
2304
|
+
};
|
|
2305
|
+
var WorkspaceInitError = class extends Error {
|
|
2306
|
+
constructor(message, code) {
|
|
2307
|
+
super(message);
|
|
2308
|
+
this.code = code;
|
|
2309
|
+
this.name = "WorkspaceInitError";
|
|
2310
|
+
}
|
|
2311
|
+
code;
|
|
2312
|
+
};
|
|
2313
|
+
var execFileAsync = promisify(execFile);
|
|
2314
|
+
async function initWorkspace(options) {
|
|
2315
|
+
const workspaceRoot = path9.resolve(options.workspaceRoot);
|
|
2316
|
+
const plan = options.now === void 0 ? createWorkspacePlan({ name: options.name }) : createWorkspacePlan({ name: options.name, now: options.now });
|
|
2317
|
+
await assertWorkspaceCanBeInitialized(workspaceRoot);
|
|
2318
|
+
if (options.dryRun === true) {
|
|
2319
|
+
return {
|
|
2320
|
+
workspaceRoot,
|
|
2321
|
+
name: plan.name,
|
|
2322
|
+
dryRun: true,
|
|
2323
|
+
git: {
|
|
2324
|
+
requested: options.git === true,
|
|
2325
|
+
initialized: false
|
|
2326
|
+
},
|
|
2327
|
+
files: plan.files.map((file) => file.path),
|
|
2328
|
+
directories: plan.directories,
|
|
2329
|
+
lint: {
|
|
2330
|
+
ok: true,
|
|
2331
|
+
issues: []
|
|
2332
|
+
},
|
|
2333
|
+
warnings: plan.warnings
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
await mkdir3(workspaceRoot, { recursive: true });
|
|
2337
|
+
await Promise.all(
|
|
2338
|
+
plan.directories.map(
|
|
2339
|
+
(directory) => mkdir3(path9.join(workspaceRoot, directory), { recursive: true })
|
|
2340
|
+
)
|
|
2341
|
+
);
|
|
2342
|
+
await Promise.all(
|
|
2343
|
+
plan.files.map((file) => writeTextFile(path9.join(workspaceRoot, file.path), file.contents))
|
|
2344
|
+
);
|
|
2345
|
+
const gitInitialized = options.git === true ? await initializeGit(workspaceRoot) : false;
|
|
2346
|
+
const lint = await lintWorkspace(workspaceRoot);
|
|
2347
|
+
return {
|
|
2348
|
+
workspaceRoot,
|
|
2349
|
+
name: plan.name,
|
|
2350
|
+
dryRun: false,
|
|
2351
|
+
git: {
|
|
2352
|
+
requested: options.git === true,
|
|
2353
|
+
initialized: gitInitialized
|
|
2354
|
+
},
|
|
2355
|
+
files: plan.files.map((file) => file.path),
|
|
2356
|
+
directories: plan.directories,
|
|
2357
|
+
lint,
|
|
2358
|
+
warnings: plan.warnings
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
async function initializeGit(workspaceRoot) {
|
|
2362
|
+
try {
|
|
2363
|
+
await execFileAsync("git", ["init"], { cwd: workspaceRoot });
|
|
2364
|
+
return true;
|
|
2365
|
+
} catch (error) {
|
|
2366
|
+
if (errorCode5(error) === "ENOENT") {
|
|
2367
|
+
throw new WorkspaceInitError("git executable was not found.", "DEPENDENCY_MISSING");
|
|
2368
|
+
}
|
|
2369
|
+
throw error;
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
async function readWorkspaceStatus(workspaceRootInput) {
|
|
2373
|
+
const workspaceRoot = path9.resolve(workspaceRootInput);
|
|
2374
|
+
const configResult = await readWorkspaceConfig(workspaceRoot);
|
|
2375
|
+
if (!configResult.ok) {
|
|
2376
|
+
return {
|
|
2377
|
+
workspaceRoot,
|
|
2378
|
+
initialized: false,
|
|
2379
|
+
wikiFiles: 0,
|
|
2380
|
+
concepts: 0,
|
|
2381
|
+
lint: {
|
|
2382
|
+
ok: false,
|
|
2383
|
+
issues: configResult.issues.map((issue) => ({
|
|
2384
|
+
code: issue.code,
|
|
2385
|
+
severity: "error",
|
|
2386
|
+
message: issue.message,
|
|
2387
|
+
path: issue.path
|
|
2388
|
+
}))
|
|
2389
|
+
},
|
|
2390
|
+
warnings: []
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
const [scanResult, lint] = await Promise.all([
|
|
2394
|
+
scanConcepts(workspaceRoot, configResult.config),
|
|
2395
|
+
lintWorkspace(workspaceRoot)
|
|
2396
|
+
]);
|
|
2397
|
+
return {
|
|
2398
|
+
workspaceRoot,
|
|
2399
|
+
initialized: true,
|
|
2400
|
+
name: configResult.config.workspace.name,
|
|
2401
|
+
wikiFiles: scanResult.files.length,
|
|
2402
|
+
concepts: scanResult.concepts.length,
|
|
2403
|
+
lint,
|
|
2404
|
+
warnings: workspacePendingWarnings()
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
async function resolveWorkspaceRoot(options) {
|
|
2408
|
+
if (options.workspaceRoot !== void 0 && options.workspaceRoot.trim().length > 0) {
|
|
2409
|
+
return path9.resolve(options.workspaceRoot);
|
|
2410
|
+
}
|
|
2411
|
+
const startDir = path9.resolve(options.startDir ?? process.cwd());
|
|
2412
|
+
const nearest = await findNearestWorkspaceRoot(startDir);
|
|
2413
|
+
if (nearest === void 0) {
|
|
2414
|
+
throw new WorkspaceResolutionError(
|
|
2415
|
+
"Could not find okfh.config.yaml in the current directory or its parents.",
|
|
2416
|
+
startDir
|
|
2417
|
+
);
|
|
2418
|
+
}
|
|
2419
|
+
return nearest;
|
|
2420
|
+
}
|
|
2421
|
+
async function findNearestWorkspaceRoot(startDir) {
|
|
2422
|
+
let current = startDir;
|
|
2423
|
+
while (true) {
|
|
2424
|
+
try {
|
|
2425
|
+
await access2(path9.join(current, "okfh.config.yaml"));
|
|
2426
|
+
return current;
|
|
2427
|
+
} catch (error) {
|
|
2428
|
+
if (errorCode5(error) !== "ENOENT") {
|
|
2429
|
+
throw error;
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
const parent = path9.dirname(current);
|
|
2433
|
+
if (parent === current) {
|
|
2434
|
+
return void 0;
|
|
2435
|
+
}
|
|
2436
|
+
current = parent;
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
function workspacePendingWarnings() {
|
|
2440
|
+
return [
|
|
2441
|
+
{
|
|
2442
|
+
code: "AGENT_PACK_PENDING",
|
|
2443
|
+
message: "Claude and Codex skill rendering is not included in the base workspace plan."
|
|
2444
|
+
}
|
|
2445
|
+
];
|
|
2446
|
+
}
|
|
2447
|
+
function createWorkspacePlan(options) {
|
|
2448
|
+
const createdAt = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
2449
|
+
const logDate = createdAt.slice(0, "YYYY-MM-DD".length);
|
|
2450
|
+
const config = createWorkspaceConfig(options.name, createdAt);
|
|
2451
|
+
return {
|
|
2452
|
+
name: options.name,
|
|
2453
|
+
createdAt,
|
|
2454
|
+
directories: workspaceDirectories(),
|
|
2455
|
+
files: workspaceFiles(options.name, logDate, config),
|
|
2456
|
+
warnings: workspacePendingWarnings()
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
function workspaceDirectories() {
|
|
2460
|
+
return [
|
|
2461
|
+
".agents/skills",
|
|
2462
|
+
".claude/skills",
|
|
2463
|
+
".codex",
|
|
2464
|
+
".okfh/cache",
|
|
2465
|
+
".okfh/reports",
|
|
2466
|
+
"raw/assets",
|
|
2467
|
+
"raw/inbox",
|
|
2468
|
+
"raw/sources",
|
|
2469
|
+
"wiki/decisions",
|
|
2470
|
+
"wiki/entities",
|
|
2471
|
+
"wiki/projects",
|
|
2472
|
+
"wiki/questions",
|
|
2473
|
+
"wiki/references",
|
|
2474
|
+
"wiki/topics"
|
|
2475
|
+
];
|
|
2476
|
+
}
|
|
2477
|
+
async function assertWorkspaceCanBeInitialized(workspaceRoot) {
|
|
2478
|
+
try {
|
|
2479
|
+
const workspaceStat = await stat2(workspaceRoot);
|
|
2480
|
+
if (!workspaceStat.isDirectory()) {
|
|
2481
|
+
throw new WorkspaceInitError(
|
|
2482
|
+
"Workspace path exists and is not a directory.",
|
|
2483
|
+
"INIT_NOT_DIRECTORY"
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2486
|
+
const entries = await readdir3(workspaceRoot);
|
|
2487
|
+
if (entries.length > 0) {
|
|
2488
|
+
throw new WorkspaceInitError("Workspace path exists and is not empty.", "INIT_NOT_EMPTY");
|
|
2489
|
+
}
|
|
2490
|
+
} catch (error) {
|
|
2491
|
+
if (errorCode5(error) === "ENOENT") {
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
throw error;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
function createWorkspaceConfig(name, createdAt) {
|
|
2498
|
+
return {
|
|
2499
|
+
version: "0.1",
|
|
2500
|
+
workspace: {
|
|
2501
|
+
name,
|
|
2502
|
+
created_at: createdAt,
|
|
2503
|
+
platform: "macos"
|
|
2504
|
+
},
|
|
2505
|
+
okf: {
|
|
2506
|
+
bundle_root: "wiki",
|
|
2507
|
+
profile: "okf-harness-default"
|
|
2508
|
+
},
|
|
2509
|
+
agents: {
|
|
2510
|
+
tier1: {
|
|
2511
|
+
claude: true,
|
|
2512
|
+
codex: true
|
|
2513
|
+
},
|
|
2514
|
+
tier2: {
|
|
2515
|
+
pi: false,
|
|
2516
|
+
opencode: false
|
|
2517
|
+
}
|
|
2518
|
+
},
|
|
2519
|
+
paths: {
|
|
2520
|
+
raw_inbox: "raw/inbox",
|
|
2521
|
+
raw_sources: "raw/sources",
|
|
2522
|
+
wiki_root: "wiki",
|
|
2523
|
+
manifest: ".okfh/manifest.jsonl"
|
|
2524
|
+
},
|
|
2525
|
+
safety: {
|
|
2526
|
+
raw_sources_immutable: true,
|
|
2527
|
+
require_git_checkpoint_before_agent_write: true,
|
|
2528
|
+
max_files_changed_per_ingest: 20
|
|
2529
|
+
}
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
function workspaceFiles(name, logDate, config) {
|
|
2533
|
+
return [
|
|
2534
|
+
{
|
|
2535
|
+
path: "README.md",
|
|
2536
|
+
contents: `# ${name}
|
|
2537
|
+
|
|
2538
|
+
This is an OKF Harness workspace.
|
|
2539
|
+
`
|
|
2540
|
+
},
|
|
2541
|
+
{
|
|
2542
|
+
path: "AGENTS.md",
|
|
2543
|
+
contents: "# OKF Harness workspace\n\nPlaceholder. Install agent guidance with okfh init or okfh agent.\n"
|
|
2544
|
+
},
|
|
2545
|
+
{
|
|
2546
|
+
path: "CLAUDE.md",
|
|
2547
|
+
contents: "@AGENTS.md\n"
|
|
2548
|
+
},
|
|
2549
|
+
{
|
|
2550
|
+
path: ".agents/skills/.gitkeep",
|
|
2551
|
+
contents: ""
|
|
2552
|
+
},
|
|
2553
|
+
{
|
|
2554
|
+
path: ".claude/skills/.gitkeep",
|
|
2555
|
+
contents: ""
|
|
2556
|
+
},
|
|
2557
|
+
{
|
|
2558
|
+
path: ".codex/.gitkeep",
|
|
2559
|
+
contents: ""
|
|
2560
|
+
},
|
|
2561
|
+
{
|
|
2562
|
+
path: ".gitignore",
|
|
2563
|
+
contents: "# OKF Harness generated caches\n.okfh/cache/\n.okfh/*.sqlite\n.okfh/backlinks.json\n.okfh/reports/graph.html\n.okfh/reports/*.tmp\n\n# OS\n.DS_Store\n\n# Secrets\n.env\n.env.*\n!.env.example\n"
|
|
2564
|
+
},
|
|
2565
|
+
{
|
|
2566
|
+
path: ".okfh/cache/.gitkeep",
|
|
2567
|
+
contents: ""
|
|
2568
|
+
},
|
|
2569
|
+
{
|
|
2570
|
+
path: ".okfh/manifest.jsonl",
|
|
2571
|
+
contents: ""
|
|
2572
|
+
},
|
|
2573
|
+
{
|
|
2574
|
+
path: ".okfh/reports/.gitkeep",
|
|
2575
|
+
contents: ""
|
|
2576
|
+
},
|
|
2577
|
+
{
|
|
2578
|
+
path: "okfh.config.yaml",
|
|
2579
|
+
contents: stringifyYaml(config)
|
|
2580
|
+
},
|
|
2581
|
+
{
|
|
2582
|
+
path: "raw/assets/README.md",
|
|
2583
|
+
contents: "# Assets\n\nStore local assets referenced by wiki pages here.\n"
|
|
2584
|
+
},
|
|
2585
|
+
{
|
|
2586
|
+
path: "raw/inbox/README.md",
|
|
2587
|
+
contents: "# Inbox\n\nDrop unregistered source material here before adding it to OKF Harness.\n"
|
|
2588
|
+
},
|
|
2589
|
+
{
|
|
2590
|
+
path: "raw/sources/README.md",
|
|
2591
|
+
contents: "# Sources\n\nRegistered raw sources live here and should not be edited in place.\n"
|
|
2592
|
+
},
|
|
2593
|
+
{
|
|
2594
|
+
path: "wiki/index.md",
|
|
2595
|
+
contents: `# ${name} Wiki
|
|
2596
|
+
|
|
2597
|
+
## Concepts
|
|
2598
|
+
|
|
2599
|
+
- [Topics](/topics/index.md)
|
|
2600
|
+
- [References](/references/index.md)
|
|
2601
|
+
`
|
|
2602
|
+
},
|
|
2603
|
+
{
|
|
2604
|
+
path: "wiki/log.md",
|
|
2605
|
+
contents: `# Log
|
|
2606
|
+
|
|
2607
|
+
## ${logDate}
|
|
2608
|
+
|
|
2609
|
+
- Initialized the OKF Harness workspace.
|
|
2610
|
+
`
|
|
2611
|
+
},
|
|
2612
|
+
...workspaceIndexFiles([
|
|
2613
|
+
"decisions",
|
|
2614
|
+
"entities",
|
|
2615
|
+
"projects",
|
|
2616
|
+
"questions",
|
|
2617
|
+
"references",
|
|
2618
|
+
"topics"
|
|
2619
|
+
])
|
|
2620
|
+
].map((file) => ({
|
|
2621
|
+
path: toPosixRelativePath(".", file.path),
|
|
2622
|
+
contents: file.contents
|
|
2623
|
+
}));
|
|
2624
|
+
}
|
|
2625
|
+
function workspaceIndexFiles(directories) {
|
|
2626
|
+
return directories.map((directory) => ({
|
|
2627
|
+
path: `wiki/${directory}/index.md`,
|
|
2628
|
+
contents: `# ${titleCase(directory)}
|
|
2629
|
+
|
|
2630
|
+
No entries yet.
|
|
2631
|
+
`
|
|
2632
|
+
}));
|
|
2633
|
+
}
|
|
2634
|
+
async function writeTextFile(filePath, contents) {
|
|
2635
|
+
await mkdir3(path9.dirname(filePath), { recursive: true });
|
|
2636
|
+
await writeFile3(filePath, contents, "utf8");
|
|
2637
|
+
}
|
|
2638
|
+
function titleCase(input) {
|
|
2639
|
+
return input.split("-").map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`).join(" ");
|
|
2640
|
+
}
|
|
2641
|
+
function errorCode5(error) {
|
|
2642
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
2643
|
+
return void 0;
|
|
2644
|
+
}
|
|
2645
|
+
const code = error.code;
|
|
2646
|
+
return typeof code === "string" ? code : void 0;
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// src/index.ts
|
|
2650
|
+
var packageInfo = {
|
|
2651
|
+
name: "@okf-harness/core",
|
|
2652
|
+
role: "core"
|
|
2653
|
+
};
|
|
2654
|
+
export {
|
|
2655
|
+
AMBIGUOUS_SECTION,
|
|
2656
|
+
BROKEN_LINK,
|
|
2657
|
+
CONFIG_INVALID,
|
|
2658
|
+
ConceptScanError,
|
|
2659
|
+
GRAPH_WRITE_FAILED,
|
|
2660
|
+
GraphWorkspaceError,
|
|
2661
|
+
INVALID_TARGET,
|
|
2662
|
+
LOG_INVALID_DATE_HEADING,
|
|
2663
|
+
MANIFEST_INVALID,
|
|
2664
|
+
MISSING_CITATIONS_SECTION,
|
|
2665
|
+
MISSING_INDEX_ENTRY,
|
|
2666
|
+
NON_MARKDOWN_TARGET,
|
|
2667
|
+
NON_UTF8_TARGET,
|
|
2668
|
+
OKF_INVALID_FRONTMATTER,
|
|
2669
|
+
OKF_MISSING_FRONTMATTER,
|
|
2670
|
+
OKF_MISSING_TYPE,
|
|
2671
|
+
PATH_OUTSIDE_WORKSPACE,
|
|
2672
|
+
READ_LIMIT_EXCEEDED,
|
|
2673
|
+
REFERENCE_SOURCE_MISSING,
|
|
2674
|
+
RESERVED_FILE_HAS_CONCEPT_FRONTMATTER,
|
|
2675
|
+
RESERVED_OKF_FILENAMES,
|
|
2676
|
+
ReadWorkspaceError,
|
|
2677
|
+
SCAN_FAILED,
|
|
2678
|
+
SOURCE_HASH_DRIFT,
|
|
2679
|
+
SOURCE_INPUT_NOT_FOUND,
|
|
2680
|
+
SOURCE_INPUT_UNSUPPORTED,
|
|
2681
|
+
SOURCE_MISSING,
|
|
2682
|
+
SOURCE_NOT_REGISTERED,
|
|
2683
|
+
SOURCE_REGISTRATION_FAILED,
|
|
2684
|
+
SourceManagementError,
|
|
2685
|
+
TARGET_NOT_FOUND,
|
|
2686
|
+
WORKSPACE_NOT_FOUND,
|
|
2687
|
+
WorkspaceConfigError,
|
|
2688
|
+
WorkspaceInitError,
|
|
2689
|
+
WorkspacePathError,
|
|
2690
|
+
WorkspaceResolutionError,
|
|
2691
|
+
addSource,
|
|
2692
|
+
buildWorkspaceGraph,
|
|
2693
|
+
conceptIdFromPath,
|
|
2694
|
+
createIngestPlan,
|
|
2695
|
+
createWorkspacePlan,
|
|
2696
|
+
initWorkspace,
|
|
2697
|
+
isReservedOkfFile,
|
|
2698
|
+
lintWorkspace,
|
|
2699
|
+
listSources,
|
|
2700
|
+
loadWorkspaceConfig,
|
|
2701
|
+
packageInfo,
|
|
2702
|
+
parseMarkdownFrontmatter,
|
|
2703
|
+
parseMarkdownLinks,
|
|
2704
|
+
parseWorkspaceConfig,
|
|
2705
|
+
readSourceManifest,
|
|
2706
|
+
readWorkspaceConfig,
|
|
2707
|
+
readWorkspaceDocument,
|
|
2708
|
+
readWorkspaceStatus,
|
|
2709
|
+
resolveOkfLinkTarget,
|
|
2710
|
+
resolveWorkspaceRoot,
|
|
2711
|
+
safeResolveWorkspacePath,
|
|
2712
|
+
scanConcepts,
|
|
2713
|
+
searchWorkspace,
|
|
2714
|
+
toPosixPath,
|
|
2715
|
+
toPosixRelativePath,
|
|
2716
|
+
workspaceConfigSchema
|
|
2717
|
+
};
|