vigiles 16.1.0 → 16.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -192,4 +192,61 @@ export declare function leafCommandsNormalized(command: string): NormalizedLeaf[
192
192
  * Parse failure → `[]`.
193
193
  */
194
194
  export declare function leafArgvSource(command: string): string[][];
195
+ /**
196
+ * Every word of every simple command in `command` that could name a FILE, with
197
+ * inline PROGRAM TEXT removed — the primitive behind "does this hook's script
198
+ * exist?".
199
+ *
200
+ * 🔴 WHY A FOURTH EXTRACTOR, stated against the three that already exist,
201
+ * because "there is already one that returns words" is exactly the reasoning
202
+ * that produced the bug this replaces.
203
+ *
204
+ * - `leafCommands` drops every word it cannot reduce to a LITERAL, so
205
+ * `${CLAUDE_PLUGIN_ROOT}/hooks/x.sh` — the standard spelling of a hook path —
206
+ * disappears entirely. Unusable for a file question.
207
+ * - `leafCommandsNormalized` basenames the head, so `./hooks/x.sh` becomes
208
+ * `x.sh` and no resolver can find it.
209
+ * - `leafArgvSource` keeps the spelling but answers a DIFFERENT question:
210
+ * "which leaves unconditionally RUN". It drops the right-hand side of `&&`
211
+ * by design, so `cd "$ROOT" && node hooks/x.mjs` yields no script. For
212
+ * coverage attribution that abstention is correct; for "must this file
213
+ * exist?" it is a miss, because a conditionally-run script still has to be
214
+ * on disk.
215
+ *
216
+ * So this walks EVERY simple command, keeps every word at source level, and
217
+ * subtracts only the words that are provably not paths.
218
+ *
219
+ * 🔴 WHAT IT SUBTRACTS, and the defect that motivated it. The hook scanner used
220
+ * to run a regex over the raw command STRING. Against the standard portable
221
+ * plugin idiom —
222
+ *
223
+ * node -e "(async()=>{…await import(…join(root,'hooks','always-on.mjs'))…})()"
224
+ *
225
+ * — it grabbed a character run ending at `.mjs` and reported the hook's script
226
+ * as `import(require(node:url).pathToFileURL(require(node:path).join(root,hooks,always-on.mjs`,
227
+ * MISSING. `hooks/always-on.mjs` was 1,766 bytes on disk. Nine such findings
228
+ * across the 32-repo corpus, contributing to two `F/0` grades. Inside a shell
229
+ * parse the argument of `-e` is not a word the shell will ever resolve to a
230
+ * file, so it is not returned.
231
+ *
232
+ * ⚠️ HOW MUCH OF THAT THE FLAG TABLE ACTUALLY DID, measured rather than
233
+ * assumed: none of it, on that corpus. Mutating the `-e`/`-c` subtraction OFF
234
+ * and re-auditing all three affected repos still yields ZERO false hook-script
235
+ * findings, because both real payloads are single words that either contain
236
+ * whitespace or do not end in a script extension, and the caller anchors its
237
+ * match to a whole word. The table is kept because it is the difference
238
+ * between a function whose contract ("words that could name a file") is true
239
+ * and one whose contract is merely true-so-far: without it a JavaScript
240
+ * program is handed to every caller as a candidate filename, and the next
241
+ * caller inherits the bug. Recorded here so nobody reads a corpus number back
242
+ * onto the wrong mechanism.
243
+ *
244
+ * Words beginning with `-` are dropped as flags: a flag is not a path, and the
245
+ * one caller anchors its match to a whole word anyway.
246
+ *
247
+ * Returns `null` — not `[]` — when the text does not parse as shell, so a
248
+ * caller can tell "no file operands" from "no analysis", and cannot silently
249
+ * treat the second as the first.
250
+ */
251
+ export declare function commandWords(command: string): string[] | null;
195
252
  //# sourceMappingURL=bash-effects.d.ts.map
@@ -29,6 +29,7 @@ exports.isReadOnlyBash = isReadOnlyBash;
29
29
  exports.leafCommands = leafCommands;
30
30
  exports.leafCommandsNormalized = leafCommandsNormalized;
31
31
  exports.leafArgvSource = leafArgvSource;
32
+ exports.commandWords = commandWords;
32
33
  // mvdan-sh is a CJS package (GopherJS build) with no bundled TypeScript types.
33
34
  // The project compiles to CommonJS (Node16, no "type":"module"), so plain
34
35
  // require() works and is the idiomatic pattern here (see linters.ts).
@@ -1098,4 +1099,150 @@ function normalizeCallExpr(node, redirs) {
1098
1099
  chdir: stripped.chdir,
1099
1100
  };
1100
1101
  }
1102
+ // ===========================================================================
1103
+ // FILE-OPERAND extraction (the reference question, not the effect question)
1104
+ // ===========================================================================
1105
+ /**
1106
+ * Interpreters, and the flags after which the NEXT word is a PROGRAM rather
1107
+ * than a path. `node -e "<js>"`, `python -c "<py>"`, `perl -E "<pl>"`.
1108
+ *
1109
+ * Keyed by the head's basename, so `/usr/local/bin/node` and `node` behave the
1110
+ * same.
1111
+ */
1112
+ const INLINE_PROGRAM_FLAGS = new Map([
1113
+ ["node", ["-e", "--eval", "-p", "--print"]],
1114
+ ["nodejs", ["-e", "--eval", "-p", "--print"]],
1115
+ ["bun", ["-e", "--eval", "-p", "--print"]],
1116
+ ["deno", ["-e", "--eval", "-p", "--print"]],
1117
+ ["python", ["-c"]],
1118
+ ["python2", ["-c"]],
1119
+ ["python3", ["-c"]],
1120
+ ["ruby", ["-e"]],
1121
+ ["perl", ["-e", "-E"]],
1122
+ ["php", ["-r"]],
1123
+ ]);
1124
+ /**
1125
+ * Shells, whose `-c` argument is a nested SHELL program. Not program text to be
1126
+ * discarded — program text to be PARSED, so `bash -c 'exec "$ROOT/hooks/x.sh"'`
1127
+ * still yields its script.
1128
+ */
1129
+ const SHELL_HEADS = new Set([
1130
+ "sh",
1131
+ "bash",
1132
+ "zsh",
1133
+ "dash",
1134
+ "ksh",
1135
+ "ash",
1136
+ "busybox",
1137
+ ]);
1138
+ /** Guard against a pathological `sh -c 'sh -c "sh -c …"'` nest. */
1139
+ const MAX_SHELL_NESTING = 3;
1140
+ /**
1141
+ * Every word of every simple command in `command` that could name a FILE, with
1142
+ * inline PROGRAM TEXT removed — the primitive behind "does this hook's script
1143
+ * exist?".
1144
+ *
1145
+ * 🔴 WHY A FOURTH EXTRACTOR, stated against the three that already exist,
1146
+ * because "there is already one that returns words" is exactly the reasoning
1147
+ * that produced the bug this replaces.
1148
+ *
1149
+ * - `leafCommands` drops every word it cannot reduce to a LITERAL, so
1150
+ * `${CLAUDE_PLUGIN_ROOT}/hooks/x.sh` — the standard spelling of a hook path —
1151
+ * disappears entirely. Unusable for a file question.
1152
+ * - `leafCommandsNormalized` basenames the head, so `./hooks/x.sh` becomes
1153
+ * `x.sh` and no resolver can find it.
1154
+ * - `leafArgvSource` keeps the spelling but answers a DIFFERENT question:
1155
+ * "which leaves unconditionally RUN". It drops the right-hand side of `&&`
1156
+ * by design, so `cd "$ROOT" && node hooks/x.mjs` yields no script. For
1157
+ * coverage attribution that abstention is correct; for "must this file
1158
+ * exist?" it is a miss, because a conditionally-run script still has to be
1159
+ * on disk.
1160
+ *
1161
+ * So this walks EVERY simple command, keeps every word at source level, and
1162
+ * subtracts only the words that are provably not paths.
1163
+ *
1164
+ * 🔴 WHAT IT SUBTRACTS, and the defect that motivated it. The hook scanner used
1165
+ * to run a regex over the raw command STRING. Against the standard portable
1166
+ * plugin idiom —
1167
+ *
1168
+ * node -e "(async()=>{…await import(…join(root,'hooks','always-on.mjs'))…})()"
1169
+ *
1170
+ * — it grabbed a character run ending at `.mjs` and reported the hook's script
1171
+ * as `import(require(node:url).pathToFileURL(require(node:path).join(root,hooks,always-on.mjs`,
1172
+ * MISSING. `hooks/always-on.mjs` was 1,766 bytes on disk. Nine such findings
1173
+ * across the 32-repo corpus, contributing to two `F/0` grades. Inside a shell
1174
+ * parse the argument of `-e` is not a word the shell will ever resolve to a
1175
+ * file, so it is not returned.
1176
+ *
1177
+ * ⚠️ HOW MUCH OF THAT THE FLAG TABLE ACTUALLY DID, measured rather than
1178
+ * assumed: none of it, on that corpus. Mutating the `-e`/`-c` subtraction OFF
1179
+ * and re-auditing all three affected repos still yields ZERO false hook-script
1180
+ * findings, because both real payloads are single words that either contain
1181
+ * whitespace or do not end in a script extension, and the caller anchors its
1182
+ * match to a whole word. The table is kept because it is the difference
1183
+ * between a function whose contract ("words that could name a file") is true
1184
+ * and one whose contract is merely true-so-far: without it a JavaScript
1185
+ * program is handed to every caller as a candidate filename, and the next
1186
+ * caller inherits the bug. Recorded here so nobody reads a corpus number back
1187
+ * onto the wrong mechanism.
1188
+ *
1189
+ * Words beginning with `-` are dropped as flags: a flag is not a path, and the
1190
+ * one caller anchors its match to a whole word anyway.
1191
+ *
1192
+ * Returns `null` — not `[]` — when the text does not parse as shell, so a
1193
+ * caller can tell "no file operands" from "no analysis", and cannot silently
1194
+ * treat the second as the first.
1195
+ */
1196
+ function commandWords(command) {
1197
+ return commandWordsAt(command, 0);
1198
+ }
1199
+ function commandWordsAt(command, depth) {
1200
+ let file;
1201
+ try {
1202
+ file = sh.syntax.NewParser().Parse(command, "cmd.sh");
1203
+ }
1204
+ catch {
1205
+ return null;
1206
+ }
1207
+ const out = [];
1208
+ sh.syntax.Walk(file, (node) => {
1209
+ if (sh.syntax.NodeType(node) === "CallExpr" && node.Args?.length)
1210
+ fileOperandsOf(node.Args, depth, out);
1211
+ return true;
1212
+ });
1213
+ return out;
1214
+ }
1215
+ /**
1216
+ * The file-operand words of ONE simple command, appended to `out`.
1217
+ *
1218
+ * Wrappers are resolved through with the same table `leafArgvSource` uses (it
1219
+ * keys on the BASENAME head, and wrappers only ever drop words off the FRONT,
1220
+ * so a count maps the result back onto the original spellings — not a second
1221
+ * copy of the rule). Flags never name a file, so they are dropped; the word
1222
+ * AFTER an inline-program flag is dropped with them, and the word after a
1223
+ * shell's `-c` is parsed as shell instead.
1224
+ */
1225
+ function fileOperandsOf(args, depth, out) {
1226
+ const raw = args.map((w) => sourceParts(w.Parts) ?? "");
1227
+ const probe = [normalizeHead(raw[0] ?? ""), ...raw.slice(1)];
1228
+ const argv = raw.slice(probe.length - stripWrappers(probe).argv.length);
1229
+ const head = normalizeHead(argv[0] ?? "");
1230
+ const programFlags = INLINE_PROGRAM_FLAGS.get(head);
1231
+ const nestsShell = SHELL_HEADS.has(head) && depth < MAX_SHELL_NESTING;
1232
+ for (let i = 0; i < argv.length; i++) {
1233
+ const w = argv[i] ?? "";
1234
+ if (w === "")
1235
+ continue;
1236
+ if (!w.startsWith("-")) {
1237
+ out.push(w);
1238
+ continue;
1239
+ }
1240
+ if (nestsShell && w === "-c") {
1241
+ out.push(...(commandWordsAt(argv[++i] ?? "", depth + 1) ?? []));
1242
+ }
1243
+ else if (programFlags?.includes(w)) {
1244
+ i++; // the program text — not a word any shell resolves to a file
1245
+ }
1246
+ }
1247
+ }
1101
1248
  //# sourceMappingURL=bash-effects.js.map
@@ -42,4 +42,57 @@ export interface FencedBlock {
42
42
  * so a caller's message points at the real line.
43
43
  */
44
44
  export declare function fencedCodeBlocks(src: string): FencedBlock[];
45
+ /**
46
+ * How a reference appeared in the markdown.
47
+ *
48
+ * `link` is a DESTINATION — the thing the reader follows: an inline link's
49
+ * target, or an image's `src`. `code` is a bare backtick span standing on its
50
+ * own in prose.
51
+ */
52
+ export type MarkdownRefKind = "link" | "code";
53
+ /** One reference recovered from markdown STRUCTURE. */
54
+ export interface MarkdownRef {
55
+ readonly kind: MarkdownRefKind;
56
+ /**
57
+ * For `link`, the destination exactly as written. For `code`, the span's
58
+ * content. Never the display text of a link — see {@link markdownRefs}.
59
+ */
60
+ readonly value: string;
61
+ /** 1-based source line the reference sits on. */
62
+ readonly line: number;
63
+ }
64
+ /**
65
+ * Every reference a markdown body makes, taken from the PARSE rather than from
66
+ * the characters.
67
+ *
68
+ * 🔴 WHY THIS EXISTS: A LINK'S TEXT IS NOT A REFERENCE. The detector this
69
+ * replaces ran two regexes over each line — one for `[..](..)`, one for
70
+ * `` `..` `` — and the second one could not see that it was standing inside the
71
+ * first. Measured 2026-08-17 on `microsoft/power-platform-skills`:
72
+ *
73
+ * See [`references/dataverse-reference.md` § Setting Lookups](../add-dataverse/references/dataverse-reference.md#setting-lookups)
74
+ *
75
+ * The DESTINATION resolves — the file is 23KB and present. The backtick span in
76
+ * the link's TEXT is a human-readable label for it. vigiles reported the label
77
+ * as a missing bundled resource, i.e. it accused a correct link of being broken
78
+ * by reading the half of it that is display. The same shape cost
79
+ * `rohitg00/pro-workflow` a second false accusation.
80
+ *
81
+ * So a code span nested inside a link's (or an image's) text is NOT emitted.
82
+ * The destination is right there, it is what the agent follows, and it is
83
+ * returned instead. The bug is not fixed here so much as made unsayable: a
84
+ * caller of this function is never handed link text at all.
85
+ *
86
+ * Fenced and indented code blocks contribute nothing ({@link fencedLineFlags}
87
+ * decides which lines those are, so no caller re-derives it).
88
+ *
89
+ * ⚠️ ONE LINE AT A TIME, and the reason is the line number. Callers report a
90
+ * reference by source line, and markdown-it's inline tokens carry no line
91
+ * information — only the enclosing block does — so a paragraph-wide parse would
92
+ * point every reference in a paragraph at the paragraph's first line. Parsing
93
+ * each line's inline content keeps the number exact. The cost is a construct
94
+ * split across two source lines (a link whose `](` sits on the next line),
95
+ * which is not recovered — the regexes this replaces did not recover it either.
96
+ */
97
+ export declare function markdownRefs(src: string): MarkdownRef[];
45
98
  //# sourceMappingURL=markdown.d.ts.map
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.fencedLineFlags = fencedLineFlags;
7
7
  exports.fencedCodeBlocks = fencedCodeBlocks;
8
+ exports.markdownRefs = markdownRefs;
8
9
  /**
9
10
  * vigiles — the ONE markdown-structure helper.
10
11
  *
@@ -25,6 +26,23 @@ exports.fencedCodeBlocks = fencedCodeBlocks;
25
26
  const markdown_it_1 = __importDefault(require("markdown-it"));
26
27
  // One reusable parser; parse() is stateless across calls.
27
28
  const md = new markdown_it_1.default();
29
+ /**
30
+ * A SECOND parser, used only by {@link markdownRefs}, with link handling turned
31
+ * down to "report exactly what the author wrote":
32
+ *
33
+ * - `normalizeLink` is neutered because the default percent-ENCODES the
34
+ * destination. A detector downstream reads `%NN` as the signature of a URL or
35
+ * of a documentation example about escaping spaces, and skips it; letting
36
+ * markdown-it encode on the way in would manufacture that signature for any
37
+ * destination holding a space or a non-ASCII character.
38
+ * - `validateLink` is opened because the default silently REFUSES to build a
39
+ * link token for schemes it distrusts (`javascript:`, `data:`), which would
40
+ * turn "a destination this tool declines to resolve" into "no destination at
41
+ * all". Skipping by scheme is the caller's job and it already does it.
42
+ */
43
+ const mdRefs = new markdown_it_1.default();
44
+ mdRefs.normalizeLink = (url) => url;
45
+ mdRefs.validateLink = () => true;
28
46
  /**
29
47
  * A boolean per source line (0-based): `true` when the line lies inside a fenced
30
48
  * code block (` ``` ` or `~~~`), the delimiter lines included — matching the
@@ -85,4 +103,85 @@ function fencedCodeBlocks(src) {
85
103
  }
86
104
  return out;
87
105
  }
106
+ /**
107
+ * Every reference a markdown body makes, taken from the PARSE rather than from
108
+ * the characters.
109
+ *
110
+ * 🔴 WHY THIS EXISTS: A LINK'S TEXT IS NOT A REFERENCE. The detector this
111
+ * replaces ran two regexes over each line — one for `[..](..)`, one for
112
+ * `` `..` `` — and the second one could not see that it was standing inside the
113
+ * first. Measured 2026-08-17 on `microsoft/power-platform-skills`:
114
+ *
115
+ * See [`references/dataverse-reference.md` § Setting Lookups](../add-dataverse/references/dataverse-reference.md#setting-lookups)
116
+ *
117
+ * The DESTINATION resolves — the file is 23KB and present. The backtick span in
118
+ * the link's TEXT is a human-readable label for it. vigiles reported the label
119
+ * as a missing bundled resource, i.e. it accused a correct link of being broken
120
+ * by reading the half of it that is display. The same shape cost
121
+ * `rohitg00/pro-workflow` a second false accusation.
122
+ *
123
+ * So a code span nested inside a link's (or an image's) text is NOT emitted.
124
+ * The destination is right there, it is what the agent follows, and it is
125
+ * returned instead. The bug is not fixed here so much as made unsayable: a
126
+ * caller of this function is never handed link text at all.
127
+ *
128
+ * Fenced and indented code blocks contribute nothing ({@link fencedLineFlags}
129
+ * decides which lines those are, so no caller re-derives it).
130
+ *
131
+ * ⚠️ ONE LINE AT A TIME, and the reason is the line number. Callers report a
132
+ * reference by source line, and markdown-it's inline tokens carry no line
133
+ * information — only the enclosing block does — so a paragraph-wide parse would
134
+ * point every reference in a paragraph at the paragraph's first line. Parsing
135
+ * each line's inline content keeps the number exact. The cost is a construct
136
+ * split across two source lines (a link whose `](` sits on the next line),
137
+ * which is not recovered — the regexes this replaces did not recover it either.
138
+ */
139
+ function markdownRefs(src) {
140
+ const lines = src.split("\n");
141
+ const fenced = fencedLineFlags(src);
142
+ const out = [];
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const line = lines[i] ?? "";
145
+ // Cheap reject: no link syntax and no backtick means no reference, and most
146
+ // lines of a real corpus are that.
147
+ if (fenced[i] || (!line.includes("`") && !line.includes("](")))
148
+ continue;
149
+ for (const tok of mdRefs.parseInline(line, {})) {
150
+ refsInInline(tok.children ?? [], i + 1, out);
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+ /** Which attribute carries the DESTINATION, per inline token type. */
156
+ const DESTINATION_ATTR = {
157
+ link_open: "href",
158
+ image: "src",
159
+ };
160
+ /**
161
+ * Walk ONE line's inline token stream, appending its references.
162
+ *
163
+ * `linkDepth` is the whole point: markdown-it emits `link_open` … `link_close`
164
+ * around the link's TEXT, so a `code_inline` seen while the depth is non-zero
165
+ * is display, and its destination has already been recorded.
166
+ */
167
+ function refsInInline(children, line, out) {
168
+ let linkDepth = 0;
169
+ for (const child of children) {
170
+ if (child.type === "link_close") {
171
+ linkDepth--;
172
+ continue;
173
+ }
174
+ if (child.type === "link_open")
175
+ linkDepth++;
176
+ // An image's alt text is a nested inline stream markdown-it keeps in
177
+ // `children`; it is display, exactly like link text, so only the `src` is
178
+ // taken and the alt is not descended into.
179
+ const destAttr = DESTINATION_ATTR[child.type];
180
+ const dest = destAttr === undefined ? null : child.attrGet(destAttr);
181
+ if (dest)
182
+ out.push({ kind: "link", value: dest, line });
183
+ else if (child.type === "code_inline" && linkDepth === 0)
184
+ out.push({ kind: "code", value: child.content, line });
185
+ }
186
+ }
88
187
  //# sourceMappingURL=markdown.js.map
@@ -37,6 +37,15 @@ exports.skillResourceIssues = skillResourceIssues;
37
37
  * `MD_HEADING`) and carries no illustrative cue (example / e.g. / such as /
38
38
  * would be / template / →). See `inlinePathIsUsed`.
39
39
  *
40
+ * CANDIDATES COME FROM THE PARSE, THE GATE READS THE PROSE. Every candidate is
41
+ * a `MarkdownRef` from `core/markdown.ts` — a link's DESTINATION or a code span
42
+ * that is not inside a link's text. The line is then consulted only to decide
43
+ * whether the surrounding prose DIRECTS the agent at the file. Before that
44
+ * split, a second regex scanned each line for backtick spans without knowing it
45
+ * was standing inside a link, and reported the link's LABEL as a missing
46
+ * resource while its destination resolved (measured on
47
+ * `microsoft/power-platform-skills` and `rohitg00/pro-workflow`, 2026-08-17).
48
+ *
40
49
  * ESCAPE HATCH: a SKILL.md carrying `<!-- vigiles-disable skill-resource-resolves -->`
41
50
  * anywhere in its body opts OUT of this check entirely (mirrors `orphans.ts`'s
42
51
  * `vigiles-disable orphan-docs`) — for a skill whose body is inherently full of
@@ -57,13 +66,6 @@ const markdown_js_1 = require("./markdown.js");
57
66
  // these is unambiguously a local bundled resource, even without a `./`.
58
67
  const BUNDLE_DIRS = ["scripts", "references", "assets"];
59
68
  const BUNDLE_PREFIX = new RegExp(`^(?:${BUNDLE_DIRS.join("|")})/`);
60
- // A markdown inline link `[text](target)` — we read its target.
61
- const MD_LINK = /\[[^\]]*\]\(([^)\s]+)\)/g;
62
- // An inline-code path mention: a backtick span whose whole content is a single
63
- // path token. We only treat it as a ref when it is a bundle-dir-prefixed path
64
- // with an extension (the high-confidence shape); a bare `scripts` or a generic
65
- // `foo.ts` mention is NOT flagged.
66
- const INLINE_SPAN = /`([^`\n]+)`/g;
67
69
  // A path must carry a file extension to be a resource reference. A bare word or
68
70
  // a directory name (`scripts/lib`) is undecidable prose — skipped.
69
71
  const HAS_EXT = /\.[A-Za-z0-9]+$/;
@@ -224,39 +226,40 @@ function inlinePathIsUsed(line) {
224
226
  return false;
225
227
  return USE_DIRECTIVE.test(line) || MD_HEADING.test(line);
226
228
  }
227
- /** Collect candidate bundled-resource refs from one body line, skipping fences. */
228
- function candidatesInLine(line, lineNo) {
229
- const out = [];
230
- // A markdown link is EXPLICIT follow-me syntax (`[text](target)`) — the link IS
231
- // the reference so it's real UNLESS the line is an illustrative example.
232
- // Suppress it ONLY on an illustrative cue; do NOT also require a use directive,
233
- // or a plain `Resources: [API](references/api.md)` (no verb) goes unchecked
234
- // (Codex review — that under-detection). A BARE inline backtick path is noisier
235
- // (often just a mention), so it still needs BOTH a use directive AND no cue.
236
- const illustrative = ILLUSTRATIVE_CUE.test(line);
237
- // Markdown links: a relative path target that passes the local-resource gate.
238
- if (!illustrative) {
239
- for (const m of line.matchAll(MD_LINK)) {
240
- const resolved = localResourceTarget(m[1]);
241
- if (resolved !== null) {
242
- out.push({ ref: m[1].trim(), resolved, kind: "link", line: lineNo });
243
- }
244
- }
245
- }
246
- // Inline-code path mentions: only the high-confidence bundle-dir-prefixed form,
247
- // and only when the prose directs the agent to use it (the noisier shape).
248
- if (inlinePathIsUsed(line)) {
249
- for (const m of line.matchAll(INLINE_SPAN)) {
250
- const token = m[1].trim();
251
- if (!isInlineBundlePath(token))
252
- continue;
253
- const resolved = localResourceTarget(token);
254
- if (resolved !== null) {
255
- out.push({ ref: token, resolved, kind: "path", line: lineNo });
256
- }
257
- }
229
+ /**
230
+ * Turn one structural markdown reference into a candidate, or `null`.
231
+ *
232
+ * `line` is the raw source line, used ONLY by the prose gate — the candidate
233
+ * itself comes from {@link markdownRefs}, never from the characters. That split
234
+ * is load-bearing: the gate reads prose because prose is what it judges, while
235
+ * a REFERENCE is a thing the parser found.
236
+ */
237
+ function candidateFor(ref, line) {
238
+ if (ref.kind === "link") {
239
+ // A markdown link is EXPLICIT follow-me syntax the DESTINATION is the
240
+ // reference — so it's real UNLESS the line is an illustrative example.
241
+ // Suppress it ONLY on an illustrative cue; do NOT also require a use
242
+ // directive, or a plain `Resources: [API](references/api.md)` (no verb)
243
+ // goes unchecked (Codex review — that under-detection).
244
+ if (ILLUSTRATIVE_CUE.test(line))
245
+ return null;
246
+ const resolved = localResourceTarget(ref.value);
247
+ if (resolved === null)
248
+ return null;
249
+ return { ref: ref.value.trim(), resolved, kind: "link", line: ref.line };
258
250
  }
259
- return out;
251
+ // A bare inline backtick path is noisier (often just a mention), so it needs
252
+ // BOTH a use directive AND no cue, and only in the high-confidence
253
+ // bundle-dir-prefixed form.
254
+ if (!inlinePathIsUsed(line))
255
+ return null;
256
+ const token = ref.value.trim();
257
+ if (!isInlineBundlePath(token))
258
+ return null;
259
+ const resolved = localResourceTarget(token);
260
+ if (resolved === null)
261
+ return null;
262
+ return { ref: token, resolved, kind: "path", line: ref.line };
260
263
  }
261
264
  /**
262
265
  * A SKILL.md body carrying this marker opts OUT of skill-resource checking
@@ -293,25 +296,23 @@ function skillResourceIssues(skillBody, skillDir, opts) {
293
296
  const findings = [];
294
297
  const seen = new Set();
295
298
  const lines = skillBody.split("\n");
296
- const fenced = (0, markdown_js_1.fencedLineFlags)(skillBody);
297
- for (let i = 0; i < lines.length; i++) {
298
- if (fenced[i])
299
+ for (const ref of (0, markdown_js_1.markdownRefs)(skillBody)) {
300
+ const c = candidateFor(ref, lines[ref.line - 1] ?? "");
301
+ if (c === null)
302
+ continue;
303
+ if (resolvesAnywhere(c.resolved))
304
+ continue;
305
+ // De-dupe the same missing file referenced several times in the body.
306
+ const key = `${c.kind}:${c.resolved}`;
307
+ if (seen.has(key))
299
308
  continue;
300
- for (const c of candidatesInLine(lines[i], i + 1)) {
301
- if (resolvesAnywhere(c.resolved))
302
- continue;
303
- // De-dupe the same missing file referenced several times in the body.
304
- const key = `${c.kind}:${c.resolved}`;
305
- if (seen.has(key))
306
- continue;
307
- seen.add(key);
308
- findings.push({
309
- ref: c.ref,
310
- resolved: c.resolved,
311
- kind: c.kind,
312
- line: c.line,
313
- });
314
- }
309
+ seen.add(key);
310
+ findings.push({
311
+ ref: c.ref,
312
+ resolved: c.resolved,
313
+ kind: c.kind,
314
+ line: c.line,
315
+ });
315
316
  }
316
317
  return findings;
317
318
  }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * vigiles — recovering a FILE REFERENCE from PLAIN SOURCE TEXT.
3
+ *
4
+ * The third of three reference grammars, and the only one with no parser behind
5
+ * it. A markdown body is read by markdown-it (`core/markdown.ts` →
6
+ * `markdownRefs`), a hook `command` is read by mvdan-sh (`core/bash-effects.ts`
7
+ * → `commandWords`), and what is left — the body of a hook script, a helper
8
+ * `.js` / `.py` / `.rb` — is scanned for path-shaped character runs, because a
9
+ * general-purpose "find every path this program touches" analysis is not a
10
+ * thing this tool can be.
11
+ *
12
+ * Since that scan IS a character run, the only thing standing between it and a
13
+ * false accusation is where the run may START and STOP. This module owns both
14
+ * boundaries and the extension vocabularies, so no caller can build a pattern
15
+ * without them.
16
+ *
17
+ * ## The two boundaries, and the two live defects that came from omitting them
18
+ *
19
+ * RIGHT — the extension must END the token. `INTRA_REF_EXTS` used to be a bare
20
+ * alternation with no trailing assertion, and `js` sits ahead of `json` in it,
21
+ * so `hooks/hooks.json` matched as `hooks/hooks.js`. Measured 2026-08-17 on
22
+ * `microsoft/power-platform-skills`: a `//` comment naming `hooks/hooks.json`,
23
+ * with that exact file sitting beside it on disk, was reported as
24
+ * "hooks/hooks.js (referenced but MISSING)" — a maintainer told a file they
25
+ * have is missing, under a name they never wrote.
26
+ *
27
+ * LEFT — the surface dir must START the token. `(?:agents|hooks|skills)/` with
28
+ * nothing before it also matches the tail of `claude-agents/`. Measured the
29
+ * same day on `fcakyon/claude-codex-settings`: a
30
+ * `new URL("../../../claude-agents/fable-advisor.md", …)` — a correct,
31
+ * resolving reference — was reported as a broken `agents/fable-advisor.md`.
32
+ * That boundary is a predicate ({@link startsAtSeparator}) rather than a regex
33
+ * lookbehind, because the browser twin (`scan-files.ts`) compiles this into the
34
+ * demo engine, and because the caller already inspects `m.index` for its
35
+ * plugin-rooted test.
36
+ *
37
+ * ## Comments are prose in every language, not only in shell
38
+ *
39
+ * `stripShellComments` had the right idea and too narrow a domain: a full-line
40
+ * `#` in a `.sh` file is prose, and so is a full-line `//` or a JSDoc `*` in a
41
+ * `.js` file. Both remaining corpus false accusations in this detector came out
42
+ * of JSDoc — `* It is deliberately not registered in hooks/hooks.json:` and
43
+ * "`git log --grep=.env -- hooks/x.mjs` is refused …". Neither is a file
44
+ * operation; both were reported as broken references.
45
+ *
46
+ * FULL-LINE ONLY, the same rule the shell version already held. A trailing
47
+ * comment on a real code line is left alone, so a genuine reference sharing a
48
+ * line with code is never dropped. The cost is stated rather than hidden: a
49
+ * path mentioned ONLY in a trailing comment still counts as a reference.
50
+ */
51
+ /**
52
+ * Whether a match at `idx` begins at a path boundary rather than inside a
53
+ * longer name. Position 0 counts as a boundary.
54
+ *
55
+ * `/` is a boundary on purpose: `${CLAUDE_PLUGIN_ROOT}/hooks/x.sh` is the
56
+ * standard spelling, and the caller's plugin-rooted test decides whether the
57
+ * segment before that slash roots the path inside the plugin or outside it.
58
+ */
59
+ export declare function startsAtSeparator(content: string, idx: number): boolean;
60
+ /**
61
+ * Extensions an intra-plugin reference may carry — the file kinds a plugin's
62
+ * own hook / helper source legitimately points at.
63
+ */
64
+ export declare const INTRA_REF_EXTENSIONS: readonly ["md", "sh", "cmd", "mjs", "cjs", "js", "ts", "py", "rb", "txt", "json"];
65
+ /**
66
+ * Extensions a RUNNABLE script carries. Shared by the hook scanner and the
67
+ * coverage twins, which each declared their own copy before.
68
+ */
69
+ export declare const SCRIPT_REF_EXTENSIONS: readonly ["sh", "mjs", "cjs", "js", "ts", "py", "rb"];
70
+ /**
71
+ * A plugin-relative path under one of `dirs`, carrying a known extension — the
72
+ * pattern for scanning a plugin's own SOURCE TEXT.
73
+ *
74
+ * The left boundary is not baked into the returned regex (see
75
+ * {@link startsAtSeparator}): a caller scanning raw text must apply that
76
+ * predicate at `m.index`, which both the disk detector and its browser twin do
77
+ * inside their plugin-rooted test.
78
+ */
79
+ export declare function intraRefPattern(dirs: readonly string[]): RegExp;
80
+ /**
81
+ * A script path occupying a WHOLE shell WORD.
82
+ *
83
+ * ⚠️ ANCHORING IS NOT WHAT FIXES THE `node -e` DEFECT, and saying so would be
84
+ * wrong twice over — the payload
85
+ * `import(require(node:url).pathToFileURL(require(node:path).join(root,hooks,always-on.mjs`
86
+ * contains no whitespace and ends in `.mjs`, so it satisfies this pattern
87
+ * perfectly (asserted in source-refs.test.ts, so the claim cannot drift back).
88
+ * That defect is fixed one level up, by `commandWords` refusing to hand the
89
+ * argument of `-e` to anyone.
90
+ *
91
+ * What anchoring buys is narrower and worth stating exactly: the reported name
92
+ * is always a word a shell could hand to `execve`, never a fragment cut out of
93
+ * a longer one. `echo "see hooks/x.sh"` is one word containing a path; before,
94
+ * `hooks/x.sh` was lifted out of it and checked as though the hook ran it.
95
+ *
96
+ * The cost, stated rather than discovered later: a path bundled into a flag
97
+ * (`--require=hooks/x.js`) is no longer seen. Measured across the 32-repo
98
+ * dogfood corpus, that costs zero findings.
99
+ */
100
+ export declare function scriptWordPattern(): RegExp;
101
+ /**
102
+ * A script path appearing anywhere inside a string — for the one caller with no
103
+ * shell parse to hand (the coverage twins scan a serialized settings blob).
104
+ * Carries the right boundary; it cannot carry the left one, and its callers
105
+ * gate every hit on the file existing, so a stray match is dropped rather than
106
+ * reported.
107
+ */
108
+ export declare function scriptRefPattern(): RegExp;
109
+ /**
110
+ * Drop FULL-LINE comments (including a shebang, which also starts with `#`)
111
+ * from an executable source before it is scanned for path references.
112
+ *
113
+ * A file of unknown kind is returned unchanged: guessing a comment syntax is
114
+ * how a real reference gets deleted, and this detector's contract is that it
115
+ * under-reports rather than accuses.
116
+ */
117
+ export declare function stripFullLineComments(path: string, content: string): string;
118
+ //# sourceMappingURL=source-refs.d.ts.map