@first-tree-ai/context-tree 0.1.1 → 0.1.2
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/.agents/plugins/marketplace.json +19 -0
- package/.claude-plugin/marketplace.json +21 -0
- package/.claude-plugin/plugin.json +12 -0
- package/.codex-plugin/plugin.json +28 -0
- package/README.md +152 -79
- package/dist/cli/index.mjs +25841 -6
- package/dist/index.d.mts +11 -2
- package/dist/index.mjs +1087 -2
- package/dist/{schemas-DyQ0V9Z3.mjs → schemas-BWM6Q6iz.mjs} +75 -6
- package/dist/{schemas-BZkU14CI.d.mts → schemas-C4bs-FkC.d.mts} +115 -3
- package/dist/schemas.d.mts +2 -2
- package/dist/schemas.mjs +2 -2
- package/docs/specification.md +58 -23
- package/examples/basic/NODE.md +0 -2
- package/hooks/hooks.json +26 -0
- package/hooks/session-start.mjs +64 -0
- package/package.json +10 -4
- package/plugin.json +14 -0
- package/policy/context-tree-policy.md +2 -0
- package/skills/context-tree-init/SKILL.md +11 -10
- package/skills/context-tree-init/scripts/context-tree.mjs +41 -0
- package/skills/context-tree-link/SKILL.md +45 -0
- package/skills/context-tree-link/agents/openai.yaml +4 -0
- package/skills/context-tree-link/scripts/context-tree.mjs +41 -0
- package/skills/context-tree-read/SKILL.md +27 -25
- package/skills/context-tree-read/agents/openai.yaml +1 -1
- package/skills/context-tree-read/scripts/context-tree.mjs +41 -0
- package/skills/context-tree-write/SKILL.md +53 -35
- package/skills/context-tree-write/agents/openai.yaml +1 -1
- package/skills/context-tree-write/scripts/context-tree.mjs +41 -0
- package/templates/AGENTS.md +66 -0
- package/dist/src-DJZoQVCF.mjs +0 -619
package/dist/src-DJZoQVCF.mjs
DELETED
|
@@ -1,619 +0,0 @@
|
|
|
1
|
-
import { C as isRecord, S as parseMarkdownFrontmatter, _ as parseContextTreeRootNode, i as VALIDATION_CODES } from "./schemas-DyQ0V9Z3.mjs";
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, isAbsolute, join, parse, posix, relative, resolve } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
7
|
-
//#region src/core/internal/packaged-resource.ts
|
|
8
|
-
const PACKAGE_NAME = "@first-tree-ai/context-tree";
|
|
9
|
-
function isPackageRoot(path) {
|
|
10
|
-
const manifestPath = join(path, "package.json");
|
|
11
|
-
if (!existsSync(manifestPath)) return false;
|
|
12
|
-
try {
|
|
13
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
14
|
-
return isRecord(manifest) && manifest.name === PACKAGE_NAME;
|
|
15
|
-
} catch {
|
|
16
|
-
return false;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
function resolvePackagedResource(...segments) {
|
|
20
|
-
let candidate = dirname(fileURLToPath(import.meta.url));
|
|
21
|
-
const filesystemRoot = parse(candidate).root;
|
|
22
|
-
while (true) {
|
|
23
|
-
if (isPackageRoot(candidate)) {
|
|
24
|
-
const resource = resolve(candidate, ...segments);
|
|
25
|
-
if (existsSync(resource)) return resource;
|
|
26
|
-
throw new Error(`Packaged resource is missing: ${segments.join("/")}`);
|
|
27
|
-
}
|
|
28
|
-
if (candidate === filesystemRoot) break;
|
|
29
|
-
candidate = dirname(candidate);
|
|
30
|
-
}
|
|
31
|
-
throw new Error(`Package root is missing while resolving: ${segments.join("/")}`);
|
|
32
|
-
}
|
|
33
|
-
function readPackageManifest() {
|
|
34
|
-
const parsed = JSON.parse(readFileSync(resolvePackagedResource("package.json"), "utf8"));
|
|
35
|
-
if (!isRecord(parsed)) throw new Error("Package metadata is invalid.");
|
|
36
|
-
return parsed;
|
|
37
|
-
}
|
|
38
|
-
function readPackageVersion() {
|
|
39
|
-
const manifest = readPackageManifest();
|
|
40
|
-
if (typeof manifest.version !== "string") throw new Error("Package version is missing or invalid.");
|
|
41
|
-
return manifest.version;
|
|
42
|
-
}
|
|
43
|
-
//#endregion
|
|
44
|
-
//#region src/core/policy.ts
|
|
45
|
-
function readContextTreePolicy() {
|
|
46
|
-
return {
|
|
47
|
-
content: readFileSync(resolvePackagedResource("policy", "context-tree-policy.md"), "utf8"),
|
|
48
|
-
schemaVersion: 1
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
//#endregion
|
|
52
|
-
//#region src/core/path.ts
|
|
53
|
-
function isPathInside(root, target) {
|
|
54
|
-
const path = relative(root, target);
|
|
55
|
-
return path === "" || !path.startsWith("..") && !isAbsolute(path);
|
|
56
|
-
}
|
|
57
|
-
function resolveTreeRoot(path) {
|
|
58
|
-
const absolute = resolve(path);
|
|
59
|
-
const entry = lstatSync(absolute);
|
|
60
|
-
if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Context Tree root must be a real directory: ${path}`);
|
|
61
|
-
return realpathSync(absolute);
|
|
62
|
-
}
|
|
63
|
-
function toPosixPath(path) {
|
|
64
|
-
return path.replace(/\\/gu, "/");
|
|
65
|
-
}
|
|
66
|
-
//#endregion
|
|
67
|
-
//#region src/core/internal/content-class.ts
|
|
68
|
-
const GENERATED_DIRECTORY_NAMES = new Set([
|
|
69
|
-
"node_modules",
|
|
70
|
-
"__pycache__",
|
|
71
|
-
"dist",
|
|
72
|
-
"build",
|
|
73
|
-
".next",
|
|
74
|
-
".turbo"
|
|
75
|
-
]);
|
|
76
|
-
const REPO_INFRA_MARKDOWN_FILES = new Set(["AGENTS.md", "CLAUDE.md"]);
|
|
77
|
-
const MANAGED_SYMLINK_PATHS = new Set(["WHITEPAPER.md"]);
|
|
78
|
-
function toTreeRelativePosixPath(treeRoot, targetPath) {
|
|
79
|
-
return relative(treeRoot, targetPath).replace(/\\/gu, "/");
|
|
80
|
-
}
|
|
81
|
-
function classifyContextContent(relativePath) {
|
|
82
|
-
const parts = relativePath.replace(/\\/gu, "/").replace(/^\.\//u, "").split("/").filter((part) => part.length > 0);
|
|
83
|
-
if (parts.some((part) => part.startsWith(".") || GENERATED_DIRECTORY_NAMES.has(part)) || parts[0] === "scripts" || REPO_INFRA_MARKDOWN_FILES.has(parts.at(-1) ?? "")) return "repo-infra";
|
|
84
|
-
if (parts[0] === "members") return "member";
|
|
85
|
-
return "normal";
|
|
86
|
-
}
|
|
87
|
-
function emptyContentClassCounts() {
|
|
88
|
-
return {
|
|
89
|
-
normal: 0,
|
|
90
|
-
member: 0,
|
|
91
|
-
"repo-infra": 0
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
function readDirectoryEntries(path) {
|
|
95
|
-
try {
|
|
96
|
-
return readdirSync(path, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
|
|
97
|
-
} catch {
|
|
98
|
-
return [];
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
function canonicalTarget$1(realTreeRoot, path) {
|
|
102
|
-
try {
|
|
103
|
-
const realTarget = realpathSync(path);
|
|
104
|
-
if (!isPathInside(realTreeRoot, realTarget)) return { kind: "escaped" };
|
|
105
|
-
return {
|
|
106
|
-
kind: "resolved",
|
|
107
|
-
relativePath: toTreeRelativePosixPath(realTreeRoot, realTarget)
|
|
108
|
-
};
|
|
109
|
-
} catch {
|
|
110
|
-
return { kind: "unresolved" };
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
function inspectMarkdownSymlink(realTreeRoot, absolutePath, contentClass) {
|
|
114
|
-
let targetStat;
|
|
115
|
-
try {
|
|
116
|
-
targetStat = statSync(absolutePath);
|
|
117
|
-
} catch {
|
|
118
|
-
return { kind: "unresolved" };
|
|
119
|
-
}
|
|
120
|
-
const target = canonicalTarget$1(realTreeRoot, absolutePath);
|
|
121
|
-
if (target.kind !== "resolved") return target;
|
|
122
|
-
if (!targetStat.isFile()) return { kind: "unsupported" };
|
|
123
|
-
if (classifyContextContent(target.relativePath) !== contentClass) return {
|
|
124
|
-
kind: "content-class-mismatch",
|
|
125
|
-
canonicalRelativePath: target.relativePath
|
|
126
|
-
};
|
|
127
|
-
return { kind: "regular" };
|
|
128
|
-
}
|
|
129
|
-
function collectContextMarkdownContent(treeRoot) {
|
|
130
|
-
const directories = [];
|
|
131
|
-
const directorySymlinks = [];
|
|
132
|
-
const files = [];
|
|
133
|
-
const realTreeRoot = realpathSync(treeRoot);
|
|
134
|
-
function walk(directoryPath) {
|
|
135
|
-
for (const entry of readDirectoryEntries(directoryPath)) {
|
|
136
|
-
const absolutePath = join(directoryPath, entry.name);
|
|
137
|
-
const relativePath = toTreeRelativePosixPath(treeRoot, absolutePath);
|
|
138
|
-
const contentClass = classifyContextContent(relativePath);
|
|
139
|
-
if (entry.isDirectory()) {
|
|
140
|
-
if (contentClass !== "repo-infra") {
|
|
141
|
-
directories.push(relativePath);
|
|
142
|
-
walk(absolutePath);
|
|
143
|
-
}
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
const symbolicLink = entry.isSymbolicLink();
|
|
147
|
-
if (symbolicLink) try {
|
|
148
|
-
if (statSync(absolutePath).isDirectory()) {
|
|
149
|
-
if (contentClass !== "repo-infra" || entry.name.endsWith(".md")) {
|
|
150
|
-
const target = canonicalTarget$1(realTreeRoot, absolutePath);
|
|
151
|
-
directorySymlinks.push({
|
|
152
|
-
escaped: target.kind === "escaped",
|
|
153
|
-
relativePath
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
} catch {
|
|
159
|
-
if (MANAGED_SYMLINK_PATHS.has(relativePath)) continue;
|
|
160
|
-
if (entry.name.endsWith(".md")) files.push({
|
|
161
|
-
absolutePath,
|
|
162
|
-
contentClass,
|
|
163
|
-
inspection: { kind: "unresolved" },
|
|
164
|
-
relativePath
|
|
165
|
-
});
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
if (!entry.isFile() && !symbolicLink || !entry.name.endsWith(".md")) continue;
|
|
169
|
-
if (symbolicLink && MANAGED_SYMLINK_PATHS.has(relativePath)) continue;
|
|
170
|
-
files.push({
|
|
171
|
-
absolutePath,
|
|
172
|
-
contentClass,
|
|
173
|
-
inspection: symbolicLink ? inspectMarkdownSymlink(realTreeRoot, absolutePath, contentClass) : { kind: "regular" },
|
|
174
|
-
relativePath
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
walk(treeRoot);
|
|
179
|
-
return {
|
|
180
|
-
directories,
|
|
181
|
-
directorySymlinks,
|
|
182
|
-
files
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
//#endregion
|
|
186
|
-
//#region src/core/internal/filesystem.ts
|
|
187
|
-
function readUtf8File(path) {
|
|
188
|
-
return new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path));
|
|
189
|
-
}
|
|
190
|
-
//#endregion
|
|
191
|
-
//#region src/core/internal/context-document.ts
|
|
192
|
-
function readContextDocument(path) {
|
|
193
|
-
try {
|
|
194
|
-
return parseMarkdownFrontmatter(readUtf8File(path));
|
|
195
|
-
} catch (error) {
|
|
196
|
-
return {
|
|
197
|
-
body: "",
|
|
198
|
-
data: null,
|
|
199
|
-
error: error instanceof Error ? error.message : String(error),
|
|
200
|
-
frontmatter: "invalid"
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
function readNonEmptyStringField(data, key) {
|
|
205
|
-
if (!(key in data)) return {
|
|
206
|
-
present: false,
|
|
207
|
-
valid: false
|
|
208
|
-
};
|
|
209
|
-
const value = data[key];
|
|
210
|
-
if (typeof value !== "string" || value.trim().length === 0) return {
|
|
211
|
-
present: true,
|
|
212
|
-
valid: false
|
|
213
|
-
};
|
|
214
|
-
return {
|
|
215
|
-
present: true,
|
|
216
|
-
valid: true,
|
|
217
|
-
value: value.trim()
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
function readNonEmptyStringArrayField(data, key) {
|
|
221
|
-
if (!(key in data)) return {
|
|
222
|
-
present: false,
|
|
223
|
-
valid: false
|
|
224
|
-
};
|
|
225
|
-
const value = data[key];
|
|
226
|
-
if (!Array.isArray(value) || value.length === 0) return {
|
|
227
|
-
present: true,
|
|
228
|
-
valid: false
|
|
229
|
-
};
|
|
230
|
-
const items = [];
|
|
231
|
-
for (const item of value) {
|
|
232
|
-
if (typeof item !== "string" || item.trim().length === 0) return {
|
|
233
|
-
present: true,
|
|
234
|
-
valid: false
|
|
235
|
-
};
|
|
236
|
-
items.push(item.trim());
|
|
237
|
-
}
|
|
238
|
-
return {
|
|
239
|
-
present: true,
|
|
240
|
-
valid: true,
|
|
241
|
-
value: items
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
function readNodeDocument(path) {
|
|
245
|
-
const document = readContextDocument(path);
|
|
246
|
-
if (document.frontmatter !== "valid") return null;
|
|
247
|
-
const title = readNonEmptyStringField(document.data, "title");
|
|
248
|
-
const description = readNonEmptyStringField(document.data, "description");
|
|
249
|
-
if (!title.valid || description.present && !description.valid) return null;
|
|
250
|
-
return {
|
|
251
|
-
body: document.body,
|
|
252
|
-
frontmatter: document.data,
|
|
253
|
-
title: title.value,
|
|
254
|
-
...description.valid ? { description: description.value } : {}
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
//#endregion
|
|
258
|
-
//#region src/core/read.ts
|
|
259
|
-
function normalizeTreeTarget(value) {
|
|
260
|
-
if (!value || value === ".") return "";
|
|
261
|
-
const normalized = posix.normalize(toPosixPath(value).replace(/^\.\//u, ""));
|
|
262
|
-
if (normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/")) throw new Error(`Read target is outside the Context Tree: ${value}`);
|
|
263
|
-
return normalized.replace(/\/$/u, "");
|
|
264
|
-
}
|
|
265
|
-
function canonicalTarget(root, path) {
|
|
266
|
-
const requested = normalizeTreeTarget(path);
|
|
267
|
-
const semanticPath = requested === "NODE.md" ? "" : requested.endsWith("/NODE.md") ? dirname(requested) : requested;
|
|
268
|
-
if (classifyContextContent(semanticPath) === "repo-infra") throw new Error(`Read target is repository infrastructure: ${requested || "."}`);
|
|
269
|
-
const absolutePath = resolve(root, semanticPath);
|
|
270
|
-
if (!isPathInside(root, absolutePath)) throw new Error("Read target escapes the Context Tree root.");
|
|
271
|
-
const entry = lstatSync(absolutePath);
|
|
272
|
-
if (entry.isSymbolicLink() || !entry.isDirectory() && !entry.isFile()) throw new Error(`Read target must be a real file or directory: ${requested || "."}`);
|
|
273
|
-
if (realpathSync(absolutePath) !== absolutePath) throw new Error(`Read target must not traverse a symlink: ${requested || "."}`);
|
|
274
|
-
const relativePath = toPosixPath(relative(root, absolutePath));
|
|
275
|
-
if (entry.isFile() && !absolutePath.endsWith(".md")) throw new Error(`Read target must be a Markdown file or indexed directory: ${requested || "."}`);
|
|
276
|
-
return {
|
|
277
|
-
absolutePath,
|
|
278
|
-
kind: entry.isDirectory() ? "directory" : "file",
|
|
279
|
-
relativePath
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
function readNode(path, relativePath, kind) {
|
|
283
|
-
const documentPath = kind === "directory" ? join(path, "NODE.md") : path;
|
|
284
|
-
const entry = lstatSync(documentPath);
|
|
285
|
-
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error(`Context Tree document must be a regular file: ${relativePath || "NODE.md"}`);
|
|
286
|
-
const document = readNodeDocument(documentPath);
|
|
287
|
-
if (document === null) throw new Error(`Context Tree document has invalid or missing metadata: ${relativePath || "."}`);
|
|
288
|
-
return {
|
|
289
|
-
body: document.body,
|
|
290
|
-
contentClass: classifyContextContent(relativePath),
|
|
291
|
-
frontmatter: document.frontmatter,
|
|
292
|
-
kind,
|
|
293
|
-
path: relativePath || "."
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
function childSummary(root, parentPath, name) {
|
|
297
|
-
const absolutePath = join(parentPath, name);
|
|
298
|
-
const relativePath = toPosixPath(relative(root, absolutePath));
|
|
299
|
-
const contentClass = classifyContextContent(relativePath);
|
|
300
|
-
if (contentClass === "repo-infra") return null;
|
|
301
|
-
const entry = lstatSync(absolutePath);
|
|
302
|
-
if (entry.isSymbolicLink()) return null;
|
|
303
|
-
const kind = entry.isDirectory() ? "directory" : entry.isFile() && name.endsWith(".md") && name !== "NODE.md" ? "file" : null;
|
|
304
|
-
if (kind === null) return null;
|
|
305
|
-
const document = readNodeDocument(kind === "directory" ? join(absolutePath, "NODE.md") : absolutePath);
|
|
306
|
-
if (document === null) throw new Error(`Context Tree child has invalid or missing metadata: ${relativePath}`);
|
|
307
|
-
return {
|
|
308
|
-
contentClass,
|
|
309
|
-
...document.description === void 0 ? {} : { description: document.description },
|
|
310
|
-
kind,
|
|
311
|
-
path: relativePath,
|
|
312
|
-
title: document.title
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
function readTree(treePath, path) {
|
|
316
|
-
const root = resolveTreeRoot(treePath);
|
|
317
|
-
const target = canonicalTarget(root, path);
|
|
318
|
-
const node = readNode(target.absolutePath, target.relativePath, target.kind);
|
|
319
|
-
return {
|
|
320
|
-
children: target.kind === "file" ? [] : readdirSync(target.absolutePath).map((name) => childSummary(root, target.absolutePath, name)).filter((child) => child !== null).sort((left, right) => left.path.localeCompare(right.path)),
|
|
321
|
-
node,
|
|
322
|
-
root,
|
|
323
|
-
schemaVersion: 1,
|
|
324
|
-
target: target.relativePath || "."
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
//#endregion
|
|
328
|
-
//#region src/core/internal/github-repository.ts
|
|
329
|
-
function parseGitHubRepositoryIdentity(repository) {
|
|
330
|
-
const repositoryParts = repository.split("/");
|
|
331
|
-
const [owner, name] = repositoryParts;
|
|
332
|
-
if (repositoryParts.length !== 2 || owner === void 0 || name === void 0 || !/^[A-Za-z\d](?:[A-Za-z\d-]{0,37}[A-Za-z\d])?$/u.test(owner) || !/^[A-Za-z\d._-]{1,100}$/u.test(name) || name === "." || name === ".." || /\.git$/iu.test(name)) throw new Error("Repository must be an explicit GitHub OWNER/REPO identity.");
|
|
333
|
-
return name;
|
|
334
|
-
}
|
|
335
|
-
//#endregion
|
|
336
|
-
//#region src/core/internal/context-links.ts
|
|
337
|
-
function stripQueryAndFragment(target) {
|
|
338
|
-
const indexes = [target.indexOf("?"), target.indexOf("#")].filter((index) => index >= 0);
|
|
339
|
-
const end = indexes.length === 0 ? target.length : Math.min(...indexes);
|
|
340
|
-
return target.slice(0, end);
|
|
341
|
-
}
|
|
342
|
-
function decodeTarget(target) {
|
|
343
|
-
try {
|
|
344
|
-
return decodeURIComponent(target);
|
|
345
|
-
} catch {
|
|
346
|
-
return target;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
function isWindowsAbsoluteTarget(target) {
|
|
350
|
-
return /^[a-z]:[\\/]/iu.test(target) || /^\\/u.test(target);
|
|
351
|
-
}
|
|
352
|
-
function isTreeLocalTarget(target) {
|
|
353
|
-
const trimmed = target.trim();
|
|
354
|
-
if (isWindowsAbsoluteTarget(decodeTarget(stripQueryAndFragment(trimmed)))) return true;
|
|
355
|
-
return trimmed.length > 0 && !trimmed.startsWith("#") && !trimmed.startsWith("//") && !/^[a-z][a-z\d+.-]*:/iu.test(trimmed);
|
|
356
|
-
}
|
|
357
|
-
function targetExists(path, softLink) {
|
|
358
|
-
try {
|
|
359
|
-
const stat = statSync(path);
|
|
360
|
-
if (stat.isFile()) return !softLink || path.endsWith(".md");
|
|
361
|
-
return stat.isDirectory() && (!softLink || existsSync(resolve(path, "NODE.md")));
|
|
362
|
-
} catch {
|
|
363
|
-
return false;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
function resolveLocalTreeTarget(options) {
|
|
367
|
-
if (!isTreeLocalTarget(options.target)) return null;
|
|
368
|
-
const decodedTarget = decodeTarget(stripQueryAndFragment(options.target.trim()));
|
|
369
|
-
const withoutSuffix = decodedTarget.replace(/\\/gu, "/");
|
|
370
|
-
if (withoutSuffix.length === 0) return null;
|
|
371
|
-
if (isWindowsAbsoluteTarget(decodedTarget)) return "escaped-missing";
|
|
372
|
-
const sourceDirectory = posix.dirname(options.sourcePath);
|
|
373
|
-
const relativePath = posix.normalize(options.softLink || withoutSuffix.startsWith("/") ? withoutSuffix.replace(/^\/+/, "") : posix.join(sourceDirectory, withoutSuffix));
|
|
374
|
-
const absoluteRoot = resolve(options.treeRoot);
|
|
375
|
-
const absoluteTarget = resolve(absoluteRoot, relativePath);
|
|
376
|
-
if (!isPathInside(absoluteRoot, absoluteTarget)) return "escaped-missing";
|
|
377
|
-
if (!targetExists(absoluteTarget, options.softLink)) return "missing";
|
|
378
|
-
try {
|
|
379
|
-
if (!isPathInside(realpathSync(absoluteRoot), realpathSync(absoluteTarget))) return "escaped-existing";
|
|
380
|
-
} catch {
|
|
381
|
-
return "missing";
|
|
382
|
-
}
|
|
383
|
-
return "valid";
|
|
384
|
-
}
|
|
385
|
-
function readMarkdownLinkTargets(markdown) {
|
|
386
|
-
const root = fromMarkdown(markdown);
|
|
387
|
-
const targets = [];
|
|
388
|
-
function visit(node) {
|
|
389
|
-
if (!isRecord(node)) return;
|
|
390
|
-
if ((node.type === "link" || node.type === "image" || node.type === "definition") && typeof node.url === "string") targets.push(node.url);
|
|
391
|
-
if (Array.isArray(node.children)) for (const child of node.children) visit(child);
|
|
392
|
-
}
|
|
393
|
-
visit(root);
|
|
394
|
-
return targets;
|
|
395
|
-
}
|
|
396
|
-
//#endregion
|
|
397
|
-
//#region src/core/internal/validate-nodes.ts
|
|
398
|
-
function addFinding(findings, code, path, message, target) {
|
|
399
|
-
findings.push({
|
|
400
|
-
code,
|
|
401
|
-
message,
|
|
402
|
-
path,
|
|
403
|
-
...target === void 0 ? {} : { target }
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
function validateRequiredNodeMetadata(document, path, findings) {
|
|
407
|
-
if (document.frontmatter === "missing") {
|
|
408
|
-
addFinding(findings, VALIDATION_CODES.frontmatterMissing, path, "missing frontmatter");
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
if (document.frontmatter === "invalid") {
|
|
412
|
-
addFinding(findings, VALIDATION_CODES.frontmatterParse, path, `frontmatter could not be parsed: ${document.error}`);
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
const title = readNonEmptyStringField(document.data, "title");
|
|
416
|
-
if (!title.present) addFinding(findings, VALIDATION_CODES.titleMissing, path, "missing 'title' field in frontmatter");
|
|
417
|
-
else if (!title.valid) addFinding(findings, VALIDATION_CODES.titleInvalid, path, "'title' must be a non-empty string");
|
|
418
|
-
const description = readNonEmptyStringField(document.data, "description");
|
|
419
|
-
if (description.present && !description.valid) addFinding(findings, VALIDATION_CODES.descriptionInvalid, path, "'description' must be a non-empty string when present");
|
|
420
|
-
}
|
|
421
|
-
function validateRootOnlyFields(document, path, findings) {
|
|
422
|
-
if (path === "NODE.md" || document.frontmatter !== "valid") return;
|
|
423
|
-
const fields = ["schemaVersion", "relatedRepositories"].filter((field) => field in document.data);
|
|
424
|
-
if (fields.length > 0) addFinding(findings, VALIDATION_CODES.rootOnlyFields, path, `root-only frontmatter field${fields.length === 1 ? "" : "s"} must appear only in root NODE.md: ${fields.join(", ")}`);
|
|
425
|
-
}
|
|
426
|
-
function readSoftLinks(document, path, findings) {
|
|
427
|
-
if (document.frontmatter !== "valid") return [];
|
|
428
|
-
const softLinks = readNonEmptyStringArrayField(document.data, "soft_links");
|
|
429
|
-
if (!softLinks.present) return [];
|
|
430
|
-
if (!softLinks.valid) {
|
|
431
|
-
addFinding(findings, VALIDATION_CODES.softLinksInvalid, path, "'soft_links' must be a non-empty string array when present");
|
|
432
|
-
return [];
|
|
433
|
-
}
|
|
434
|
-
return softLinks.value;
|
|
435
|
-
}
|
|
436
|
-
function validateSoftLinks(options) {
|
|
437
|
-
for (const target of readSoftLinks(options.document, options.path, options.findings)) {
|
|
438
|
-
const resolved = resolveLocalTreeTarget({
|
|
439
|
-
sourcePath: options.path,
|
|
440
|
-
target,
|
|
441
|
-
treeRoot: options.treeRoot,
|
|
442
|
-
softLink: true
|
|
443
|
-
});
|
|
444
|
-
if (resolved === null || resolved === "missing" || resolved === "escaped-missing") addFinding(options.findings, VALIDATION_CODES.softLinkBroken, options.path, "broken soft_links target", target);
|
|
445
|
-
if (resolved === null) continue;
|
|
446
|
-
if (resolved === "escaped-existing" || resolved === "escaped-missing") addFinding(options.findings, VALIDATION_CODES.softLinkPathEscape, options.path, "soft_links target resolves outside the Context Tree root", target);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
function validateMarkdownLinks(document, path, treeRoot, findings) {
|
|
450
|
-
for (const target of readMarkdownLinkTargets(document.body)) {
|
|
451
|
-
const resolved = resolveLocalTreeTarget({
|
|
452
|
-
sourcePath: path,
|
|
453
|
-
target,
|
|
454
|
-
treeRoot,
|
|
455
|
-
softLink: false
|
|
456
|
-
});
|
|
457
|
-
if (resolved === null) continue;
|
|
458
|
-
if (resolved === "escaped-existing" || resolved === "escaped-missing") addFinding(findings, VALIDATION_CODES.markdownPathEscape, path, "Markdown link resolves outside the Context Tree root", target);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
function collectNodeValidationFindings(treeRoot) {
|
|
462
|
-
const findings = [];
|
|
463
|
-
const scannedByContentClass = emptyContentClassCounts();
|
|
464
|
-
const content = collectContextMarkdownContent(treeRoot);
|
|
465
|
-
for (const directory of content.directories) {
|
|
466
|
-
const nodePath = `${directory}/NODE.md`;
|
|
467
|
-
let hasRegularNode = false;
|
|
468
|
-
try {
|
|
469
|
-
const entry = lstatSync(join(treeRoot, nodePath));
|
|
470
|
-
hasRegularNode = entry.isFile() && !entry.isSymbolicLink();
|
|
471
|
-
} catch {}
|
|
472
|
-
if (!hasRegularNode) addFinding(findings, VALIDATION_CODES.directoryNodeMissing, directory, "Context Tree directory is missing NODE.md");
|
|
473
|
-
}
|
|
474
|
-
for (const directory of content.directorySymlinks) addFinding(findings, directory.escaped ? VALIDATION_CODES.directorySymlinkPathEscape : VALIDATION_CODES.directorySymlinkUnsupported, directory.relativePath, directory.escaped ? "directory symlink resolves outside the Context Tree root" : "Context Tree domain directories must not be symlinks");
|
|
475
|
-
for (const file of content.files) {
|
|
476
|
-
scannedByContentClass[file.contentClass] += 1;
|
|
477
|
-
if (file.inspection.kind === "unresolved") {
|
|
478
|
-
addFinding(findings, VALIDATION_CODES.markdownFileSymlinkBroken, file.relativePath, "Markdown file symlink target cannot be resolved");
|
|
479
|
-
continue;
|
|
480
|
-
}
|
|
481
|
-
if (file.inspection.kind === "escaped") {
|
|
482
|
-
addFinding(findings, VALIDATION_CODES.markdownFilePathEscape, file.relativePath, "Markdown file resolves outside the Context Tree root");
|
|
483
|
-
continue;
|
|
484
|
-
}
|
|
485
|
-
if (file.inspection.kind === "unsupported") {
|
|
486
|
-
addFinding(findings, VALIDATION_CODES.markdownFileSymlinkUnsupported, file.relativePath, "Markdown file symlink must resolve to a regular file");
|
|
487
|
-
continue;
|
|
488
|
-
}
|
|
489
|
-
if (file.inspection.kind === "content-class-mismatch") {
|
|
490
|
-
const canonicalContentClass = classifyContextContent(file.inspection.canonicalRelativePath);
|
|
491
|
-
addFinding(findings, VALIDATION_CODES.markdownFileContentClassMismatch, file.relativePath, `Markdown file symlink crosses content-class boundary from ${file.contentClass} to ${canonicalContentClass}`, file.inspection.canonicalRelativePath);
|
|
492
|
-
continue;
|
|
493
|
-
}
|
|
494
|
-
if (file.contentClass === "repo-infra") continue;
|
|
495
|
-
const document = readContextDocument(file.absolutePath);
|
|
496
|
-
validateRootOnlyFields(document, file.relativePath, findings);
|
|
497
|
-
if (file.relativePath !== "NODE.md" || document.frontmatter === "valid") validateRequiredNodeMetadata(document, file.relativePath, findings);
|
|
498
|
-
validateSoftLinks({
|
|
499
|
-
document,
|
|
500
|
-
findings,
|
|
501
|
-
path: file.relativePath,
|
|
502
|
-
treeRoot
|
|
503
|
-
});
|
|
504
|
-
validateMarkdownLinks(document, file.relativePath, treeRoot, findings);
|
|
505
|
-
}
|
|
506
|
-
return {
|
|
507
|
-
findings,
|
|
508
|
-
scannedByContentClass
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
//#endregion
|
|
512
|
-
//#region src/core/verify.ts
|
|
513
|
-
function rootNodeFindings(root) {
|
|
514
|
-
const path = join(root, "NODE.md");
|
|
515
|
-
if (!existsSync(path)) return [{
|
|
516
|
-
code: VALIDATION_CODES.rootMissing,
|
|
517
|
-
message: "root NODE.md is missing",
|
|
518
|
-
path: "NODE.md"
|
|
519
|
-
}];
|
|
520
|
-
try {
|
|
521
|
-
const entry = lstatSync(path);
|
|
522
|
-
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("Root NODE.md must be a regular file and must not be a symlink.");
|
|
523
|
-
parseContextTreeRootNode(readUtf8File(path));
|
|
524
|
-
return [];
|
|
525
|
-
} catch (error) {
|
|
526
|
-
return [{
|
|
527
|
-
code: VALIDATION_CODES.rootNodeInvalid,
|
|
528
|
-
message: error instanceof Error ? error.message : String(error),
|
|
529
|
-
path: "NODE.md"
|
|
530
|
-
}];
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
function deduplicate(findings) {
|
|
534
|
-
const seen = /* @__PURE__ */ new Set();
|
|
535
|
-
return findings.filter((finding) => {
|
|
536
|
-
const key = `${finding.code}\0${finding.path}\0${finding.target ?? ""}`;
|
|
537
|
-
if (seen.has(key)) return false;
|
|
538
|
-
seen.add(key);
|
|
539
|
-
return true;
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
function verifyTree(treePath) {
|
|
543
|
-
const root = resolveTreeRoot(treePath);
|
|
544
|
-
const nodeResult = collectNodeValidationFindings(root);
|
|
545
|
-
const findings = deduplicate([...rootNodeFindings(root), ...nodeResult.findings]);
|
|
546
|
-
return {
|
|
547
|
-
findings,
|
|
548
|
-
ok: findings.length === 0,
|
|
549
|
-
root,
|
|
550
|
-
scannedByContentClass: nodeResult.scannedByContentClass,
|
|
551
|
-
schemaVersion: 1
|
|
552
|
-
};
|
|
553
|
-
}
|
|
554
|
-
//#endregion
|
|
555
|
-
//#region src/core/scaffold.ts
|
|
556
|
-
function template(name, values) {
|
|
557
|
-
let result = readFileSync(resolvePackagedResource("templates", name), "utf8");
|
|
558
|
-
for (const [key, value] of Object.entries(values)) result = result.replaceAll(`{{${key}}}`, value);
|
|
559
|
-
return result;
|
|
560
|
-
}
|
|
561
|
-
function initializeGitRepository(root) {
|
|
562
|
-
const initialized = spawnSync("git", [
|
|
563
|
-
"init",
|
|
564
|
-
"--quiet",
|
|
565
|
-
root
|
|
566
|
-
], { stdio: "ignore" });
|
|
567
|
-
if (initialized.error !== void 0 || initialized.status !== 0) throw new Error("Failed to initialize Git repository.");
|
|
568
|
-
const branch = spawnSync("git", [
|
|
569
|
-
"-C",
|
|
570
|
-
root,
|
|
571
|
-
"symbolic-ref",
|
|
572
|
-
"--short",
|
|
573
|
-
"HEAD"
|
|
574
|
-
], {
|
|
575
|
-
encoding: "utf8",
|
|
576
|
-
stdio: [
|
|
577
|
-
"ignore",
|
|
578
|
-
"pipe",
|
|
579
|
-
"ignore"
|
|
580
|
-
]
|
|
581
|
-
});
|
|
582
|
-
const name = branch.stdout.replace(/\r?\n$/u, "");
|
|
583
|
-
if (branch.error !== void 0 || branch.status !== 0 || name.length === 0) throw new Error("Failed to resolve the initial Git branch during repository initialization.");
|
|
584
|
-
return name;
|
|
585
|
-
}
|
|
586
|
-
function scaffoldTree(options) {
|
|
587
|
-
const title = parseGitHubRepositoryIdentity(options.repository);
|
|
588
|
-
const root = resolve(options.path);
|
|
589
|
-
const destination = lstatSync(root, { throwIfNoEntry: false });
|
|
590
|
-
if (destination !== void 0) {
|
|
591
|
-
if (destination.isSymbolicLink() || !destination.isDirectory()) throw new Error(`Refusing to scaffold into a symlink or non-directory destination: ${root}`);
|
|
592
|
-
if (readdirSync(root).length > 0) throw new Error(`Refusing to scaffold into a non-empty directory: ${root}`);
|
|
593
|
-
}
|
|
594
|
-
const initialBranch = initializeGitRepository(root);
|
|
595
|
-
const values = {
|
|
596
|
-
branchJson: JSON.stringify(initialBranch),
|
|
597
|
-
packageVersion: readPackageVersion(),
|
|
598
|
-
title,
|
|
599
|
-
titleJson: JSON.stringify(title)
|
|
600
|
-
};
|
|
601
|
-
const files = [["NODE.md", "root-node.md"], [".github/workflows/validate-context-tree.yml", "validate-context-tree.yml"]];
|
|
602
|
-
for (const [relativePath, source] of files) {
|
|
603
|
-
const path = join(root, relativePath);
|
|
604
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
605
|
-
writeFileSync(path, template(source, values), {
|
|
606
|
-
encoding: "utf8",
|
|
607
|
-
flag: "wx",
|
|
608
|
-
mode: 420
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
return {
|
|
612
|
-
files: files.map(([path]) => path),
|
|
613
|
-
root,
|
|
614
|
-
schemaVersion: 1,
|
|
615
|
-
verification: verifyTree(root)
|
|
616
|
-
};
|
|
617
|
-
}
|
|
618
|
-
//#endregion
|
|
619
|
-
export { readContextTreePolicy as a, readTree as i, verifyTree as n, readPackageVersion as o, parseGitHubRepositoryIdentity as r, scaffoldTree as t };
|