@smeltjs/core 0.3.0 → 0.4.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.
Files changed (89) hide show
  1. package/dist/agents/guide.d.ts +68 -0
  2. package/dist/agents/guide.d.ts.map +1 -0
  3. package/dist/agents/guide.js +72 -0
  4. package/dist/agents/guide.js.map +1 -0
  5. package/dist/agents/instructions.d.ts +133 -0
  6. package/dist/agents/instructions.d.ts.map +1 -0
  7. package/dist/agents/instructions.js +212 -0
  8. package/dist/agents/instructions.js.map +1 -0
  9. package/dist/agents/lint.d.ts +154 -0
  10. package/dist/agents/lint.d.ts.map +1 -0
  11. package/dist/agents/lint.js +761 -0
  12. package/dist/agents/lint.js.map +1 -0
  13. package/dist/agents/split.d.ts +113 -0
  14. package/dist/agents/split.d.ts.map +1 -0
  15. package/dist/agents/split.js +234 -0
  16. package/dist/agents/split.js.map +1 -0
  17. package/dist/cli/agents.d.ts +39 -0
  18. package/dist/cli/agents.d.ts.map +1 -0
  19. package/dist/cli/agents.js +121 -0
  20. package/dist/cli/agents.js.map +1 -0
  21. package/dist/cli/args.d.ts +1 -0
  22. package/dist/cli/args.d.ts.map +1 -1
  23. package/dist/cli/args.js.map +1 -1
  24. package/dist/cli/config.d.ts +20 -0
  25. package/dist/cli/config.d.ts.map +1 -1
  26. package/dist/cli/config.js +34 -1
  27. package/dist/cli/config.js.map +1 -1
  28. package/dist/cli/hooks.d.ts +25 -16
  29. package/dist/cli/hooks.d.ts.map +1 -1
  30. package/dist/cli/hooks.js +71 -178
  31. package/dist/cli/hooks.js.map +1 -1
  32. package/dist/cli/report.d.ts +23 -0
  33. package/dist/cli/report.d.ts.map +1 -1
  34. package/dist/cli/report.js +140 -2
  35. package/dist/cli/report.js.map +1 -1
  36. package/dist/cli/run.d.ts +5 -3
  37. package/dist/cli/run.d.ts.map +1 -1
  38. package/dist/cli/run.js +2 -1
  39. package/dist/cli/run.js.map +1 -1
  40. package/dist/cli/subcommands/agents.d.ts +72 -0
  41. package/dist/cli/subcommands/agents.d.ts.map +1 -0
  42. package/dist/cli/subcommands/agents.js +162 -0
  43. package/dist/cli/subcommands/agents.js.map +1 -0
  44. package/dist/cli/subcommands/flags.d.ts +3 -0
  45. package/dist/cli/subcommands/flags.d.ts.map +1 -1
  46. package/dist/cli/subcommands/flags.js +13 -1
  47. package/dist/cli/subcommands/flags.js.map +1 -1
  48. package/dist/cli/subcommands/hooks.d.ts.map +1 -1
  49. package/dist/cli/subcommands/hooks.js +19 -3
  50. package/dist/cli/subcommands/hooks.js.map +1 -1
  51. package/dist/cli/subcommands/registry.d.ts +2 -1
  52. package/dist/cli/subcommands/registry.d.ts.map +1 -1
  53. package/dist/cli/subcommands/registry.js +3 -0
  54. package/dist/cli/subcommands/registry.js.map +1 -1
  55. package/dist/cli/subcommands/subcommand.d.ts +1 -1
  56. package/dist/cli/subcommands/subcommand.d.ts.map +1 -1
  57. package/dist/cli/subcommands/subcommand.js.map +1 -1
  58. package/dist/harness/codex.d.ts.map +1 -1
  59. package/dist/harness/codex.js +1 -0
  60. package/dist/harness/codex.js.map +1 -1
  61. package/dist/harness/gemini.d.ts.map +1 -1
  62. package/dist/harness/gemini.js +1 -0
  63. package/dist/harness/gemini.js.map +1 -1
  64. package/dist/harness/grok.d.ts.map +1 -1
  65. package/dist/harness/grok.js +1 -0
  66. package/dist/harness/grok.js.map +1 -1
  67. package/dist/harness/hermes.d.ts.map +1 -1
  68. package/dist/harness/hermes.js +1 -0
  69. package/dist/harness/hermes.js.map +1 -1
  70. package/dist/harness/profile.d.ts +26 -1
  71. package/dist/harness/profile.d.ts.map +1 -1
  72. package/dist/harness/profile.js +15 -0
  73. package/dist/harness/profile.js.map +1 -1
  74. package/dist/harness/registry.d.ts +55 -6
  75. package/dist/harness/registry.d.ts.map +1 -1
  76. package/dist/harness/registry.js +63 -6
  77. package/dist/harness/registry.js.map +1 -1
  78. package/dist/index.d.ts +10 -0
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +8 -0
  81. package/dist/index.js.map +1 -1
  82. package/dist/text/json-edit.d.ts +71 -0
  83. package/dist/text/json-edit.d.ts.map +1 -0
  84. package/dist/text/json-edit.js +219 -0
  85. package/dist/text/json-edit.js.map +1 -0
  86. package/dist/types.d.ts +0 -11
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/types.js.map +1 -1
  89. package/package.json +1 -1
@@ -0,0 +1,761 @@
1
+ import { nodeFsReader } from '../repomap/reader.js';
2
+ import { citing, GUIDE } from './guide.js';
3
+ import { ancestorDirs, readInstructionSet, resolvesInTree } from './instructions.js';
4
+ /**
5
+ * `smelt agents lint` — the audit of the blob an agent loads on **every** request.
6
+ *
7
+ * smelt's whole subject is what a context window is spent on, and an instruction file
8
+ * is the one blob every request pays for whether or not it is relevant. So the lint is
9
+ * the same three moves smelt makes everywhere else, aimed at a file nobody measures:
10
+ *
11
+ * 1. **Measure, never threshold** (ruling R2). Bytes per level, the most any single
12
+ * request loads, and the repository-wide surface — three numbers, each labelled
13
+ * with the question it answers, because a monorepo makes the last two differ and
14
+ * printing one under the other's heading is a lie about a cost. Plus an imperative
15
+ * count that says out loud it is a heuristic. The only number that can fail a run
16
+ * is `agents.budgetBytes` in `smelt.config.json` — *the user's own*. There is no
17
+ * built-in budget, for exactly the reason `--budget` has none.
18
+ * 2. **Explain every finding** (Law 2, in the {@link ElisionReason} discipline). A
19
+ * finding is a stable `rule` id plus a sentence, and the sentence ends in a phrase
20
+ * from the guide it is applying, attributed — see `./guide.ts`.
21
+ * 3. **Resolve against the real tree** (ruling R3). `dead-path` and `dead-link` are
22
+ * the checks nobody else makes, because everyone else is linting Markdown while
23
+ * the thing that has rotted is the *repository the Markdown describes*. A renamed
24
+ * `src/auth/handlers.ts` does not make the file invalid; it makes it a lie that
25
+ * the agent believes on every request.
26
+ *
27
+ * **Advisory by default.** Findings exit 0. `--strict` turns any finding into exit 1
28
+ * for CI, because a check that cannot be enforced is a check nobody runs, and a check
29
+ * that is enforced by default is smelt deciding somebody's house style for them.
30
+ *
31
+ * The heuristics here are heuristics, and the report says so where it matters: the
32
+ * imperative count carries "(heuristic)" in its own label and in every receipt,
33
+ * `generated-boilerplate` calls itself the softest rule in its own explanation, and the
34
+ * closing line of every run states that findings are advisory and exit 0 unless
35
+ * `--strict` was asked for. A rule that fires on a file the guide would call minimal is
36
+ * not automatically a bug in the file — it may be a bug in the rule, or in the guide,
37
+ * and the answer is worth writing down either way (ruling R9; see this repository's own
38
+ * `AGENTS.md`).
39
+ */
40
+ /* ------------------------------------------------------------------------------------
41
+ * Rule ids — stable, and the whole machine-readable surface of a finding
42
+ * ---------------------------------------------------------------------------------- */
43
+ /** A path-like token in the prose that resolves to nothing in the tree. The flagship. */
44
+ export const DEAD_PATH_RULE = 'dead-path';
45
+ /** A Markdown link whose relative target is not in the tree. */
46
+ export const DEAD_LINK_RULE = 'dead-link';
47
+ /** "always", "never", ALL-CAPS forcing. */
48
+ export const FORCING_LANGUAGE_RULE = 'forcing-language';
49
+ /** A directory tree, or a run of bare path lines. */
50
+ export const STRUCTURE_DUMP_RULE = 'structure-dump';
51
+ /** The fingerprints an init script leaves. The softest rule here, and it says so. */
52
+ export const GENERATED_BOILERPLATE_RULE = 'generated-boilerplate';
53
+ /** A code-style rule that loads on every request to be relevant on some of them. */
54
+ export const LANGUAGE_RULE_RULE = 'language-rule';
55
+ /** A mirror (CLAUDE.md / GEMINI.md) that has diverged from its AGENTS.md. */
56
+ export const MIRROR_DRIFT_RULE = 'mirror-drift';
57
+ /** The same instruction present at a level and at one of its ancestors. */
58
+ export const RESTATED_AT_LEVEL_RULE = 'restated-at-level';
59
+ /**
60
+ * The rules a finding can carry, in report order.
61
+ *
62
+ * `Object.freeze`-flat on purpose: the ids are a wire surface. They go into `--json`,
63
+ * into CI greps and into whatever a user filters on, so they are declared once and
64
+ * never spelled again in prose.
65
+ */
66
+ export const AGENTS_LINT_RULES = [
67
+ DEAD_PATH_RULE,
68
+ DEAD_LINK_RULE,
69
+ FORCING_LANGUAGE_RULE,
70
+ STRUCTURE_DUMP_RULE,
71
+ GENERATED_BOILERPLATE_RULE,
72
+ LANGUAGE_RULE_RULE,
73
+ MIRROR_DRIFT_RULE,
74
+ RESTATED_AT_LEVEL_RULE,
75
+ ];
76
+ /**
77
+ * The imperative counter's rule id (ruling R6).
78
+ *
79
+ * **Deliberately not in {@link AGENTS_LINT_RULES}.** An imperative is not a defect —
80
+ * an instruction file is *made* of imperatives — so counting them is a measurement,
81
+ * like `outputBytes`, and putting them among the findings would make `--strict` red on
82
+ * every real AGENTS.md and therefore useless. Each counted line still carries a
83
+ * receipt naming the verb that matched, because a heuristic whose matches you cannot
84
+ * inspect is a number nobody can check.
85
+ */
86
+ export const IMPERATIVE_LINE_RULE = 'imperative-line';
87
+ /**
88
+ * Lint the merged set under `root`.
89
+ *
90
+ * Pure over its inputs and its reader: nothing is written, and every filesystem touch
91
+ * goes through {@link RepoReader}, which has no writer on it.
92
+ */
93
+ export function lintAgents(options) {
94
+ const reader = options.reader ?? nodeFsReader();
95
+ const set = readInstructionSet({
96
+ root: options.root,
97
+ reader,
98
+ ...(options.ignore === undefined ? {} : { ignore: options.ignore }),
99
+ });
100
+ const findings = [];
101
+ const imperatives = [];
102
+ for (const level of set.levels) {
103
+ // The imperative count is a companion to the byte total, so it is counted over
104
+ // exactly what the byte total is counted over: the primaries. A mirror is an
105
+ // alternative spelling of a level, not a second level — counting it would make
106
+ // the headline number describe a request nobody makes.
107
+ imperatives.push(...countImperatives(level.primary, scanLines(level.primary.text)));
108
+ // The rules, though, run over the primary **and every mirror that has actually
109
+ // diverged**: a drifted CLAUDE.md is what Claude Code loads, so its own dead paths
110
+ // are real. A symlink or a byte-identical copy is skipped — it would mint a
111
+ // duplicate of every finding on the primary and say nothing new.
112
+ const linted = [
113
+ level.primary,
114
+ ...level.mirrors.filter((mirror) => standingOf(level.primary, mirror) === 'drift'),
115
+ ];
116
+ for (const file of linted) {
117
+ const lines = scanLines(file.text);
118
+ findings.push(...findDeadLinks(file, lines, options.root, reader));
119
+ findings.push(...findDeadPaths(file, lines, options.root, reader));
120
+ findings.push(...findForcingLanguage(file, lines));
121
+ findings.push(...findStructureDumps(file, lines));
122
+ findings.push(...findGeneratedBoilerplate(file, lines));
123
+ findings.push(...findLanguageRules(file, lines));
124
+ }
125
+ findings.push(...findMirrorDrift(level.primary, level.mirrors));
126
+ }
127
+ findings.push(...findRestatedAcrossLevels(set));
128
+ return {
129
+ root: options.root,
130
+ levels: set.levels.map((level) => ({
131
+ dir: level.dir,
132
+ path: level.primary.path,
133
+ bytes: level.primary.bytes,
134
+ mirrors: level.mirrors.map((mirror) => ({
135
+ path: mirror.path,
136
+ bytes: mirror.bytes,
137
+ standing: standingOf(level.primary, mirror),
138
+ })),
139
+ })),
140
+ totalBytes: set.totalBytes,
141
+ perRequestBytes: set.perRequestBytes,
142
+ ...(options.budgetBytes === undefined ? {} : { budgetBytes: options.budgetBytes }),
143
+ imperatives,
144
+ findings: findings.toSorted(byRuleThenPlace),
145
+ };
146
+ }
147
+ /** How far over the user's budget the merged set is, or `undefined` when it fits. */
148
+ export function overBudgetBytes(report) {
149
+ if (report.budgetBytes === undefined)
150
+ return undefined;
151
+ const over = report.totalBytes - report.budgetBytes;
152
+ return over > 0 ? over : undefined;
153
+ }
154
+ /** Findings in report order: rule first, then where they were found. */
155
+ function byRuleThenPlace(a, b) {
156
+ const rank = ruleOrder(a.reason.rule) - ruleOrder(b.reason.rule) ||
157
+ (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) ||
158
+ a.line - b.line;
159
+ return rank;
160
+ }
161
+ function ruleOrder(rule) {
162
+ const index = AGENTS_LINT_RULES.indexOf(rule);
163
+ return index === -1 ? AGENTS_LINT_RULES.length : index;
164
+ }
165
+ /**
166
+ * Split a file into lines, marking fenced code.
167
+ *
168
+ * The distinction matters in both directions: a `const x = 1` inside a fence is an
169
+ * *example*, not a `language-rule`, and a tree drawing is only a `structure-dump`
170
+ * because it is a fence full of paths. Rules that read prose skip fences; the one
171
+ * rule that reads fences skips prose.
172
+ */
173
+ function scanLines(text) {
174
+ const out = [];
175
+ let fence;
176
+ text.split('\n').forEach((line, index) => {
177
+ const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
178
+ const opensFence = marker !== null && fence === undefined;
179
+ if (marker !== null) {
180
+ if (fence === undefined)
181
+ fence = marker[1].slice(0, 1);
182
+ else if (marker[1].startsWith(fence))
183
+ fence = undefined;
184
+ out.push({ number: index + 1, text: line, fenced: true, opensFence });
185
+ return;
186
+ }
187
+ out.push({ number: index + 1, text: line, fenced: fence !== undefined, opensFence: false });
188
+ });
189
+ return out;
190
+ }
191
+ /** A line stripped of list bullets, heading hashes, blockquote marks and bold runs. */
192
+ function bareText(text) {
193
+ return text
194
+ .replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+|#{1,6}\s+|>\s*)+/, '')
195
+ .replace(/\*\*/g, '')
196
+ .trim();
197
+ }
198
+ /* ------------------------------------------------------------------------------------
199
+ * imperative-line — a labelled heuristic (R6)
200
+ * ---------------------------------------------------------------------------------- */
201
+ /**
202
+ * The openers counted as an instruction.
203
+ *
204
+ * A closed list, deliberately: an open-ended part-of-speech guess would be a number
205
+ * nobody could reproduce, and this figure is reported beside a byte count that *is*
206
+ * exact. The modal openers the guide itself names — always / never / do not / must /
207
+ * should — are here alongside the verbs an instruction file actually opens with.
208
+ */
209
+ const IMPERATIVE_OPENERS = [
210
+ 'add',
211
+ 'always',
212
+ 'avoid',
213
+ 'build',
214
+ 'check',
215
+ 'commit',
216
+ 'create',
217
+ 'do',
218
+ 'document',
219
+ 'ensure',
220
+ 'follow',
221
+ 'format',
222
+ 'ignore',
223
+ 'implement',
224
+ 'install',
225
+ 'keep',
226
+ 'lint',
227
+ 'make',
228
+ 'must',
229
+ 'name',
230
+ 'never',
231
+ 'place',
232
+ 'prefer',
233
+ 'put',
234
+ 'read',
235
+ 'refuse',
236
+ 'remove',
237
+ 'return',
238
+ 'run',
239
+ 'should',
240
+ 'skip',
241
+ 'test',
242
+ 'throw',
243
+ 'treat',
244
+ 'update',
245
+ 'use',
246
+ 'verify',
247
+ 'write',
248
+ ];
249
+ /**
250
+ * Count the lines that read as instructions, one receipt each.
251
+ *
252
+ * Reported as `imperatives (heuristic)` and never as a precise figure, because it is
253
+ * not one: "Run `pnpm verify`" counts and "The gate is `pnpm verify`" does not, and
254
+ * both are the same instruction. The number is useful as a *scale* — the guide cites
255
+ * ~150-200 as what a frontier thinking model follows consistently — and useless as a
256
+ * threshold, which is why nothing here compares it to anything.
257
+ */
258
+ function countImperatives(file, lines) {
259
+ const out = [];
260
+ for (const line of lines) {
261
+ if (line.fenced)
262
+ continue;
263
+ const bare = bareText(line.text);
264
+ if (bare === '')
265
+ continue;
266
+ const opener = /^(do not|[A-Za-z']+)/.exec(bare.toLowerCase())?.[1];
267
+ if (opener === undefined)
268
+ continue;
269
+ const matched = opener === 'do not' ? 'do not' : opener === "don't" ? "don't" : opener;
270
+ const counted = matched === 'do not' ||
271
+ matched === "don't" ||
272
+ IMPERATIVE_OPENERS.includes(matched.replace(/'.*$/, ''));
273
+ if (!counted)
274
+ continue;
275
+ out.push({
276
+ file: file.path,
277
+ line: line.number,
278
+ reason: {
279
+ rule: IMPERATIVE_LINE_RULE,
280
+ explanation: `opens with "${matched}", so it is counted as one instruction (heuristic)` +
281
+ citing(GUIDE.instructionCeiling),
282
+ },
283
+ });
284
+ }
285
+ return out;
286
+ }
287
+ /* ------------------------------------------------------------------------------------
288
+ * dead-link — a Markdown link whose target left the tree
289
+ * ---------------------------------------------------------------------------------- */
290
+ /** `[text](target)`, with the target captured. */
291
+ const MARKDOWN_LINK = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
292
+ /**
293
+ * CommonMark lets a link destination be wrapped in angle brackets — `[x](<a b.md>)`,
294
+ * the only way to write one containing a space. The brackets are delimiters, not part
295
+ * of the path, and statting `<src/kept.ts>` never resolves: the rule would report a
296
+ * live file as dead, which is the exact failure this rule exists to avoid making.
297
+ */
298
+ function stripAngles(target) {
299
+ return target.startsWith('<') && target.endsWith('>') ? target.slice(1, -1) : target;
300
+ }
301
+ function findDeadLinks(file, lines, root, reader) {
302
+ const out = [];
303
+ for (const line of lines) {
304
+ if (line.fenced)
305
+ continue;
306
+ for (const match of line.text.matchAll(MARKDOWN_LINK)) {
307
+ const target = stripFragment(stripAngles(match[1]));
308
+ if (target === '' || isExternal(target))
309
+ continue;
310
+ const resolved = resolveAgainst(file.dir, target);
311
+ if (resolved === undefined)
312
+ continue;
313
+ if (resolvesInTree(root, reader, resolved))
314
+ continue;
315
+ out.push({
316
+ file: file.path,
317
+ line: line.number,
318
+ reason: {
319
+ rule: DEAD_LINK_RULE,
320
+ explanation: `links to \`${target}\`, which is not in the tree — the pointer the root ` +
321
+ `file exists to be goes nowhere` +
322
+ citing(GUIDE.pointsElsewhere),
323
+ },
324
+ });
325
+ }
326
+ }
327
+ return out;
328
+ }
329
+ /* ------------------------------------------------------------------------------------
330
+ * dead-path — the flagship (R3)
331
+ * ---------------------------------------------------------------------------------- */
332
+ /** A token with a directory separator: `src/auth`, `./scripts/build.mjs`, `docs/`. */
333
+ const SLASHED = /^\.{0,2}\/?[\w.+-]+(?:\/[\w.+-]+)*\/?$/;
334
+ /** A bare file name whose extension says "this is a file in this repo". */
335
+ const CODE_FILE = /^[\w.-]+\.(?:[cm]?[jt]sx?|json|md|ya?ml|toml|py|rs|go|rb|java|sh|sql|css|html)$/;
336
+ /**
337
+ * A host name: what `example.com/guide` is, and what a repository path never is.
338
+ *
339
+ * A scheme-less URL is the one thing that is shaped exactly like a relative path —
340
+ * dotted word, slash, more words — and the guide this very tool cites is written
341
+ * `aihero.dev/a-complete-guide-to-agents-md` in smelt's own help text. Only the
342
+ * segment before the first slash is tested, so `scripts/build.sh` is untouched: `sh`
343
+ * is a TLD *and* an extension, and which one it is depends entirely on where the dot
344
+ * sits relative to the separator.
345
+ */
346
+ const DOMAIN_HOST = /^[\w-]+(?:\.[\w-]+)*\.(?:com|org|net|io|dev|ai|app|co|me|sh|so|to|xyz|gg|cloud|page|info|blog)$/i;
347
+ /**
348
+ * `Node.js`, `Vue.js`, `Bun.sh`, `Three.js` — a product, not a file in this tree.
349
+ *
350
+ * Narrow on purpose: one capitalised word, then one of the five suffixes products are
351
+ * actually named with. It costs a bare mention of a PascalCase `Button.js`, which is
352
+ * a real filename — but that is a finding not made, and this is a false accusation not
353
+ * made, and on the flagship rule those two are not worth the same.
354
+ */
355
+ const PRODUCT_NAME = /^[A-Z][A-Za-z]*\.(?:js|sh|ai|dev|io)$/;
356
+ function findDeadPaths(file, lines, root, reader) {
357
+ const out = [];
358
+ const seen = new Set();
359
+ for (const line of lines) {
360
+ if (line.fenced)
361
+ continue;
362
+ // Markdown link targets belong to `dead-link`; blank them so one dead pointer is
363
+ // never reported twice under two rules.
364
+ const withoutLinks = line.text.replace(MARKDOWN_LINK, '[]()');
365
+ for (const token of pathCandidates(withoutLinks)) {
366
+ const key = `${String(line.number)}${token}`;
367
+ if (seen.has(key))
368
+ continue;
369
+ seen.add(key);
370
+ const resolved = resolveAgainst(file.dir, token);
371
+ // A token that climbed out of the tree with `..` is not a path this lint can
372
+ // rule on: it names something outside the repository, and both "dead" and
373
+ // "alive" would be guesses about a directory nobody handed us.
374
+ if (resolved === undefined)
375
+ continue;
376
+ if (resolvesInTree(root, reader, resolved))
377
+ continue;
378
+ out.push({
379
+ file: file.path,
380
+ line: line.number,
381
+ reason: {
382
+ rule: DEAD_PATH_RULE,
383
+ explanation: `names \`${token}\`, which resolves to nothing in this tree — an agent ` +
384
+ `reads this on every request and looks there anyway` +
385
+ citing(GUIDE.stalenessPoisons),
386
+ },
387
+ });
388
+ }
389
+ }
390
+ return out;
391
+ }
392
+ /**
393
+ * The path-like tokens on one line.
394
+ *
395
+ * Inline code spans first, because a path in an instruction file is nearly always in
396
+ * backticks; then bare words, filtered hard. The filters are the interesting part —
397
+ * every one of them is a false positive this rule made before it had them:
398
+ *
399
+ * - `https://…`, `mailto:` — not tree paths.
400
+ * - `@smeltjs/core`, `@types/node` — package names, which look exactly like paths.
401
+ * - `src/**\/*.ts` — a glob describes a set, and a set does not resolve.
402
+ * - `pnpm run build`, `and/or` — anything with whitespace, and anything whose
403
+ * segments carry no extension and no separator worth trusting.
404
+ * - `v1.2/v2` style version prose, caught by requiring a real segment shape.
405
+ * - `example.com/guide`, `aihero.dev/…` — a URL somebody wrote without its scheme.
406
+ * - `Node.js`, `Vue.js`, `Bun.sh` — products whose names end in an extension.
407
+ *
408
+ * The last two are why a token *without* a separator is a candidate only when it came
409
+ * out of a code span. In running prose a dotted bare word is far more often a product
410
+ * than a file, and this rule's whole value is that a reader believes it: one confident
411
+ * sentence accusing `Node.js` of having left the tree costs more trust than a dozen
412
+ * real findings earn. In backticks the author has said "this is a thing in my
413
+ * repository", and the rule takes them at their word.
414
+ */
415
+ function pathCandidates(text) {
416
+ const tokens = [];
417
+ const add = (raw, fromCodeSpan) => {
418
+ const token = raw.replace(/[),.:;]+$/, '').trim();
419
+ if (token === '' || !isPathLike(token, fromCodeSpan))
420
+ return;
421
+ tokens.push(token);
422
+ };
423
+ const withoutCode = text.replace(/`([^`]+)`/g, (_whole, inner) => {
424
+ add(inner, true);
425
+ return ' ';
426
+ });
427
+ for (const word of withoutCode.split(/\s+/))
428
+ add(word, false);
429
+ return tokens;
430
+ }
431
+ /**
432
+ * Is `token` a path into this tree?
433
+ *
434
+ * `fromCodeSpan` is the author's own signal, and it decides the one ambiguous case: a
435
+ * dotted word with no separator. See {@link pathCandidates}.
436
+ */
437
+ function isPathLike(token, fromCodeSpan) {
438
+ if (isExternal(token))
439
+ return false;
440
+ if (token.startsWith('@'))
441
+ return false;
442
+ if (/[*?[\]{}<>|"'`\\]/.test(token))
443
+ return false;
444
+ if (token.startsWith('#'))
445
+ return false;
446
+ if (!SLASHED.test(token))
447
+ return false;
448
+ if (token.includes('/')) {
449
+ // `example.com/guide` — a URL with its scheme left off, not a directory.
450
+ return !DOMAIN_HOST.test(token.split('/')[0] ?? '');
451
+ }
452
+ return fromCodeSpan && !PRODUCT_NAME.test(token) && CODE_FILE.test(token);
453
+ }
454
+ /** True for anything that is not a path into this tree. */
455
+ function isExternal(target) {
456
+ return /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//') || target.startsWith('#');
457
+ }
458
+ function stripFragment(target) {
459
+ const cut = target.indexOf('#');
460
+ return cut === -1 ? target : target.slice(0, cut);
461
+ }
462
+ /**
463
+ * A token in a nested instruction file is relative to *that* file's directory, which
464
+ * is the whole reason a nested file can hold a link the root one cannot. `../` is
465
+ * resolved rather than refused, so a nested file may point back up the tree.
466
+ *
467
+ * `undefined` when the token climbs past the repository root. It used to clamp there —
468
+ * `stack.pop()` on an empty stack is a no-op — which silently turned `../sibling/x.ts`
469
+ * into `sibling/x.ts` and then answered a question about the wrong file, in whichever
470
+ * direction happened to be wrong. Outside the tree, this lint has nothing to say.
471
+ */
472
+ function resolveAgainst(dir, token) {
473
+ const base = dir === '' ? [] : dir.split('/');
474
+ const parts = token.replace(/\/+$/, '').split('/');
475
+ const stack = [...base];
476
+ for (const part of parts) {
477
+ if (part === '' || part === '.')
478
+ continue;
479
+ if (part === '..') {
480
+ if (stack.length === 0)
481
+ return undefined;
482
+ stack.pop();
483
+ }
484
+ else
485
+ stack.push(part);
486
+ }
487
+ return stack.join('/');
488
+ }
489
+ /* ------------------------------------------------------------------------------------
490
+ * forcing-language
491
+ * ---------------------------------------------------------------------------------- */
492
+ /** ALL-CAPS words a reader meets as shouting rather than as an acronym. */
493
+ const SHOUTED = /\b(ALWAYS|NEVER|MUST|DO NOT|DON'T|REQUIRED|MANDATORY|CRITICAL|IMPORTANT)\b/;
494
+ /** The two words the guide names in its own example of what *not* to write. */
495
+ const FORCING_WORDS = /\b(always|never)\b/i;
496
+ function findForcingLanguage(file, lines) {
497
+ const out = [];
498
+ for (const line of lines) {
499
+ if (line.fenced)
500
+ continue;
501
+ const shouted = SHOUTED.exec(line.text)?.[1];
502
+ const forcing = shouted ?? FORCING_WORDS.exec(line.text)?.[1];
503
+ if (forcing === undefined)
504
+ continue;
505
+ out.push({
506
+ file: file.path,
507
+ line: line.number,
508
+ reason: {
509
+ rule: FORCING_LANGUAGE_RULE,
510
+ explanation: `forces with "${forcing}" — forcing language spends tokens on emphasis ` +
511
+ `rather than on information` +
512
+ citing(GUIDE.lightTouch),
513
+ },
514
+ });
515
+ }
516
+ return out;
517
+ }
518
+ /* ------------------------------------------------------------------------------------
519
+ * structure-dump
520
+ * ---------------------------------------------------------------------------------- */
521
+ /**
522
+ * The characters a generated tree listing is drawn with.
523
+ *
524
+ * Both charsets: `tree` draws box-drawing by default and `|--` / `` `-- `` under
525
+ * `--charset=ascii`, and the ASCII form is what lands in a file written on a machine
526
+ * whose terminal was not UTF-8. Missing it made the whole rule silent on a fenced
527
+ * ASCII tree — and because fenced lines skip `dead-path` too, that block produced no
528
+ * finding of any kind.
529
+ */
530
+ const TREE_DRAWING = /[├└│─]|^\s*(?:\|--|`--|\|\s{3}|\+--)/;
531
+ /** How many path-ish lines in a row read as a dump rather than as an example. */
532
+ const DUMP_RUN = 3;
533
+ function findStructureDumps(file, lines) {
534
+ const out = [];
535
+ // A fenced block whose body is a tree drawing.
536
+ let fenceStart;
537
+ let drawn = 0;
538
+ for (const line of lines) {
539
+ if (line.opensFence) {
540
+ fenceStart = line;
541
+ drawn = 0;
542
+ continue;
543
+ }
544
+ if (!line.fenced) {
545
+ fenceStart = undefined;
546
+ continue;
547
+ }
548
+ if (fenceStart === undefined)
549
+ continue;
550
+ if (TREE_DRAWING.test(line.text))
551
+ drawn += 1;
552
+ if (drawn === 2) {
553
+ out.push(structureFinding(file, fenceStart.number, 'a directory tree'));
554
+ fenceStart = undefined;
555
+ }
556
+ }
557
+ // A run of bare path lines in prose — the same dump without the box characters.
558
+ let run = 0;
559
+ let runStart = 0;
560
+ for (const line of lines) {
561
+ if (line.fenced) {
562
+ run = 0;
563
+ continue;
564
+ }
565
+ const bare = bareText(line.text);
566
+ // A line whose whole content is one token is a path line, not prose — so the
567
+ // code-span rule `dead-path` needs against running text has nothing to guard here.
568
+ const isPathLine = bare !== '' && isPathLike(bare.replace(/`/g, '').split(/\s+/)[0] ?? '', true);
569
+ if (isPathLine && bare.split(/\s+/).length <= 2) {
570
+ if (run === 0)
571
+ runStart = line.number;
572
+ run += 1;
573
+ if (run === DUMP_RUN)
574
+ out.push(structureFinding(file, runStart, 'a run of path lines'));
575
+ }
576
+ else {
577
+ run = 0;
578
+ }
579
+ }
580
+ return out;
581
+ }
582
+ function structureFinding(file, line, what) {
583
+ return {
584
+ file: file.path,
585
+ line,
586
+ reason: {
587
+ rule: STRUCTURE_DUMP_RULE,
588
+ explanation: `spends the every-request budget on ${what} — layout is the fact in a ` +
589
+ `repository that changes most often, so it is also the fact that rots first` +
590
+ citing(GUIDE.describeCapabilities),
591
+ },
592
+ };
593
+ }
594
+ /* ------------------------------------------------------------------------------------
595
+ * generated-boilerplate — the softest rule here, and it says so
596
+ * ---------------------------------------------------------------------------------- */
597
+ /** The fingerprints an init script leaves behind, with what each one is. */
598
+ const BOILERPLATE_SIGNATURES = [
599
+ [/\bauto-?generated\b/i, 'an "auto-generated" marker'],
600
+ [/\bgenerated by\b/i, 'a "generated by" credit'],
601
+ [/<!--\s*generated/i, 'a generated-block comment'],
602
+ [/\b(?:claude|codex|gemini|cursor|agents?)\s+init\b/i, 'an init-command credit'],
603
+ [/\bthis file was (?:created|generated)\b/i, 'a "this file was generated" line'],
604
+ [/\bdo not edit\b/i, 'a "do not edit" banner'],
605
+ ];
606
+ function findGeneratedBoilerplate(file, lines) {
607
+ const out = [];
608
+ for (const line of lines) {
609
+ if (line.fenced)
610
+ continue;
611
+ for (const [pattern, what] of BOILERPLATE_SIGNATURES) {
612
+ if (!pattern.test(line.text))
613
+ continue;
614
+ out.push({
615
+ file: file.path,
616
+ line: line.number,
617
+ reason: {
618
+ rule: GENERATED_BOILERPLATE_RULE,
619
+ explanation: `carries ${what}, which suggests this file was generated rather than ` +
620
+ `written. This is the softest rule here: a signature is circumstantial, ` +
621
+ `and a hand-written file may honestly carry one, so it never means more ` +
622
+ `than "read this file again"` +
623
+ citing(GUIDE.neverGenerate),
624
+ },
625
+ });
626
+ break;
627
+ }
628
+ }
629
+ return out;
630
+ }
631
+ /* ------------------------------------------------------------------------------------
632
+ * language-rule
633
+ * ---------------------------------------------------------------------------------- */
634
+ /** Style rules that pay their every-request cost only when the agent writes code. */
635
+ const LANGUAGE_RULE_SIGNATURES = [
636
+ [/\bconst\b[^\n]*\blet\b|\blet\b[^\n]*\bconst\b/, 'a const/let rule'],
637
+ [/\binterface\b[^\n]*\btype\b|\btype\b[^\n]*\binterface\b/, 'an interface-vs-type rule'],
638
+ [/\bstrict[- ]?null(?:checks)?\b/i, 'a strict-null rule'],
639
+ [/\bsemi-?colons?\b/i, 'a semicolon rule'],
640
+ [/\b(?:single|double) quotes\b/i, 'a quote-style rule'],
641
+ [/\barrow functions?\b/i, 'an arrow-function rule'],
642
+ [/\bnamed exports?\b|\bdefault exports?\b/i, 'an export-style rule'],
643
+ [
644
+ /\btabs? (?:over|versus|vs\.?) spaces?\b|\bspaces? (?:over|versus|vs\.?) tabs?\b/i,
645
+ 'an indentation rule',
646
+ ],
647
+ ];
648
+ function findLanguageRules(file, lines) {
649
+ const out = [];
650
+ for (const line of lines) {
651
+ if (line.fenced)
652
+ continue;
653
+ for (const [pattern, what] of LANGUAGE_RULE_SIGNATURES) {
654
+ if (!pattern.test(line.text))
655
+ continue;
656
+ out.push({
657
+ file: file.path,
658
+ line: line.number,
659
+ reason: {
660
+ rule: LANGUAGE_RULE_RULE,
661
+ explanation: `states ${what}, which is paid for on every request and is relevant on ` +
662
+ `few of them — move it behind a link and it costs only the tasks it applies to` +
663
+ citing(GUIDE.loadWhenRelevant),
664
+ },
665
+ });
666
+ break;
667
+ }
668
+ }
669
+ return out;
670
+ }
671
+ /* ------------------------------------------------------------------------------------
672
+ * mirror-drift (R4)
673
+ * ---------------------------------------------------------------------------------- */
674
+ function standingOf(primary, mirror) {
675
+ if (mirror.symlink)
676
+ return 'symlink';
677
+ return mirror.text === primary.text ? 'copy' : 'drift';
678
+ }
679
+ /**
680
+ * A mirror that has diverged from its `AGENTS.md`.
681
+ *
682
+ * A byte-identical copy is **not** a finding: it is not drift, and calling it one
683
+ * would be smelt enforcing the guide's suggestion rather than reporting a fact. What
684
+ * the report does say, beside every copy, is that a symlink cannot drift — which is
685
+ * the guide's suggestion offered, exactly as `smelt hooks` offers rather than does.
686
+ */
687
+ function findMirrorDrift(primary, mirrors) {
688
+ return mirrors
689
+ .filter((mirror) => standingOf(primary, mirror) === 'drift')
690
+ .map((mirror) => ({
691
+ file: mirror.path,
692
+ line: 1,
693
+ reason: {
694
+ rule: MIRROR_DRIFT_RULE,
695
+ explanation: `has diverged from \`${primary.path}\` (${String(mirror.bytes)} bytes against ` +
696
+ `${String(primary.bytes)}) — two harnesses are now reading two different sets ` +
697
+ `of instructions from one repository` +
698
+ citing(GUIDE.symlinkMirror),
699
+ },
700
+ }));
701
+ }
702
+ /* ------------------------------------------------------------------------------------
703
+ * restated-at-level (R8)
704
+ * ---------------------------------------------------------------------------------- */
705
+ /** Below this, a repeated line is a heading or a bullet marker, not an instruction. */
706
+ const RESTATEMENT_MIN_CHARS = 40;
707
+ /**
708
+ * The same instruction present at a level **and at one of its ancestors**.
709
+ *
710
+ * The guide's rule is that a nested file *merges with* the root, so a line written in
711
+ * both is a line the agent is handed twice — paid for twice, and the second copy
712
+ * carrying the risk that only one of them is ever updated. Reported on the deeper file,
713
+ * because that is the copy the ancestor already covers.
714
+ *
715
+ * **Only ancestors.** A merge runs up the tree, never across it: an agent working in
716
+ * `pkg/a` loads the root file and `pkg/a`'s, and never `pkg/b`'s. So a line two
717
+ * siblings happen to share is not a line anybody is handed twice, and reporting it as
718
+ * one would print an explanation about a merge that does not happen — a finding whose
719
+ * sentence is false, which is worse than no finding at all (Law 2).
720
+ */
721
+ function findRestatedAcrossLevels(set) {
722
+ /** dir → the lines that level states, each with the file that states them. */
723
+ const stated = new Map();
724
+ const out = [];
725
+ // Levels arrive root-first (`readInstructionSet` sorts by depth), so every ancestor
726
+ // of a level has already been recorded by the time the level is read.
727
+ for (const level of set.levels) {
728
+ const file = level.primary;
729
+ const local = new Map();
730
+ stated.set(level.dir, local);
731
+ const ancestors = ancestorDirs(level.dir);
732
+ scanLines(file.text).forEach((line) => {
733
+ if (line.fenced)
734
+ return;
735
+ const normalized = bareText(line.text).toLowerCase().replace(/\s+/g, ' ');
736
+ if (normalized.length < RESTATEMENT_MIN_CHARS)
737
+ return;
738
+ if (local.has(normalized))
739
+ return;
740
+ local.set(normalized, file.path);
741
+ const earlier = ancestors
742
+ .map((dir) => stated.get(dir)?.get(normalized))
743
+ .find((path) => path !== undefined);
744
+ if (earlier === undefined)
745
+ return;
746
+ out.push({
747
+ file: file.path,
748
+ line: line.number,
749
+ reason: {
750
+ rule: RESTATED_AT_LEVEL_RULE,
751
+ explanation: `repeats a line already in \`${earlier}\`, which is above it — the levels ` +
752
+ `merge, so the agent is handed this twice and only one copy will be kept ` +
753
+ `up to date` +
754
+ citing(GUIDE.nestedMerge),
755
+ },
756
+ });
757
+ });
758
+ }
759
+ return out;
760
+ }
761
+ //# sourceMappingURL=lint.js.map