first-tree 0.0.2 → 0.0.4
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 +116 -40
- package/dist/cli.js +46 -17
- package/dist/help-Dtdj91HJ.js +25 -0
- package/dist/init--VepFe6N.js +403 -0
- package/dist/installer-cH7N4RNj.js +47 -0
- package/dist/onboarding-C9cYSE6F.js +2 -0
- package/dist/onboarding-CPP8fF4D.js +10 -0
- package/dist/repo-DY57bMqr.js +318 -0
- package/dist/upgrade-Cgx_K2HM.js +135 -0
- package/dist/{verify-CSRIkuoM.js → verify-mC9ZTd1f.js} +118 -29
- package/package.json +33 -10
- package/skills/first-tree/SKILL.md +113 -0
- package/skills/first-tree/agents/openai.yaml +4 -0
- package/skills/first-tree/assets/framework/VERSION +1 -0
- package/skills/first-tree/assets/framework/examples/claude-code/README.md +14 -0
- package/skills/first-tree/assets/framework/examples/claude-code/settings.json +14 -0
- package/skills/first-tree/assets/framework/helpers/generate-codeowners.ts +224 -0
- package/skills/first-tree/assets/framework/helpers/inject-tree-context.sh +15 -0
- package/skills/first-tree/assets/framework/helpers/run-review.ts +193 -0
- package/skills/first-tree/assets/framework/manifest.json +11 -0
- package/skills/first-tree/assets/framework/prompts/pr-review.md +38 -0
- package/skills/first-tree/assets/framework/templates/agents.md.template +49 -0
- package/skills/first-tree/assets/framework/templates/member-node.md.template +18 -0
- package/skills/first-tree/assets/framework/templates/members-domain.md.template +45 -0
- package/skills/first-tree/assets/framework/templates/root-node.md.template +41 -0
- package/skills/first-tree/assets/framework/workflows/codeowners.yml +31 -0
- package/skills/first-tree/assets/framework/workflows/pr-review.yml +146 -0
- package/skills/first-tree/assets/framework/workflows/validate.yml +19 -0
- package/skills/first-tree/engine/commands/help.ts +32 -0
- package/skills/first-tree/engine/commands/init.ts +1 -0
- package/skills/first-tree/engine/commands/upgrade.ts +1 -0
- package/skills/first-tree/engine/commands/verify.ts +1 -0
- package/skills/first-tree/engine/init.ts +414 -0
- package/skills/first-tree/engine/onboarding.ts +10 -0
- package/skills/first-tree/engine/repo.ts +360 -0
- package/skills/first-tree/engine/rules/agent-instructions.ts +59 -0
- package/skills/first-tree/engine/rules/agent-integration.ts +19 -0
- package/skills/first-tree/engine/rules/ci-validation.ts +72 -0
- package/skills/first-tree/engine/rules/framework.ts +13 -0
- package/skills/first-tree/engine/rules/index.ts +41 -0
- package/skills/first-tree/engine/rules/members.ts +21 -0
- package/skills/first-tree/engine/rules/populate-tree.ts +36 -0
- package/skills/first-tree/engine/rules/root-node.ts +41 -0
- package/skills/first-tree/engine/runtime/adapters.ts +22 -0
- package/skills/first-tree/engine/runtime/asset-loader.ts +141 -0
- package/skills/first-tree/engine/runtime/installer.ts +82 -0
- package/skills/first-tree/engine/runtime/upgrader.ts +23 -0
- package/skills/first-tree/engine/upgrade.ts +233 -0
- package/skills/first-tree/engine/validators/members.ts +215 -0
- package/skills/first-tree/engine/validators/nodes.ts +559 -0
- package/skills/first-tree/engine/verify.ts +155 -0
- package/skills/first-tree/references/about.md +36 -0
- package/skills/first-tree/references/maintainer-architecture.md +59 -0
- package/skills/first-tree/references/maintainer-build-and-distribution.md +59 -0
- package/skills/first-tree/references/maintainer-testing.md +58 -0
- package/skills/first-tree/references/maintainer-thin-cli.md +38 -0
- package/skills/first-tree/references/onboarding.md +185 -0
- package/skills/first-tree/references/ownership-and-naming.md +94 -0
- package/skills/first-tree/references/principles.md +113 -0
- package/skills/first-tree/references/source-map.md +94 -0
- package/skills/first-tree/references/upgrade-contract.md +94 -0
- package/skills/first-tree/scripts/check-skill-sync.sh +133 -0
- package/skills/first-tree/scripts/quick_validate.py +95 -0
- package/skills/first-tree/scripts/run-local-cli.sh +35 -0
- package/skills/first-tree/tests/asset-loader.test.ts +75 -0
- package/skills/first-tree/tests/generate-codeowners.test.ts +94 -0
- package/skills/first-tree/tests/helpers.ts +169 -0
- package/skills/first-tree/tests/init.test.ts +250 -0
- package/skills/first-tree/tests/repo.test.ts +440 -0
- package/skills/first-tree/tests/rules.test.ts +413 -0
- package/skills/first-tree/tests/run-review.test.ts +155 -0
- package/skills/first-tree/tests/skill-artifacts.test.ts +311 -0
- package/skills/first-tree/tests/thin-cli.test.ts +104 -0
- package/skills/first-tree/tests/upgrade.test.ts +103 -0
- package/skills/first-tree/tests/validate-members.test.ts +224 -0
- package/skills/first-tree/tests/validate-nodes.test.ts +198 -0
- package/skills/first-tree/tests/verify.test.ts +241 -0
- package/dist/init-CE_944sb.js +0 -283
- package/dist/repo-BByc3VvM.js +0 -111
- package/dist/upgrade-Chr7z0CY.js +0 -82
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, posix } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
AGENT_INSTRUCTIONS_FILE,
|
|
5
|
+
LEGACY_AGENT_INSTRUCTIONS_FILE,
|
|
6
|
+
LEGACY_SKILL_NAME,
|
|
7
|
+
LEGACY_SKILL_ROOT,
|
|
8
|
+
SKILL_NAME,
|
|
9
|
+
SKILL_ROOT,
|
|
10
|
+
} from "#skill/engine/runtime/asset-loader.js";
|
|
11
|
+
|
|
12
|
+
const FRONTMATTER_RE = /^---\s*\n(.*?)\n---/s;
|
|
13
|
+
const OWNERS_RE = /^owners:\s*\[([^\]]*)\]/m;
|
|
14
|
+
const SOFT_LINKS_INLINE_RE = /^soft_links:\s*\[([^\]]*)\]/m;
|
|
15
|
+
const SOFT_LINKS_BLOCK_RE = /^soft_links:\s*\n((?:\s+-\s+.+\n?)+)/m;
|
|
16
|
+
const TITLE_RE = /^title:\s*['"]?(.+?)['"]?\s*$/m;
|
|
17
|
+
const GITHUB_USER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/;
|
|
18
|
+
const MD_LINK_RE = /\[.*?\]\(([^)]+\.md)\)/g;
|
|
19
|
+
const DOMAIN_LINK_RE = /\[(\w[\w-]*)\/?\]\((\w[\w-]*)\/NODE\.md\)/g;
|
|
20
|
+
|
|
21
|
+
const SKIP = new Set(["node_modules", "__pycache__"]);
|
|
22
|
+
const SKIP_FILES = new Set([
|
|
23
|
+
AGENT_INSTRUCTIONS_FILE,
|
|
24
|
+
LEGACY_AGENT_INSTRUCTIONS_FILE,
|
|
25
|
+
"CLAUDE.md",
|
|
26
|
+
]);
|
|
27
|
+
const MIN_BODY_LENGTH = 20;
|
|
28
|
+
|
|
29
|
+
export class Findings {
|
|
30
|
+
errors: string[] = [];
|
|
31
|
+
warnings: string[] = [];
|
|
32
|
+
infos: string[] = [];
|
|
33
|
+
|
|
34
|
+
error(msg: string): void {
|
|
35
|
+
this.errors.push(msg);
|
|
36
|
+
}
|
|
37
|
+
warning(msg: string): void {
|
|
38
|
+
this.warnings.push(msg);
|
|
39
|
+
}
|
|
40
|
+
info(msg: string): void {
|
|
41
|
+
this.infos.push(msg);
|
|
42
|
+
}
|
|
43
|
+
hasErrors(): boolean {
|
|
44
|
+
return this.errors.length > 0;
|
|
45
|
+
}
|
|
46
|
+
printReport(totalFiles: number): void {
|
|
47
|
+
const all: [string, string][] = [
|
|
48
|
+
...this.errors.map((e): [string, string] => ["error", e]),
|
|
49
|
+
...this.warnings.map((w): [string, string] => ["warning", w]),
|
|
50
|
+
...this.infos.map((i): [string, string] => ["info", i]),
|
|
51
|
+
];
|
|
52
|
+
if (all.length > 0) {
|
|
53
|
+
const counts: string[] = [];
|
|
54
|
+
if (this.errors.length) counts.push(`${this.errors.length} error(s)`);
|
|
55
|
+
if (this.warnings.length) counts.push(`${this.warnings.length} warning(s)`);
|
|
56
|
+
if (this.infos.length) counts.push(`${this.infos.length} info(s)`);
|
|
57
|
+
console.log(`Found ${counts.join(", ")}:\n`);
|
|
58
|
+
const icons: Record<string, string> = {
|
|
59
|
+
error: "\u2717",
|
|
60
|
+
warning: "\u26a0",
|
|
61
|
+
info: "\u2139",
|
|
62
|
+
};
|
|
63
|
+
for (const [severity, msg] of all) {
|
|
64
|
+
console.log(` ${icons[severity]} [${severity}] ${msg}`);
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
console.log(`All ${totalFiles} node(s) passed validation.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// -- Utilities --
|
|
73
|
+
|
|
74
|
+
let treeRoot = "";
|
|
75
|
+
const textCache = new Map<string, string | null>();
|
|
76
|
+
|
|
77
|
+
export function setTreeRoot(root: string): void {
|
|
78
|
+
treeRoot = root;
|
|
79
|
+
textCache.clear();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getTreeRoot(): string {
|
|
83
|
+
return treeRoot;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function rel(path: string): string {
|
|
87
|
+
return relative(treeRoot, path);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isInstalledSkillPath(relPath: string): boolean {
|
|
91
|
+
return (
|
|
92
|
+
relPath === SKILL_ROOT ||
|
|
93
|
+
relPath.startsWith(`${SKILL_ROOT}/`) ||
|
|
94
|
+
relPath === LEGACY_SKILL_ROOT ||
|
|
95
|
+
relPath.startsWith(`${LEGACY_SKILL_ROOT}/`)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isFrameworkContainerDir(relPath: string, fullPath: string): boolean {
|
|
100
|
+
if (relPath !== "skills") {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const entries = readdirSync(fullPath).filter((entry) => !entry.startsWith("."));
|
|
106
|
+
if (entries.length === 0) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return entries.every(
|
|
110
|
+
(entry) => entry === SKILL_NAME || entry === LEGACY_SKILL_NAME,
|
|
111
|
+
);
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function shouldSkip(path: string): boolean {
|
|
118
|
+
const relPath = relative(treeRoot, path);
|
|
119
|
+
const parts = relPath.split("/");
|
|
120
|
+
|
|
121
|
+
if (parts.some((part) => SKIP.has(part) || part.startsWith("."))) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return isInstalledSkillPath(relPath) || isFrameworkContainerDir(relPath, path);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readText(path: string): string | null {
|
|
129
|
+
if (!textCache.has(path)) {
|
|
130
|
+
try {
|
|
131
|
+
textCache.set(path, readFileSync(path, "utf-8"));
|
|
132
|
+
} catch {
|
|
133
|
+
textCache.set(path, null);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return textCache.get(path)!;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parseFrontmatter(path: string): string | null {
|
|
140
|
+
const text = readText(path);
|
|
141
|
+
if (text === null) return null;
|
|
142
|
+
const m = text.match(FRONTMATTER_RE);
|
|
143
|
+
return m ? m[1] : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function parseBody(path: string): string | null {
|
|
147
|
+
const text = readText(path);
|
|
148
|
+
if (text === null) return null;
|
|
149
|
+
const m = text.match(FRONTMATTER_RE);
|
|
150
|
+
if (m) return text.slice(m[0].length);
|
|
151
|
+
return text;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function parseSoftLinks(fm: string): string[] | null {
|
|
155
|
+
// Inline format
|
|
156
|
+
let m = fm.match(SOFT_LINKS_INLINE_RE);
|
|
157
|
+
if (m) {
|
|
158
|
+
const raw = m[1].trim();
|
|
159
|
+
if (!raw) return [];
|
|
160
|
+
return raw
|
|
161
|
+
.split(",")
|
|
162
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
|
163
|
+
.filter(Boolean);
|
|
164
|
+
}
|
|
165
|
+
// Block format
|
|
166
|
+
m = fm.match(SOFT_LINKS_BLOCK_RE);
|
|
167
|
+
if (m) {
|
|
168
|
+
return m[1]
|
|
169
|
+
.trim()
|
|
170
|
+
.split("\n")
|
|
171
|
+
.map((line) =>
|
|
172
|
+
line
|
|
173
|
+
.trim()
|
|
174
|
+
.replace(/^-\s*/, "")
|
|
175
|
+
.trim()
|
|
176
|
+
.replace(/^['"]|['"]$/g, ""),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resolveSoftLink(link: string): boolean {
|
|
183
|
+
const clean = link.replace(/^\/+/, "");
|
|
184
|
+
const target = join(treeRoot, clean);
|
|
185
|
+
|
|
186
|
+
// Direct .md file
|
|
187
|
+
try {
|
|
188
|
+
if (statSync(target).isFile() && target.endsWith(".md")) return true;
|
|
189
|
+
} catch {
|
|
190
|
+
// not found
|
|
191
|
+
}
|
|
192
|
+
// Directory with NODE.md
|
|
193
|
+
try {
|
|
194
|
+
if (statSync(target).isDirectory() && existsSync(join(target, "NODE.md")))
|
|
195
|
+
return true;
|
|
196
|
+
} catch {
|
|
197
|
+
// not found
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeSoftLink(link: string): string {
|
|
203
|
+
const clean = link.replace(/^\/+/, "");
|
|
204
|
+
const target = join(treeRoot, clean);
|
|
205
|
+
try {
|
|
206
|
+
if (statSync(target).isDirectory()) return join(target, "NODE.md");
|
|
207
|
+
} catch {
|
|
208
|
+
// not a directory
|
|
209
|
+
}
|
|
210
|
+
return target;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function collectMdFiles(): string[] {
|
|
214
|
+
const files: string[] = [];
|
|
215
|
+
function walk(dir: string): void {
|
|
216
|
+
let entries: string[];
|
|
217
|
+
try {
|
|
218
|
+
entries = readdirSync(dir).sort();
|
|
219
|
+
} catch {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
const full = join(dir, entry);
|
|
224
|
+
if (shouldSkip(full)) continue;
|
|
225
|
+
try {
|
|
226
|
+
const stat = statSync(full);
|
|
227
|
+
if (stat.isDirectory()) {
|
|
228
|
+
walk(full);
|
|
229
|
+
} else if (
|
|
230
|
+
stat.isFile() &&
|
|
231
|
+
entry.endsWith(".md") &&
|
|
232
|
+
!SKIP_FILES.has(entry)
|
|
233
|
+
) {
|
|
234
|
+
files.push(full);
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
// skip
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
walk(treeRoot);
|
|
242
|
+
return files;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// -- Validation checks --
|
|
246
|
+
|
|
247
|
+
export function validateOwners(
|
|
248
|
+
fm: string,
|
|
249
|
+
path: string,
|
|
250
|
+
findings: Findings,
|
|
251
|
+
): void {
|
|
252
|
+
const m = fm.match(OWNERS_RE);
|
|
253
|
+
if (!m) {
|
|
254
|
+
findings.error(`${rel(path)}: missing 'owners' field in frontmatter`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const raw = m[1].trim();
|
|
258
|
+
if (!raw) return; // owners: [] is valid (inheritance)
|
|
259
|
+
|
|
260
|
+
const owners = raw
|
|
261
|
+
.split(",")
|
|
262
|
+
.map((o) => o.trim())
|
|
263
|
+
.filter(Boolean);
|
|
264
|
+
if (owners.length === 0) {
|
|
265
|
+
findings.error(`${rel(path)}: owners list contains only whitespace entries`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (owners.length === 1 && owners[0] === "*") return; // owners: [*] valid
|
|
269
|
+
|
|
270
|
+
for (const owner of owners) {
|
|
271
|
+
if (owner === "*") {
|
|
272
|
+
findings.error(
|
|
273
|
+
`${rel(path)}: wildcard '*' must be the sole entry, not mixed with usernames`,
|
|
274
|
+
);
|
|
275
|
+
} else if (!GITHUB_USER_RE.test(owner)) {
|
|
276
|
+
findings.error(`${rel(path)}: invalid owner '${owner}'`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function validateSoftLinks(
|
|
282
|
+
fm: string,
|
|
283
|
+
path: string,
|
|
284
|
+
findings: Findings,
|
|
285
|
+
): void {
|
|
286
|
+
const links = parseSoftLinks(fm);
|
|
287
|
+
if (links === null) return;
|
|
288
|
+
for (const link of links) {
|
|
289
|
+
if (!link) {
|
|
290
|
+
findings.error(`${rel(path)}: empty soft_link entry`);
|
|
291
|
+
} else if (!resolveSoftLink(link)) {
|
|
292
|
+
findings.error(
|
|
293
|
+
`${rel(path)}: soft_link '${link}' does not resolve to an existing node`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function validateFolders(findings: Findings): void {
|
|
300
|
+
function walk(dir: string): void {
|
|
301
|
+
let entries: string[];
|
|
302
|
+
try {
|
|
303
|
+
entries = readdirSync(dir).sort();
|
|
304
|
+
} catch {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
for (const entry of entries) {
|
|
308
|
+
const full = join(dir, entry);
|
|
309
|
+
if (shouldSkip(full)) continue;
|
|
310
|
+
try {
|
|
311
|
+
if (!statSync(full).isDirectory()) continue;
|
|
312
|
+
} catch {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (!existsSync(join(full, "NODE.md"))) {
|
|
316
|
+
findings.error(`${rel(full)}/: missing NODE.md`);
|
|
317
|
+
}
|
|
318
|
+
walk(full);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
walk(treeRoot);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function validateDirectoryListing(findings: Findings): void {
|
|
325
|
+
function walk(dir: string): void {
|
|
326
|
+
let entries: string[];
|
|
327
|
+
try {
|
|
328
|
+
entries = readdirSync(dir).sort();
|
|
329
|
+
} catch {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
for (const entry of entries) {
|
|
333
|
+
const full = join(dir, entry);
|
|
334
|
+
if (shouldSkip(full)) continue;
|
|
335
|
+
try {
|
|
336
|
+
if (!statSync(full).isDirectory()) continue;
|
|
337
|
+
} catch {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const nodeMd = join(full, "NODE.md");
|
|
341
|
+
if (!existsSync(nodeMd)) {
|
|
342
|
+
walk(full);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const body = parseBody(nodeMd);
|
|
346
|
+
if (body !== null) {
|
|
347
|
+
const actualLeaves = new Set<string>();
|
|
348
|
+
for (const f of readdirSync(full).sort()) {
|
|
349
|
+
const fp = join(full, f);
|
|
350
|
+
try {
|
|
351
|
+
if (statSync(fp).isFile() && f.endsWith(".md") && f !== "NODE.md") {
|
|
352
|
+
actualLeaves.add(f);
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
// skip
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const referenced = new Set<string>();
|
|
359
|
+
let linkMatch: RegExpExecArray | null;
|
|
360
|
+
const linkRe = /\[.*?\]\(([^)]+\.md)\)/g;
|
|
361
|
+
while ((linkMatch = linkRe.exec(body)) !== null) {
|
|
362
|
+
const ref = linkMatch[1];
|
|
363
|
+
if (ref.startsWith("http") || ref.startsWith("/")) continue;
|
|
364
|
+
if (!ref.includes("/")) referenced.add(ref);
|
|
365
|
+
}
|
|
366
|
+
for (const orphan of [...actualLeaves].filter((f) => !referenced.has(f)).sort()) {
|
|
367
|
+
findings.warning(
|
|
368
|
+
`${rel(nodeMd)}: leaf file '${orphan}' exists but is not mentioned in NODE.md`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
for (const ref of [...referenced].filter((f) => !actualLeaves.has(f)).sort()) {
|
|
372
|
+
if (!existsSync(join(full, ref))) {
|
|
373
|
+
findings.warning(
|
|
374
|
+
`${rel(nodeMd)}: references '${ref}' but the file does not exist`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
walk(full);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
walk(treeRoot);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function validateRootDomainSync(findings: Findings): void {
|
|
386
|
+
const nodeMd = join(treeRoot, "NODE.md");
|
|
387
|
+
const body = parseBody(nodeMd);
|
|
388
|
+
if (body === null) return;
|
|
389
|
+
|
|
390
|
+
// Strip HTML comments
|
|
391
|
+
const bodyNoComments = body.replace(/<!--.*?-->/gs, "");
|
|
392
|
+
|
|
393
|
+
const listedDomains = new Set<string>();
|
|
394
|
+
let dm: RegExpExecArray | null;
|
|
395
|
+
const domainRe = /\[(\w[\w-]*)\/?\]\((\w[\w-]*)\/NODE\.md\)/g;
|
|
396
|
+
while ((dm = domainRe.exec(bodyNoComments)) !== null) {
|
|
397
|
+
listedDomains.add(dm[2]);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const actualDomains = new Set<string>();
|
|
401
|
+
for (const child of readdirSync(treeRoot).sort()) {
|
|
402
|
+
const full = join(treeRoot, child);
|
|
403
|
+
try {
|
|
404
|
+
if (!statSync(full).isDirectory()) continue;
|
|
405
|
+
} catch {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (child.startsWith(".") || SKIP.has(child)) continue;
|
|
409
|
+
if (existsSync(join(full, "NODE.md"))) actualDomains.add(child);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const missing of [...actualDomains].filter((d) => !listedDomains.has(d)).sort()) {
|
|
413
|
+
findings.error(
|
|
414
|
+
`NODE.md: domain directory '${missing}/' exists but is not listed in root NODE.md`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
for (const extra of [...listedDomains].filter((d) => !actualDomains.has(d)).sort()) {
|
|
418
|
+
findings.error(
|
|
419
|
+
`NODE.md: lists domain '${extra}/' but the directory does not exist or has no NODE.md`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function validateSoftLinkReciprocity(
|
|
425
|
+
files: string[],
|
|
426
|
+
findings: Findings,
|
|
427
|
+
): void {
|
|
428
|
+
const allLinks: [string, string][] = [];
|
|
429
|
+
|
|
430
|
+
for (const path of files) {
|
|
431
|
+
const fm = parseFrontmatter(path);
|
|
432
|
+
if (fm === null) continue;
|
|
433
|
+
const links = parseSoftLinks(fm);
|
|
434
|
+
if (!links) continue;
|
|
435
|
+
for (const link of links) {
|
|
436
|
+
if (!link) continue;
|
|
437
|
+
const target = normalizeSoftLink(link);
|
|
438
|
+
allLinks.push([path, target]);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
for (const [source, target] of allLinks) {
|
|
443
|
+
if (!existsSync(target)) continue;
|
|
444
|
+
|
|
445
|
+
let hasBackLink = false;
|
|
446
|
+
const targetFm = parseFrontmatter(target);
|
|
447
|
+
if (targetFm) {
|
|
448
|
+
const targetLinks = parseSoftLinks(targetFm);
|
|
449
|
+
if (targetLinks) {
|
|
450
|
+
for (const tl of targetLinks) {
|
|
451
|
+
if (!tl) continue;
|
|
452
|
+
const resolved = normalizeSoftLink(tl);
|
|
453
|
+
if (
|
|
454
|
+
resolved === source ||
|
|
455
|
+
resolved === join(source, "..", "NODE.md")
|
|
456
|
+
) {
|
|
457
|
+
hasBackLink = true;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!hasBackLink) {
|
|
465
|
+
const targetBody = parseBody(target);
|
|
466
|
+
if (targetBody) {
|
|
467
|
+
const sourceRel = rel(source);
|
|
468
|
+
const linkRe = /\[.*?\]\(([^)]+\.md)\)/g;
|
|
469
|
+
let lm: RegExpExecArray | null;
|
|
470
|
+
while ((lm = linkRe.exec(targetBody)) !== null) {
|
|
471
|
+
const ref = lm[1];
|
|
472
|
+
if (ref.startsWith("http") || ref.startsWith("/")) continue;
|
|
473
|
+
if (sourceRel.endsWith(ref) || ref === sourceRel) {
|
|
474
|
+
hasBackLink = true;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (!hasBackLink) {
|
|
482
|
+
findings.info(
|
|
483
|
+
`${rel(source)}: soft_link to '${rel(target)}' is one-way (target has no reference back)`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function validateEmptyNodes(
|
|
490
|
+
files: string[],
|
|
491
|
+
findings: Findings,
|
|
492
|
+
): void {
|
|
493
|
+
for (const path of files) {
|
|
494
|
+
const text = readText(path);
|
|
495
|
+
if (text === null) continue;
|
|
496
|
+
const m = text.match(FRONTMATTER_RE);
|
|
497
|
+
if (!m) continue;
|
|
498
|
+
const body = text.slice(m[0].length);
|
|
499
|
+
const stripped = body.replace(/\s+/g, "");
|
|
500
|
+
if (stripped.length < MIN_BODY_LENGTH) {
|
|
501
|
+
findings.warning(`${rel(path)}: node has little or no body content`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function validateTitleMismatch(
|
|
507
|
+
files: string[],
|
|
508
|
+
findings: Findings,
|
|
509
|
+
): void {
|
|
510
|
+
for (const path of files) {
|
|
511
|
+
const text = readText(path);
|
|
512
|
+
if (text === null) continue;
|
|
513
|
+
const fmMatch = text.match(FRONTMATTER_RE);
|
|
514
|
+
if (!fmMatch) continue;
|
|
515
|
+
|
|
516
|
+
const fm = fmMatch[1];
|
|
517
|
+
const titleMatch = fm.match(TITLE_RE);
|
|
518
|
+
if (!titleMatch) continue;
|
|
519
|
+
const fmTitle = titleMatch[1].trim();
|
|
520
|
+
|
|
521
|
+
const body = text.slice(fmMatch[0].length);
|
|
522
|
+
const headingMatch = body.match(/^#\s+(.+)$/m);
|
|
523
|
+
if (!headingMatch) continue;
|
|
524
|
+
const bodyHeading = headingMatch[1].trim();
|
|
525
|
+
|
|
526
|
+
if (fmTitle !== bodyHeading) {
|
|
527
|
+
findings.warning(
|
|
528
|
+
`${rel(path)}: frontmatter title '${fmTitle}' differs from first heading '${bodyHeading}'`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function runValidateNodes(root: string): { exitCode: number; findings: Findings } {
|
|
535
|
+
setTreeRoot(root);
|
|
536
|
+
const files = collectMdFiles();
|
|
537
|
+
const findings = new Findings();
|
|
538
|
+
|
|
539
|
+
validateFolders(findings);
|
|
540
|
+
|
|
541
|
+
for (const path of files) {
|
|
542
|
+
const fm = parseFrontmatter(path);
|
|
543
|
+
if (fm === null) {
|
|
544
|
+
findings.error(`${rel(path)}: no frontmatter found`);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
validateOwners(fm, path, findings);
|
|
548
|
+
validateSoftLinks(fm, path, findings);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
validateDirectoryListing(findings);
|
|
552
|
+
validateRootDomainSync(findings);
|
|
553
|
+
validateSoftLinkReciprocity(files, findings);
|
|
554
|
+
validateEmptyNodes(files, findings);
|
|
555
|
+
validateTitleMismatch(files, findings);
|
|
556
|
+
|
|
557
|
+
findings.printReport(files.length);
|
|
558
|
+
return { exitCode: findings.hasErrors() ? 1 : 0, findings };
|
|
559
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { Repo } from "#skill/engine/repo.js";
|
|
3
|
+
import {
|
|
4
|
+
AGENT_INSTRUCTIONS_FILE,
|
|
5
|
+
LEGACY_AGENT_INSTRUCTIONS_FILE,
|
|
6
|
+
} from "#skill/engine/runtime/asset-loader.js";
|
|
7
|
+
import { runValidateMembers } from "#skill/engine/validators/members.js";
|
|
8
|
+
import { runValidateNodes } from "#skill/engine/validators/nodes.js";
|
|
9
|
+
|
|
10
|
+
const UNCHECKED_RE = /^- \[ \] (.+)$/gm;
|
|
11
|
+
export const VERIFY_USAGE = `usage: context-tree verify [--tree-path PATH]
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--tree-path PATH Verify a tree repo from another working directory
|
|
15
|
+
--help Show this help message
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
export function check(label: string, passed: boolean): boolean {
|
|
19
|
+
const icon = passed ? "\u2713" : "\u2717";
|
|
20
|
+
const status = passed ? "PASS" : "FAIL";
|
|
21
|
+
console.log(` ${icon} [${status}] ${label}`);
|
|
22
|
+
return passed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function checkProgress(repo: Repo): string[] {
|
|
26
|
+
const progressPath = repo.progressPath();
|
|
27
|
+
const text = progressPath === null ? null : repo.readFile(progressPath);
|
|
28
|
+
if (text === null) return [];
|
|
29
|
+
const matches: string[] = [];
|
|
30
|
+
let m: RegExpExecArray | null;
|
|
31
|
+
UNCHECKED_RE.lastIndex = 0;
|
|
32
|
+
while ((m = UNCHECKED_RE.exec(text)) !== null) {
|
|
33
|
+
matches.push(m[1]);
|
|
34
|
+
}
|
|
35
|
+
return matches;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ValidateNodesResult {
|
|
39
|
+
exitCode: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type NodeValidator = (root: string) => ValidateNodesResult;
|
|
43
|
+
|
|
44
|
+
function defaultNodeValidator(root: string): ValidateNodesResult {
|
|
45
|
+
const { exitCode } = runValidateNodes(root);
|
|
46
|
+
return { exitCode };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function runVerify(repo?: Repo, nodeValidator?: NodeValidator): number {
|
|
50
|
+
const r = repo ?? new Repo();
|
|
51
|
+
const validate = nodeValidator ?? defaultNodeValidator;
|
|
52
|
+
|
|
53
|
+
if (r.isLikelySourceRepo() && !r.looksLikeTreeRepo()) {
|
|
54
|
+
console.error(
|
|
55
|
+
"Error: no installed framework skill found here. This looks like a source/workspace repo. Run `context-tree init` to create a dedicated tree repo, or pass `--tree-path` to verify an existing tree repo.",
|
|
56
|
+
);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let allPassed = true;
|
|
61
|
+
const progressPath = r.progressPath() ?? r.preferredProgressPath();
|
|
62
|
+
const frameworkVersionPath = r.frameworkVersionPath();
|
|
63
|
+
|
|
64
|
+
console.log("Context Tree Verification\n");
|
|
65
|
+
|
|
66
|
+
// Progress file check
|
|
67
|
+
const unchecked = checkProgress(r);
|
|
68
|
+
if (unchecked.length > 0) {
|
|
69
|
+
console.log(` Unchecked items in ${progressPath}:\n`);
|
|
70
|
+
for (const item of unchecked) {
|
|
71
|
+
console.log(` - [ ] ${item}`);
|
|
72
|
+
}
|
|
73
|
+
console.log();
|
|
74
|
+
console.log(
|
|
75
|
+
` Verify each step above and check it off in ${progressPath} before running verify again.\n`,
|
|
76
|
+
);
|
|
77
|
+
allPassed = false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Deterministic checks
|
|
81
|
+
console.log(" Checks:\n");
|
|
82
|
+
|
|
83
|
+
// 1. Framework exists
|
|
84
|
+
allPassed = check(`${frameworkVersionPath} exists`, r.hasFramework()) && allPassed;
|
|
85
|
+
|
|
86
|
+
// 2. Root NODE.md has valid frontmatter
|
|
87
|
+
const fm = r.frontmatter("NODE.md");
|
|
88
|
+
const hasValidNode =
|
|
89
|
+
fm !== null && fm.title !== undefined && fm.owners !== undefined;
|
|
90
|
+
allPassed = check(
|
|
91
|
+
"Root NODE.md has valid frontmatter (title, owners)",
|
|
92
|
+
hasValidNode,
|
|
93
|
+
) && allPassed;
|
|
94
|
+
|
|
95
|
+
// 3. AGENTS.md is canonical and contains framework markers
|
|
96
|
+
const hasCanonicalAgentInstructions = r.hasCanonicalAgentInstructionsFile();
|
|
97
|
+
const hasLegacyAgentInstructions = r.hasLegacyAgentInstructionsFile();
|
|
98
|
+
if (hasLegacyAgentInstructions) {
|
|
99
|
+
const followUp = hasCanonicalAgentInstructions
|
|
100
|
+
? `Remove legacy \`${LEGACY_AGENT_INSTRUCTIONS_FILE}\` after confirming its contents are in \`${AGENT_INSTRUCTIONS_FILE}\`.`
|
|
101
|
+
: `Rename \`${LEGACY_AGENT_INSTRUCTIONS_FILE}\` to \`${AGENT_INSTRUCTIONS_FILE}\`.`;
|
|
102
|
+
console.log(` Legacy agent instructions detected. ${followUp}\n`);
|
|
103
|
+
}
|
|
104
|
+
allPassed = check(
|
|
105
|
+
`${AGENT_INSTRUCTIONS_FILE} is the only agent instructions file and has framework markers`,
|
|
106
|
+
hasCanonicalAgentInstructions &&
|
|
107
|
+
!hasLegacyAgentInstructions &&
|
|
108
|
+
r.hasAgentInstructionsMarkers(),
|
|
109
|
+
) && allPassed;
|
|
110
|
+
|
|
111
|
+
// 4. Node validation
|
|
112
|
+
const { exitCode } = validate(r.root);
|
|
113
|
+
allPassed = check("Node validation passes", exitCode === 0) && allPassed;
|
|
114
|
+
|
|
115
|
+
// 5. Member validation
|
|
116
|
+
const members = runValidateMembers(r.root);
|
|
117
|
+
allPassed = check("Member validation passes", members.exitCode === 0) && allPassed;
|
|
118
|
+
|
|
119
|
+
console.log();
|
|
120
|
+
if (allPassed) {
|
|
121
|
+
console.log("All checks passed.");
|
|
122
|
+
} else {
|
|
123
|
+
console.log("Some checks failed. See above for details.");
|
|
124
|
+
}
|
|
125
|
+
return allPassed ? 0 : 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function runVerifyCli(args: string[] = []): number {
|
|
129
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
130
|
+
console.log(VERIFY_USAGE);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let treePath: string | undefined;
|
|
135
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
136
|
+
const arg = args[index];
|
|
137
|
+
if (arg === "--tree-path") {
|
|
138
|
+
const value = args[index + 1];
|
|
139
|
+
if (!value) {
|
|
140
|
+
console.error("Missing value for --tree-path");
|
|
141
|
+
console.log(VERIFY_USAGE);
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
treePath = value;
|
|
145
|
+
index += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.error(`Unknown verify option: ${arg}`);
|
|
150
|
+
console.log(VERIFY_USAGE);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return runVerify(treePath ? new Repo(resolve(process.cwd(), treePath)) : undefined);
|
|
155
|
+
}
|