@brainervirus/workit-core 7.3.0 → 7.5.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/package.json +1 -1
- package/skills/workit-fanout/SKILL.md +27 -27
- package/skills/workit-fanout/references/brief.md +5 -1
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +3 -2
- package/skills/workit-shape/references/knowledge.md +3 -0
- package/skills/workit-ship/SKILL.md +2 -1
- package/src/core/methods.ts +9 -10
- package/src/core/skill-manifests.ts +3 -0
- package/src/duration.ts +27 -0
- package/src/fanout-check.ts +178 -27
- package/src/fanout-landed.ts +313 -0
- package/src/fanout-status.ts +321 -0
- package/src/fanout-worktree.ts +520 -0
- package/src/fanout.ts +131 -17
- package/src/knowledge.ts +368 -0
package/src/fanout.ts
CHANGED
|
@@ -104,14 +104,41 @@ const unescape = (pattern: string): string => pattern.replace(/\\(.)/gu, "$1");
|
|
|
104
104
|
/** Has an unescaped `*`, `?` or `{`. */
|
|
105
105
|
const hasWildcard = (pattern: string): boolean => /[*?{]/u.test(pattern.replace(/\\./gu, ""));
|
|
106
106
|
|
|
107
|
+
/** Brace alternatives expanded (`src/{a,b}/x` -> `src/a/x`, `src/b/x`), capped. */
|
|
108
|
+
function expandBraces(pattern: string, limit = 64): string[] {
|
|
109
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
110
|
+
if (pattern[index] === "\\") {
|
|
111
|
+
index += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const close = pattern.indexOf("}", index);
|
|
115
|
+
if (pattern[index] !== "{" || close < index) continue;
|
|
116
|
+
const head = pattern.slice(0, index);
|
|
117
|
+
const tail = pattern.slice(close + 1);
|
|
118
|
+
const out: string[] = [];
|
|
119
|
+
for (const alternative of pattern.slice(index + 1, close).split(","))
|
|
120
|
+
for (const rest of expandBraces(tail, limit)) {
|
|
121
|
+
if (out.length >= limit) return out;
|
|
122
|
+
out.push(`${head}${alternative}${rest}`);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
return [pattern];
|
|
127
|
+
}
|
|
128
|
+
|
|
107
129
|
/**
|
|
108
|
-
*
|
|
109
|
-
* created yet: `src/new/**` -> `src/new/<any>`,
|
|
130
|
+
* Concrete paths the pattern matches, one per brace alternative, standing in
|
|
131
|
+
* for files nobody has created yet: `src/new/**` -> `src/new/<any>`,
|
|
132
|
+
* `src/{a,b}/*.ts` -> `src/a/<any>.ts`, `src/b/<any>.ts`.
|
|
110
133
|
*/
|
|
111
134
|
export function scopeSamples(pattern: string): string[] {
|
|
112
135
|
// A plain path covers what is below it too, so `src/new` and `src/new/**`
|
|
113
136
|
// overlap through `src/new/<any>` without a second sample.
|
|
114
137
|
if (!hasWildcard(pattern)) return [unescape(pattern)];
|
|
138
|
+
return [...new Set(expandBraces(pattern).map(sampleOne))];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sampleOne(pattern: string): string {
|
|
115
142
|
let out = "";
|
|
116
143
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
117
144
|
const char = pattern[index];
|
|
@@ -127,13 +154,9 @@ export function scopeSamples(pattern: string): string[] {
|
|
|
127
154
|
}
|
|
128
155
|
} else if (char === "*") out += "<any>";
|
|
129
156
|
else if (char === "?") out += "x";
|
|
130
|
-
else
|
|
131
|
-
const close = pattern.indexOf("}", index);
|
|
132
|
-
out += pattern.slice(index + 1, close).split(",")[0];
|
|
133
|
-
index = close;
|
|
134
|
-
} else out += char;
|
|
157
|
+
else out += char;
|
|
135
158
|
}
|
|
136
|
-
return
|
|
159
|
+
return out;
|
|
137
160
|
}
|
|
138
161
|
|
|
139
162
|
function globSource(pattern: string): string {
|
|
@@ -190,9 +213,23 @@ const matchesAny = (patterns: readonly string[], file: string): boolean =>
|
|
|
190
213
|
|
|
191
214
|
/** A scope entry in canonical form, or why it is refused. */
|
|
192
215
|
function normalizePattern(raw: string): { pattern: string } | { error: string } {
|
|
193
|
-
//
|
|
194
|
-
// Windows separator.
|
|
195
|
-
let pattern = raw.trim()
|
|
216
|
+
// Only `\\[` and `\\]` are escapes (`app/\\[id\\]`); any other backslash is
|
|
217
|
+
// a Windows separator. Where both readings are possible, refuse.
|
|
218
|
+
let pattern = raw.trim();
|
|
219
|
+
if (pattern.includes("\\")) {
|
|
220
|
+
if (/\\[*?{}]/u.test(pattern))
|
|
221
|
+
return {
|
|
222
|
+
error:
|
|
223
|
+
"has \\*, \\?, \\{ or \\}: only \\[ and \\] are escapes; use / as the separator (src/**/*.ts)",
|
|
224
|
+
};
|
|
225
|
+
const brackets = /\\[[\]]/u.test(pattern);
|
|
226
|
+
if (brackets && pattern.replace(/\\[[\]]/gu, "").includes("\\"))
|
|
227
|
+
return {
|
|
228
|
+
error:
|
|
229
|
+
"mixes \\[ or \\] escapes with other backslashes, which is ambiguous: use / as the separator (app/\\[id\\]/page.tsx)",
|
|
230
|
+
};
|
|
231
|
+
if (!brackets) pattern = pattern.replaceAll("\\", "/");
|
|
232
|
+
}
|
|
196
233
|
while (pattern.startsWith("./")) pattern = pattern.slice(2);
|
|
197
234
|
if (pattern === "." || pattern === "") pattern = raw.trim() ? "**" : "";
|
|
198
235
|
if (pattern.endsWith("/")) pattern = `${pattern}**`;
|
|
@@ -244,6 +281,8 @@ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
|
|
244
281
|
|
|
245
282
|
export type PlanDefaults = {
|
|
246
283
|
trunk: string;
|
|
284
|
+
/** The main checkout: a relative `worktree` is resolved against it at plan time. */
|
|
285
|
+
root: string;
|
|
247
286
|
/** Where default worktrees go: `<repo>-wt/` next to the main checkout. */
|
|
248
287
|
worktreeRoot: string;
|
|
249
288
|
};
|
|
@@ -335,7 +374,10 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
|
|
|
335
374
|
id,
|
|
336
375
|
branch,
|
|
337
376
|
base: optional("base") ?? "",
|
|
338
|
-
worktree:
|
|
377
|
+
worktree: path.resolve(
|
|
378
|
+
defaults.root,
|
|
379
|
+
optional("worktree") ?? path.join(defaults.worktreeRoot, id),
|
|
380
|
+
),
|
|
339
381
|
tier: tier as Tier,
|
|
340
382
|
dependsOn: [...new Set(dependsOn ?? [])],
|
|
341
383
|
scope,
|
|
@@ -582,16 +624,59 @@ export function analyzeOverlap(
|
|
|
582
624
|
return { overlaps, conflicts };
|
|
583
625
|
}
|
|
584
626
|
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
// case-only collisions with the trunk
|
|
629
|
+
|
|
630
|
+
export type CaseCollision = { slice: string; path: string; trunk: string };
|
|
631
|
+
|
|
632
|
+
/** Trunk paths (files and the directories above them) by lower case. */
|
|
633
|
+
export function caseIndex(files: Iterable<string>): Map<string, Set<string>> {
|
|
634
|
+
const index = new Map<string, Set<string>>();
|
|
635
|
+
for (const file of files) {
|
|
636
|
+
const parts = file.split("/");
|
|
637
|
+
for (let end = 1; end <= parts.length; end += 1) {
|
|
638
|
+
const prefix = parts.slice(0, end).join("/");
|
|
639
|
+
const key = prefix.toLowerCase();
|
|
640
|
+
(index.get(key) ?? index.set(key, new Set()).get(key))?.add(prefix);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return index;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The shallowest part of `file` (a directory or the file itself) that the
|
|
648
|
+
* trunk spells differently only in case: one path on macOS and Windows
|
|
649
|
+
* checkouts, two in git. Null when there is none.
|
|
650
|
+
*/
|
|
651
|
+
export function caseClash(
|
|
652
|
+
index: ReadonlyMap<string, ReadonlySet<string>>,
|
|
653
|
+
file: string,
|
|
654
|
+
): { path: string; trunk: string } | null {
|
|
655
|
+
const parts = file.split("/");
|
|
656
|
+
for (let end = 1; end <= parts.length; end += 1) {
|
|
657
|
+
const prefix = parts.slice(0, end).join("/");
|
|
658
|
+
if (prefix.includes("<any>")) return null;
|
|
659
|
+
const spellings = index.get(prefix.toLowerCase());
|
|
660
|
+
if (spellings && !spellings.has(prefix))
|
|
661
|
+
return { path: prefix, trunk: [...spellings].toSorted()[0] };
|
|
662
|
+
}
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
585
666
|
// ---------------------------------------------------------------------------
|
|
586
667
|
// git
|
|
587
668
|
|
|
588
669
|
type GitRun = { ok: boolean; status: number | null; stdout: string; stderr: string };
|
|
589
670
|
|
|
590
|
-
export const gitRun = (
|
|
671
|
+
export const gitRun = (
|
|
672
|
+
cwd: string,
|
|
673
|
+
args: string[],
|
|
674
|
+
timeoutMs: number = GIT_TIMEOUTS.local,
|
|
675
|
+
): GitRun => {
|
|
591
676
|
const run = spawnSync("git", args, {
|
|
592
677
|
cwd,
|
|
593
678
|
encoding: "utf8",
|
|
594
|
-
timeout:
|
|
679
|
+
timeout: timeoutMs,
|
|
595
680
|
killSignal: "SIGKILL",
|
|
596
681
|
windowsHide: true,
|
|
597
682
|
maxBuffer: 64 * 1024 * 1024,
|
|
@@ -631,10 +716,15 @@ export function defaultTrunk(cwd: string): string {
|
|
|
631
716
|
return base ? base.replace(/^origin\//u, "") : "main";
|
|
632
717
|
}
|
|
633
718
|
|
|
719
|
+
/** The main checkout (beside `.git`), else `cwd`. */
|
|
720
|
+
export function mainCheckout(cwd: string): string {
|
|
721
|
+
const common = gitCommonDir(cwd);
|
|
722
|
+
return common && path.basename(common) === ".git" ? path.dirname(common) : path.resolve(cwd);
|
|
723
|
+
}
|
|
724
|
+
|
|
634
725
|
/** `<repo>-wt/` beside the main checkout. */
|
|
635
726
|
export function worktreeRoot(cwd: string): string {
|
|
636
|
-
const
|
|
637
|
-
const main = common && path.basename(common) === ".git" ? path.dirname(common) : cwd;
|
|
727
|
+
const main = mainCheckout(cwd);
|
|
638
728
|
return path.join(path.dirname(main), `${path.basename(main)}-wt`);
|
|
639
729
|
}
|
|
640
730
|
|
|
@@ -823,7 +913,11 @@ export function planFanout(
|
|
|
823
913
|
if (!dir.ok) return dir;
|
|
824
914
|
const parsed = parsePlan(
|
|
825
915
|
input.trunk && isRecord(input.raw) ? { ...input.raw, trunk: input.trunk } : input.raw,
|
|
826
|
-
{
|
|
916
|
+
{
|
|
917
|
+
trunk: input.trunk ?? defaultTrunk(cwd),
|
|
918
|
+
root: mainCheckout(cwd),
|
|
919
|
+
worktreeRoot: worktreeRoot(cwd),
|
|
920
|
+
},
|
|
827
921
|
input.name,
|
|
828
922
|
);
|
|
829
923
|
if (!parsed.ok)
|
|
@@ -858,6 +952,26 @@ export function planFanout(
|
|
|
858
952
|
{ name: plan.name, conflicts, overlaps },
|
|
859
953
|
);
|
|
860
954
|
|
|
955
|
+
const trunkCase = caseIndex(tracked);
|
|
956
|
+
const clashes: CaseCollision[] = [];
|
|
957
|
+
for (const slice of plan.slices) {
|
|
958
|
+
const seen = new Set<string>();
|
|
959
|
+
for (const sample of [...slice.scope, ...slice.owns].flatMap(scopeSamples)) {
|
|
960
|
+
const clash = caseClash(trunkCase, sample);
|
|
961
|
+
if (clash && !seen.has(clash.path)) {
|
|
962
|
+
seen.add(clash.path);
|
|
963
|
+
clashes.push({ slice: slice.id, ...clash });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (clashes.length)
|
|
968
|
+
return fanoutFail(
|
|
969
|
+
"blocked",
|
|
970
|
+
`slice ${clashes[0].slice} names ${clashes[0].path}, which differs only in case from ${plan.trunk}'s ${clashes[0].trunk} (one path on macOS and Windows)`,
|
|
971
|
+
`use ${plan.trunk}'s spelling ${clashes[0].trunk} in slice ${clashes[0].slice}'s scope, then workit fanout plan again`,
|
|
972
|
+
{ name: plan.name, caseCollisions: clashes, overlaps },
|
|
973
|
+
);
|
|
974
|
+
|
|
861
975
|
const existing = readFanout(cwd, plan.name);
|
|
862
976
|
const previous = existing.ok ? existing.data : null;
|
|
863
977
|
plan.createdAt = previous?.createdAt || now.toISOString();
|
package/src/knowledge.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// `workit knowledge lint`: deterministic checks over the repository's agent
|
|
2
|
+
// knowledge files, so the guards against markdown rot are a check that can
|
|
3
|
+
// fail rather than a sentence an agent may ignore. Four rules:
|
|
4
|
+
//
|
|
5
|
+
// - agents-budget: AGENTS.md (and a CLAUDE.md that is not the same file) stays
|
|
6
|
+
// at or under 8 KB; everything an agent pays for on every session.
|
|
7
|
+
// - broken-link: a markdown link, or a backticked path whose first segment
|
|
8
|
+
// exists at the repository top, points at a local file that is missing.
|
|
9
|
+
// - scaffold-file: a need-based file (CODING_STANDARDS.md, GLOSSARY.md) holds
|
|
10
|
+
// only headings, comments or placeholders. These files are created in the
|
|
11
|
+
// same edit as their first real entry, never as an empty scaffold.
|
|
12
|
+
// - duplicate-rule: the same sentence (normalized: case, markup, wrapping)
|
|
13
|
+
// appears in a steering file and in CODING_STANDARDS.md. A rule lives once.
|
|
14
|
+
//
|
|
15
|
+
// Read-only; plain TS over node built-ins (git only to list tracked paths).
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
export const AGENTS_BYTE_BUDGET = 8 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Root files every session reads (navigation and rules). */
|
|
23
|
+
const STEERING_FILES = ["AGENTS.md", "CLAUDE.md"];
|
|
24
|
+
/** Root files that exist only once they hold a real entry. */
|
|
25
|
+
const NEED_BASED_FILES = ["CODING_STANDARDS.md", "GLOSSARY.md"];
|
|
26
|
+
const STANDARDS_FILE = "CODING_STANDARDS.md";
|
|
27
|
+
/** A sentence shorter than this is a heading-like fragment, not a rule. */
|
|
28
|
+
const MIN_RULE_WORDS = 6;
|
|
29
|
+
|
|
30
|
+
type KnowledgeRule = "agents-budget" | "broken-link" | "scaffold-file" | "duplicate-rule";
|
|
31
|
+
|
|
32
|
+
type KnowledgeFinding = {
|
|
33
|
+
rule: KnowledgeRule;
|
|
34
|
+
/** Repository-relative, forward slashes. */
|
|
35
|
+
file: string;
|
|
36
|
+
line: number | null;
|
|
37
|
+
message: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type KnowledgeReport = {
|
|
41
|
+
root: string;
|
|
42
|
+
budget: number;
|
|
43
|
+
files: Array<{ file: string; bytes: number }>;
|
|
44
|
+
findings: KnowledgeFinding[];
|
|
45
|
+
/** Knowledge files that exist but could not be read (permissions, broken links). */
|
|
46
|
+
unreadable: string[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type Doc = { file: string; text: string; lines: string[] };
|
|
50
|
+
|
|
51
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
52
|
+
const HEADING = /^\s{0,3}#{1,6}(\s|$)/;
|
|
53
|
+
const TABLE_SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/;
|
|
54
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
|
55
|
+
const PLACEHOLDER =
|
|
56
|
+
/^(?:(?:tbd|todo|tba|fixme)\b.*|n\/a|none|none yet|coming soon|placeholder|\.\.\.|…|-)?[.:]?$/i;
|
|
57
|
+
|
|
58
|
+
const INDENTED_CODE = /^(?: {4}|\t)/;
|
|
59
|
+
const blank = (line: string | undefined) => (line ?? "").trim() === "";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Line indexes outside code (fenced blocks, closed only by a fence of the same
|
|
63
|
+
* character and at least the same length; 4-space indented blocks that do not
|
|
64
|
+
* continue a list), with HTML comments blanked.
|
|
65
|
+
*/
|
|
66
|
+
const proseLines = (lines: string[]): Array<{ index: number; text: string }> => {
|
|
67
|
+
const out: Array<{ index: number; text: string }> = [];
|
|
68
|
+
let fence: string | null = null;
|
|
69
|
+
let comment = false;
|
|
70
|
+
let indentedCode = false;
|
|
71
|
+
let inList = false;
|
|
72
|
+
lines.forEach((raw, index) => {
|
|
73
|
+
if (fence) {
|
|
74
|
+
const close = FENCE.exec(raw);
|
|
75
|
+
if (close && close[1][0] === fence[0] && close[1].length >= fence.length && !close[2].trim())
|
|
76
|
+
fence = null;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!comment) {
|
|
80
|
+
const open = FENCE.exec(raw);
|
|
81
|
+
if (open && !(open[1][0] === "`" && open[2].includes("`"))) {
|
|
82
|
+
fence = open[1];
|
|
83
|
+
indentedCode = false;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (INDENTED_CODE.test(raw) && !blank(raw)) {
|
|
87
|
+
if (indentedCode || (!inList && blank(lines[index - 1]))) {
|
|
88
|
+
indentedCode = true;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
} else if (!blank(raw)) {
|
|
92
|
+
indentedCode = false;
|
|
93
|
+
inList = LIST_ITEM.test(raw) || (inList && /^\s/.test(raw));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let text = raw;
|
|
97
|
+
if (comment) {
|
|
98
|
+
const end = text.indexOf("-->");
|
|
99
|
+
if (end === -1) return;
|
|
100
|
+
comment = false;
|
|
101
|
+
text = text.slice(end + 3);
|
|
102
|
+
}
|
|
103
|
+
text = text.replace(/<!--[\s\S]*?-->/g, "");
|
|
104
|
+
const open = text.indexOf("<!--");
|
|
105
|
+
if (open !== -1) {
|
|
106
|
+
comment = true;
|
|
107
|
+
text = text.slice(0, open);
|
|
108
|
+
}
|
|
109
|
+
out.push({ index, text });
|
|
110
|
+
});
|
|
111
|
+
return out;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const withoutFrontMatter = (lines: string[]): string[] => {
|
|
115
|
+
if (lines[0]?.trim() !== "---") return lines;
|
|
116
|
+
const end = lines.indexOf("---", 1);
|
|
117
|
+
return end === -1 ? lines : [...lines.slice(0, end + 1).map(() => ""), ...lines.slice(end + 1)];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** The file as a Doc; null when absent; "unreadable" when present but it cannot be read. */
|
|
121
|
+
const read = (root: string, file: string): (Doc & { real: string }) | null | "unreadable" => {
|
|
122
|
+
const full = path.join(root, file);
|
|
123
|
+
try {
|
|
124
|
+
fs.lstatSync(full);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
if (!fs.statSync(full).isFile()) return null;
|
|
130
|
+
const text = fs.readFileSync(full, "utf8");
|
|
131
|
+
return { file, text, lines: text.split(/\r?\n/), real: fs.realpathSync(full) };
|
|
132
|
+
} catch {
|
|
133
|
+
return "unreadable";
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Top-level entries git tracks, so a pointer is judged the same locally and in
|
|
139
|
+
* CI; null outside a git repository (then the file system decides).
|
|
140
|
+
*/
|
|
141
|
+
const trackedTopLevel = (root: string): Set<string> | null => {
|
|
142
|
+
const run = spawnSync("git", ["ls-files", "-z", "--cached"], {
|
|
143
|
+
cwd: root,
|
|
144
|
+
encoding: "utf8",
|
|
145
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
146
|
+
timeout: 20_000,
|
|
147
|
+
windowsHide: true,
|
|
148
|
+
});
|
|
149
|
+
if (run.status !== 0) return null;
|
|
150
|
+
return new Set(
|
|
151
|
+
run.stdout
|
|
152
|
+
.split("\0")
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.map((file) => file.split("/")[0]),
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const exists = (target: string): boolean => {
|
|
159
|
+
try {
|
|
160
|
+
fs.statSync(target);
|
|
161
|
+
return true;
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// --- broken-link -----------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
// Targets: `<anything>` or a run without spaces that may hold balanced parentheses.
|
|
170
|
+
const LINK = /!?\[[^\]]*\]\(\s*(?:<([^>\n]+)>|((?:[^()\s<>]|\([^()\s]*\))+))(?:\s+["'(][^)]*)?\)/g;
|
|
171
|
+
const REFERENCE_DEFINITION = /^\s{0,3}\[[^\]]+\]:\s*<?(\S+?)>?(?:\s|$)/;
|
|
172
|
+
const CODE_SPAN = /`([^`\n]+)`/g;
|
|
173
|
+
const PATH_LIKE = /^(?:\.{1,2}\/)?[\w@.-]+(?:\/[\w@.-]+)+\/?$/;
|
|
174
|
+
|
|
175
|
+
const isExternal = (target: string) =>
|
|
176
|
+
/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#") || target.startsWith("//");
|
|
177
|
+
|
|
178
|
+
const localTarget = (target: string): string => {
|
|
179
|
+
const bare = target.replace(/[#?].*$/, "");
|
|
180
|
+
try {
|
|
181
|
+
return decodeURIComponent(bare);
|
|
182
|
+
} catch {
|
|
183
|
+
return bare;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const brokenLinks = (root: string, doc: Doc, tracked: Set<string> | null): KnowledgeFinding[] => {
|
|
188
|
+
const findings: KnowledgeFinding[] = [];
|
|
189
|
+
const base = path.dirname(path.join(root, doc.file));
|
|
190
|
+
const missing = (target: string, line: number, how: string) =>
|
|
191
|
+
findings.push({
|
|
192
|
+
rule: "broken-link",
|
|
193
|
+
file: doc.file,
|
|
194
|
+
line,
|
|
195
|
+
message: `${how} \`${target}\` points at a missing file`,
|
|
196
|
+
});
|
|
197
|
+
for (const { index, text } of proseLines(doc.lines)) {
|
|
198
|
+
// Links inside inline code are examples, not links.
|
|
199
|
+
const prose = text.replace(CODE_SPAN, (span) => " ".repeat(span.length));
|
|
200
|
+
const targets = [...prose.matchAll(LINK)].map((match) => match[1] ?? match[2]);
|
|
201
|
+
const definition = REFERENCE_DEFINITION.exec(prose)?.[1];
|
|
202
|
+
if (definition) targets.push(definition);
|
|
203
|
+
for (const target of targets) {
|
|
204
|
+
if (isExternal(target)) continue;
|
|
205
|
+
const local = localTarget(target);
|
|
206
|
+
if (!local) continue;
|
|
207
|
+
const resolved = local.startsWith("/") ? path.join(root, local) : path.resolve(base, local);
|
|
208
|
+
if (!exists(resolved)) missing(target, index + 1, "link");
|
|
209
|
+
}
|
|
210
|
+
// A backticked path counts as a pointer only when it is explicitly
|
|
211
|
+
// relative (./ or ../) or its first segment is a tracked top-level entry
|
|
212
|
+
// (dot-directories included), so commands, globs, placeholders, package
|
|
213
|
+
// names and local-only directories are never mistaken for one.
|
|
214
|
+
for (const [, span] of text.matchAll(CODE_SPAN)) {
|
|
215
|
+
const candidate = span.replace(/:\d+(?::\d+)?$/, "");
|
|
216
|
+
if (!PATH_LIKE.test(candidate)) continue;
|
|
217
|
+
const relative = /^\.\.?\//.test(candidate);
|
|
218
|
+
const first = candidate.split("/")[0];
|
|
219
|
+
if (!relative && !(tracked ? tracked.has(first) : exists(path.join(root, first)))) continue;
|
|
220
|
+
const resolved = relative ? path.resolve(base, candidate) : path.join(root, candidate);
|
|
221
|
+
if (!exists(resolved)) missing(span, index + 1, "pointer");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return findings;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// --- scaffold-file ---------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
const isPlaceholder = (text: string) =>
|
|
230
|
+
PLACEHOLDER.test(
|
|
231
|
+
text
|
|
232
|
+
.replace(LIST_ITEM, "")
|
|
233
|
+
.replace(/[*_`]/g, "")
|
|
234
|
+
.replace(/^\[\s?\]\s*/, "")
|
|
235
|
+
.trim(),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
/** True when the file has no line that is an entry: only headings, rules, comments, table headers or placeholders. */
|
|
239
|
+
const scaffoldOnly = (doc: Doc): boolean => {
|
|
240
|
+
const lines = proseLines(withoutFrontMatter(doc.lines));
|
|
241
|
+
return lines.every(({ text }, position) => {
|
|
242
|
+
const trimmed = text.trim();
|
|
243
|
+
if (trimmed === "" || HEADING.test(text) || /^([-*_])(\s*\1){2,}$/.test(trimmed)) return true;
|
|
244
|
+
if (TABLE_SEPARATOR.test(text)) return true;
|
|
245
|
+
const next = lines[position + 1]?.text ?? "";
|
|
246
|
+
if (trimmed.startsWith("|") && TABLE_SEPARATOR.test(next)) return true;
|
|
247
|
+
if (trimmed.startsWith("|"))
|
|
248
|
+
return trimmed
|
|
249
|
+
.split("|")
|
|
250
|
+
.map((cell) => cell.trim())
|
|
251
|
+
.every((cell) => cell === "" || isPlaceholder(cell));
|
|
252
|
+
return isPlaceholder(trimmed);
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// --- duplicate-rule --------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/** Unwrapped list items and paragraphs, each with the line it starts on. */
|
|
259
|
+
const ruleUnits = (doc: Doc): Array<{ line: number; text: string }> => {
|
|
260
|
+
const units: Array<{ line: number; text: string }> = [];
|
|
261
|
+
let current: { line: number; text: string } | null = null;
|
|
262
|
+
const flush = () => {
|
|
263
|
+
if (current) units.push(current);
|
|
264
|
+
current = null;
|
|
265
|
+
};
|
|
266
|
+
for (const { index, text } of proseLines(doc.lines)) {
|
|
267
|
+
const trimmed = text.trim();
|
|
268
|
+
if (trimmed === "" || HEADING.test(text) || trimmed.startsWith("|")) {
|
|
269
|
+
flush();
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (LIST_ITEM.test(text) || !current) {
|
|
273
|
+
flush();
|
|
274
|
+
current = { line: index + 1, text: trimmed.replace(LIST_ITEM, "") };
|
|
275
|
+
} else current.text += ` ${trimmed}`;
|
|
276
|
+
}
|
|
277
|
+
flush();
|
|
278
|
+
return units;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const normalize = (sentence: string): string =>
|
|
282
|
+
sentence
|
|
283
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
284
|
+
.replace(/[*_`]/g, "")
|
|
285
|
+
.toLowerCase()
|
|
286
|
+
.replace(/\s+/g, " ")
|
|
287
|
+
.replace(/[\s.,;:!?]+$/, "")
|
|
288
|
+
.trim();
|
|
289
|
+
|
|
290
|
+
const sentences = (doc: Doc): Map<string, number> => {
|
|
291
|
+
const out = new Map<string, number>();
|
|
292
|
+
for (const unit of ruleUnits(doc))
|
|
293
|
+
for (const sentence of unit.text.split(/(?<=[.!?])\s+/)) {
|
|
294
|
+
const key = normalize(sentence);
|
|
295
|
+
if (key.split(" ").length >= MIN_RULE_WORDS && !out.has(key)) out.set(key, unit.line);
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const duplicateRules = (steering: Doc[], standards: Doc): KnowledgeFinding[] => {
|
|
301
|
+
const findings: KnowledgeFinding[] = [];
|
|
302
|
+
const rules = sentences(standards);
|
|
303
|
+
for (const doc of steering)
|
|
304
|
+
for (const [key, line] of sentences(doc)) {
|
|
305
|
+
const standardsLine = rules.get(key);
|
|
306
|
+
if (standardsLine === undefined) continue;
|
|
307
|
+
findings.push({
|
|
308
|
+
rule: "duplicate-rule",
|
|
309
|
+
file: doc.file,
|
|
310
|
+
line,
|
|
311
|
+
message: `repeats ${standards.file}:${standardsLine} ("${key.slice(0, 60)}${key.length > 60 ? "…" : ""}"); keep the rule in one file`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return findings;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// --- entry -----------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
/** Lint the knowledge files at `root` (the repository top). */
|
|
320
|
+
export function lintKnowledge(root: string): KnowledgeReport {
|
|
321
|
+
const seen = new Set<string>();
|
|
322
|
+
const unreadable: string[] = [];
|
|
323
|
+
const load = (file: string): Doc | null => {
|
|
324
|
+
const doc = read(root, file);
|
|
325
|
+
if (doc === "unreadable") unreadable.push(file);
|
|
326
|
+
if (doc === null || doc === "unreadable" || seen.has(doc.real)) return null;
|
|
327
|
+
seen.add(doc.real);
|
|
328
|
+
return doc;
|
|
329
|
+
};
|
|
330
|
+
const steering = STEERING_FILES.map(load).filter((doc): doc is Doc => doc !== null);
|
|
331
|
+
const needBased = NEED_BASED_FILES.map(load).filter((doc): doc is Doc => doc !== null);
|
|
332
|
+
const findings: KnowledgeFinding[] = [];
|
|
333
|
+
|
|
334
|
+
for (const doc of steering) {
|
|
335
|
+
const bytes = Buffer.byteLength(doc.text);
|
|
336
|
+
if (bytes > AGENTS_BYTE_BUDGET)
|
|
337
|
+
findings.push({
|
|
338
|
+
rule: "agents-budget",
|
|
339
|
+
file: doc.file,
|
|
340
|
+
line: null,
|
|
341
|
+
message: `${bytes} bytes, over the ${AGENTS_BYTE_BUDGET}-byte budget; move detail behind a pointer or into a check, and name what each addition removes`,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
for (const doc of needBased)
|
|
345
|
+
if (scaffoldOnly(doc))
|
|
346
|
+
findings.push({
|
|
347
|
+
rule: "scaffold-file",
|
|
348
|
+
file: doc.file,
|
|
349
|
+
line: null,
|
|
350
|
+
message:
|
|
351
|
+
"holds no entry (headings, comments or placeholders only); delete it, or create it in the same edit as its first real entry",
|
|
352
|
+
});
|
|
353
|
+
const tracked = trackedTopLevel(root);
|
|
354
|
+
for (const doc of [...steering, ...needBased]) findings.push(...brokenLinks(root, doc, tracked));
|
|
355
|
+
const standards = needBased.find((doc) => doc.file === STANDARDS_FILE);
|
|
356
|
+
if (standards) findings.push(...duplicateRules(steering, standards));
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
root,
|
|
360
|
+
budget: AGENTS_BYTE_BUDGET,
|
|
361
|
+
files: [...steering, ...needBased].map((doc) => ({
|
|
362
|
+
file: doc.file,
|
|
363
|
+
bytes: Buffer.byteLength(doc.text),
|
|
364
|
+
})),
|
|
365
|
+
findings,
|
|
366
|
+
unreadable,
|
|
367
|
+
};
|
|
368
|
+
}
|