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.
- package/dist/core/bash-effects.d.ts +57 -0
- package/dist/core/bash-effects.js +147 -0
- package/dist/core/markdown.d.ts +53 -0
- package/dist/core/markdown.js +99 -0
- package/dist/core/skill-resources.js +58 -57
- package/dist/core/source-refs.d.ts +118 -0
- package/dist/core/source-refs.js +206 -0
- package/dist/coverage-artifact.d.ts +56 -2
- package/dist/coverage-artifact.js +126 -8
- package/dist/coverage-evidence.d.ts +33 -0
- package/dist/coverage-evidence.js +62 -3
- package/dist/coverage-probe.d.ts +14 -6
- package/dist/coverage-probe.js +14 -6
- package/dist/plugin-loader.js +22 -28
- package/dist/scan-core.js +41 -14
- package/dist/scan-files.js +13 -19
- package/dist/test-coverage-files.js +10 -4
- package/dist/test-coverage.js +1 -6
- package/package.json +1 -1
|
@@ -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
|
package/dist/core/markdown.d.ts
CHANGED
|
@@ -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
|
package/dist/core/markdown.js
CHANGED
|
@@ -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
|
-
/**
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
|
297
|
-
|
|
298
|
-
if (
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|