@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/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
- * One concrete path the pattern matches, standing in for files nobody has
109
- * created yet: `src/new/**` -> `src/new/<any>`, `src/*.ts` -> `src/<any>.ts`.
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 if (char === "{" && pattern.indexOf("}", index) > index) {
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 [out];
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
- // A backslash escapes a glob character (`app/\\[id\\]`); any other one is a
194
- // Windows separator.
195
- let pattern = raw.trim().replace(/\\(?![[\]{}*?])/gu, "/");
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: optional("worktree") ?? path.join(defaults.worktreeRoot, id),
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 = (cwd: string, args: string[]): 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: GIT_TIMEOUTS.local,
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 common = gitCommonDir(cwd);
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
- { trunk: input.trunk ?? defaultTrunk(cwd), worktreeRoot: worktreeRoot(cwd) },
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();
@@ -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
+ }