vigiles 16.1.1 → 16.1.3

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.
@@ -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
@@ -827,7 +827,12 @@ function compileSkill(spec, options = {}) {
827
827
  * detection lives in the shared `verifyToolContract` detector (one-detector-no-
828
828
  * drift: compile + scan + the subagent-tool-contract lint rule call the same code). */
829
829
  function validateAgentTools(tools, dialect) {
830
- return (0, tool_contract_js_1.verifyToolContract)(tools, dialect).map((issue) => ({
830
+ // `authoringIssues` drops the `conditional` verdicts: `Agent`, `ExitPlanMode`
831
+ // and the foreground-only built-ins are REAL tools, legitimate to declare, and
832
+ // erroring on them is what made `tools: Agent, Read, Bash` — a worked example
833
+ // in the vendor's own docs — fail to compile. Everything else stays an error,
834
+ // because authoring your own spec is a closed world.
835
+ return (0, tool_contract_js_1.authoringIssues)((0, tool_contract_js_1.verifyToolContract)(tools, dialect)).map((issue) => ({
831
836
  type: "unknown-tool",
832
837
  message: issue.message,
833
838
  }));
@@ -22,6 +22,7 @@
22
22
  * Which SKILL.md frontmatter keys a harness understands — see
23
23
  * `HarnessDialect.skillFrontmatter`.
24
24
  */
25
+ import type { HarnessVocabulary } from "./vocabulary.js";
25
26
  export type SkillFrontmatterProfile = "claude-code" | "minimal";
26
27
  export interface HarnessDialect {
27
28
  /** Stable identifier, e.g. "claude-code". */
@@ -83,5 +84,31 @@ export interface HarnessDialect {
83
84
  * Optional (additive, non-breaking) — absent ⇒ no tool is known-side-effecting.
84
85
  */
85
86
  readonly sideEffectingTools?: readonly string[];
87
+ /**
88
+ * The hook-event catalog as a {@link HarnessVocabulary} — a status and a
89
+ * recorded vendor capture per term, rather than bare membership in
90
+ * `hookEvents`. When present it is what `verifyHookEvents` classifies against,
91
+ * so a name the catalog doesn't hold produces an `unrecognised` ADVISORY
92
+ * (naming vigiles's capture as the possibly-stale party) instead of the old
93
+ * behaviour, where an unknown name drew an accusation or silence depending on
94
+ * its edit distance to the list.
95
+ *
96
+ * Optional (additive, non-breaking). Absent ⇒ one is synthesised from
97
+ * `hookEvents` via `vocabularyFromLists`, so a legacy adapter keeps working
98
+ * and its unknowns become advisories rather than silence.
99
+ */
100
+ readonly hookEventVocabulary?: HarnessVocabulary;
101
+ /**
102
+ * The subagent-tool catalog as a {@link HarnessVocabulary}. Same contract as
103
+ * `hookEventVocabulary`; absent ⇒ synthesised from `builtinAgentTools`
104
+ * (available) + `neverAvailableTools` (withheld).
105
+ *
106
+ * The third status is why this exists: the vendor removes `Agent` only at the
107
+ * spawn depth limit and `ExitPlanMode` only outside plan mode, and removes
108
+ * most built-ins from a background subagent but not a foreground one — so
109
+ * "available to a subagent" is not a property of the name, and a two-way
110
+ * split had to encode one of those conditions as an unconditional fact.
111
+ */
112
+ readonly subagentToolVocabulary?: HarnessVocabulary;
86
113
  }
87
114
  //# sourceMappingURL=dialect.d.ts.map
@@ -4,31 +4,48 @@
4
4
  * (`PreToolUse`, `SessionStart`, …); a TYPO (`PreToolUSe`) means the hook
5
5
  * silently never fires — a dead registration no generic JSON linter catches.
6
6
  *
7
- * Like the tool catalog, the event set is NOT closed in practice: frameworks
8
- * extend it (TheBushidoCollective/han ships a custom runtime with `TeammateIdle`,
9
- * `WorktreeRemove`, … in its own `hooks.json`). So the audit path (scan/lint) is
10
- * HIGH-PRECISION — `confidentHookEventIssues` keeps only a close typo
11
- * (a did-you-mean within edit distance 2), never a bare unrecognized event that
12
- * may be a custom/future one. ONE detector (one-detector-no-drift): scan + the
13
- * `hook-events` lint rule call the same code. Dialect injected (core ⊄ adapter).
7
+ * The event set is NOT closed in practice: the vendor keeps adding events, and
8
+ * frameworks ship custom runtimes with their own (TheBushidoCollective/han fires
9
+ * `TeammateIdle`, `WorktreeRemove`, … from its own `hooks.json` — both of which
10
+ * have since become real Claude Code events). This check used to handle that by
11
+ * reporting an unknown event ONLY when it sat within edit distance 2 of a known
12
+ * one. That is not a confidence signal, and it failed both ways at once:
13
+ * `Setup`, a documented event, was accused of never firing and told to become
14
+ * `Stop`; twenty-one other documented events drew nothing, because they happened
15
+ * to be further than two characters from anything in a nine-name list.
16
+ *
17
+ * Now every name is CLASSIFIED against the dialect's vocabulary
18
+ * (`core/vocabulary.ts`) and every verdict is reported — with the severity
19
+ * coming from the verdict rather than from the caller. An event vigiles doesn't
20
+ * hold is an `advisory` that names vigiles's own capture as the thing that may
21
+ * be stale; it is surfaced and never scored, so a newer or custom event cannot
22
+ * cost anyone a grade. ONE detector (one-detector-no-drift): scan + the
23
+ * `hook-events` lint rule + compiled-hook `on:` validation call the same code.
24
+ * Dialect injected (core ⊄ adapter).
14
25
  */
15
26
  import type { HarnessDialect } from "./dialect.js";
27
+ import { type HarnessVocabulary, type IssueSeverity, type TermVerdict } from "./vocabulary.js";
16
28
  export interface HookEventIssue {
17
29
  readonly event: string;
18
- /** Closest known event (did-you-mean), or null. */
30
+ /** Which vocabulary verdict produced this — the input to every policy. */
31
+ readonly verdict: TermVerdict["kind"];
32
+ /** Closest known event (did-you-mean), or null. Message decoration only. */
19
33
  readonly suggestion: string | null;
34
+ /** `"scored"` counts toward the grade; `"advisory"` never does. */
35
+ readonly severity: IssueSeverity;
20
36
  readonly message: string;
21
37
  }
22
38
  /**
23
- * The HIGH-CONFIDENCE subset (what scan / lint act on): only an unrecognized
24
- * event that's a close typo of a real one. A bare unknown (no near match) is
25
- * likely a framework/custom event, not a defect — never flagged when auditing.
39
+ * The event vocabulary this dialect verifies against — its declared one, else a
40
+ * synthesised one built from the flat `hookEvents` list so an adapter that
41
+ * predates vocabularies keeps working.
26
42
  */
27
- export declare function confidentHookEventIssues(issues: readonly HookEventIssue[]): HookEventIssue[];
43
+ export declare function hookEventVocabulary(dialect: HarnessDialect): HarnessVocabulary;
28
44
  /**
29
- * Verify hook-event names against the dialect catalog. Returns one issue per
30
- * unrecognized event. Like the tool-contract check, a suggestion (edit distance
31
- * ≤ 2) is the confidence signal that an unknown is really a typo of a real event.
45
+ * Verify hook-event names against the dialect vocabulary. Returns one issue per
46
+ * name that isn't plainly available, each already carrying its severity — see
47
+ * {@link scoredIssues} / {@link advisoryIssues} to split them.
32
48
  */
33
49
  export declare function verifyHookEvents(events: readonly string[], dialect: HarnessDialect): HookEventIssue[];
50
+ export { scoredIssues, advisoryIssues, authoringIssues } from "./vocabulary.js";
34
51
  //# sourceMappingURL=hook-events.d.ts.map
@@ -1,48 +1,42 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.confidentHookEventIssues = confidentHookEventIssues;
3
+ exports.authoringIssues = exports.advisoryIssues = exports.scoredIssues = void 0;
4
+ exports.hookEventVocabulary = hookEventVocabulary;
4
5
  exports.verifyHookEvents = verifyHookEvents;
5
- const edit_distance_js_1 = require("./edit-distance.js");
6
- /** Closest known hook event by edit distance (≤ 2) — a confidence signal. */
7
- function closestEvent(event, dialect) {
8
- let best = null;
9
- let bestDistance = Infinity;
10
- for (const known of dialect.hookEvents) {
11
- const d = (0, edit_distance_js_1.editDistance)(event.toLowerCase(), known.toLowerCase());
12
- if (d < bestDistance) {
13
- bestDistance = d;
14
- best = known;
15
- }
16
- }
17
- return bestDistance <= 2 ? best : null;
18
- }
6
+ const vocabulary_js_1 = require("./vocabulary.js");
19
7
  /**
20
- * The HIGH-CONFIDENCE subset (what scan / lint act on): only an unrecognized
21
- * event that's a close typo of a real one. A bare unknown (no near match) is
22
- * likely a framework/custom event, not a defect — never flagged when auditing.
8
+ * The event vocabulary this dialect verifies against — its declared one, else a
9
+ * synthesised one built from the flat `hookEvents` list so an adapter that
10
+ * predates vocabularies keeps working.
23
11
  */
24
- function confidentHookEventIssues(issues) {
25
- return issues.filter((i) => i.suggestion !== null);
12
+ function hookEventVocabulary(dialect) {
13
+ return (dialect.hookEventVocabulary ??
14
+ (0, vocabulary_js_1.vocabularyFromLists)(`${dialect.name} hook event`, `${dialect.name} adapter (no recorded capture)`, dialect.hookEvents));
26
15
  }
27
16
  /**
28
- * Verify hook-event names against the dialect catalog. Returns one issue per
29
- * unrecognized event. Like the tool-contract check, a suggestion (edit distance
30
- * ≤ 2) is the confidence signal that an unknown is really a typo of a real event.
17
+ * Verify hook-event names against the dialect vocabulary. Returns one issue per
18
+ * name that isn't plainly available, each already carrying its severity — see
19
+ * {@link scoredIssues} / {@link advisoryIssues} to split them.
31
20
  */
32
21
  function verifyHookEvents(events, dialect) {
33
- const known = new Set(dialect.hookEvents);
22
+ const vocab = hookEventVocabulary(dialect);
34
23
  const issues = [];
35
24
  for (const event of events) {
36
- if (known.has(event))
25
+ const issue = (0, vocabulary_js_1.termIssue)(vocab, (0, vocabulary_js_1.classify)(vocab, event), "Hook event", "a hook here never fires");
26
+ if (issue === null)
37
27
  continue;
38
- const near = closestEvent(event, dialect);
39
- const hint = near ? ` Did you mean "${near}"?` : "";
40
28
  issues.push({
41
29
  event,
42
- suggestion: near,
43
- message: `Unknown hook event "${event}" — a hook here never fires. Valid events: ${dialect.hookEvents.join(", ")}.${hint}`,
30
+ verdict: issue.verdict,
31
+ suggestion: issue.suggestion,
32
+ severity: issue.severity,
33
+ message: issue.message,
44
34
  });
45
35
  }
46
36
  return issues;
47
37
  }
38
+ var vocabulary_js_2 = require("./vocabulary.js");
39
+ Object.defineProperty(exports, "scoredIssues", { enumerable: true, get: function () { return vocabulary_js_2.scoredIssues; } });
40
+ Object.defineProperty(exports, "advisoryIssues", { enumerable: true, get: function () { return vocabulary_js_2.advisoryIssues; } });
41
+ Object.defineProperty(exports, "authoringIssues", { enumerable: true, get: function () { return vocabulary_js_2.authoringIssues; } });
48
42
  //# sourceMappingURL=hook-events.js.map
@@ -679,11 +679,19 @@ function compileHookProgram(source, hook, opts = {}) {
679
679
  `regex (that is why "Edit|Write" works), so it must parse as one.`);
680
680
  }
681
681
  }
682
- // A hook registered under an event the harness never fires is dead — reject it.
682
+ // A hook registered under an event the harness never fires is dead — reject
683
+ // it. AUTHORING is a closed world (you are writing this hook now, against the
684
+ // vigiles you have), so an unrecognised event is still an error — the typo
685
+ // guarantee this exists for. What changed on 2026-08-17 is the catalog it
686
+ // asks: this used to throw on `Setup`, `PostCompact`, `ConfigChange` and 19
687
+ // other REAL events, because vigiles held 9 of the vendor's 31. The fix is the
688
+ // right vocabulary, not a weaker check. A genuinely newer event still fails
689
+ // here, and now says so — the message names vigiles's capture as the thing
690
+ // that may be stale, instead of asserting the event does not exist.
683
691
  if (opts.dialect) {
684
- const issues = (0, hook_events_js_1.verifyHookEvents)([on], opts.dialect);
685
- if (issues.length > 0) {
686
- throw new HookCompileError(issues[0].message);
692
+ const fatal = (0, hook_events_js_1.authoringIssues)((0, hook_events_js_1.verifyHookEvents)([on], opts.dialect));
693
+ if (fatal.length > 0) {
694
+ throw new HookCompileError(fatal[0].message);
687
695
  }
688
696
  }
689
697
  // A `needs` entry that isn't a built-in provider never resolves — reject it
@@ -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
@@ -85,7 +85,7 @@ exports.RULE_META = {
85
85
  surface: ["subagent"],
86
86
  defaultSeverity: "warn",
87
87
  summary: "A subagent's tools: are all real (no never-available / typo).",
88
- detector: "confidentToolIssues",
88
+ detector: "verifyToolContract / scoredIssues",
89
89
  upstreamPrevention: "typed agent() vocabulary + compileAgent — an unknown tool is a tsc/compile error",
90
90
  },
91
91
  "disallowed-tools-contract": {
@@ -113,7 +113,7 @@ exports.RULE_META = {
113
113
  surface: ["hook"],
114
114
  defaultSeverity: "warn",
115
115
  summary: "A hook's event name is one the harness defines (it can fire).",
116
- detector: "confidentHookEventIssues",
116
+ detector: "verifyHookEvents / scoredIssues",
117
117
  upstreamPrevention: "compiled hook on: is dialect-validated at compile",
118
118
  },
119
119
  "hook-script-exists": {