@iceinvein/agent-skills 0.1.31 → 0.1.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "Install agent skills into AI coding tools",
5
5
  "author": "iceinvein",
6
6
  "license": "MIT",
package/skills/index.json CHANGED
@@ -197,7 +197,7 @@
197
197
  "name": "magpie",
198
198
  "description": "Interactive PR review pipeline. Runs five parallel specialist subagents (security, bugs, performance, code-smells, architecture), dedupes findings, applies a critic rubric, peer-reviews via codex exec, and serves an interactive HTML report for selecting findings to post via gh. Bundles a Bun CLI installed onto PATH via the skill's postinstall step. Use when the user asks to review a GitHub pull request.",
199
199
  "type": "prompt",
200
- "version": "0.4.1"
200
+ "version": "0.5.0"
201
201
  },
202
202
  {
203
203
  "name": "module-secret-auditor",
@@ -83,13 +83,29 @@ Write findings to $RUN_DIR/findings/<focus>.json before returning. The file MUST
83
83
  "title": string, // one line
84
84
  "description": string, // 2-4 short labelled paragraphs (see below). Cite code with file:line.
85
85
  "suggestion": { // OPTIONAL; omit the key entirely if not applicable. NOT "recommendation"
86
- "body": string,
86
+ "body": string, // LITERAL replacement source code for lines startLine..endLine. NOT prose. See rules below.
87
87
  "startLine": number,
88
88
  "endLine": number
89
89
  },
90
90
  "domain": "<focus>" // literal focus id, copied verbatim
91
91
  }
92
92
 
93
+ **Enum values are exact strings, not free-form prose.** Every value above between `"..."` and `|` markers is a literal token. Copy them verbatim. Specifically:
94
+
95
+ - `severity`, `risk.impact`, `risk.confidence` use category names (e.g. `high`, `low`), not sentences.
96
+ - `risk.likelihood` describes frequency, not impact. Valid values are exactly `likely`, `possible`, `edge-case`, `unknown`. NEVER use `high`/`medium`/`low` here (those are likelihood-as-impact and will be auto-corrected, but pick the right axis).
97
+ - `risk.action` is the disposition tag, not the recommendation text. Valid values are exactly `must-fix`, `should-fix`, `consider`, `optional`. The recommendation prose belongs in `description` under `Suggested direction:`, never in `risk.action`.
98
+
99
+ Bad (will be silently coerced, do not rely on this):
100
+ ```
101
+ "risk": { "impact": "blocker", "likelihood": "high", "confidence": "very high", "action": "Fix this immediately before merging." }
102
+ ```
103
+
104
+ Good:
105
+ ```
106
+ "risk": { "impact": "critical", "likelihood": "likely", "confidence": "high", "action": "must-fix" }
107
+ ```
108
+
93
109
  `description` MUST be a sequence of short labelled paragraphs separated by blank lines, using these exact prefixes when they apply:
94
110
 
95
111
  - `Observation: <one idea, what the diff actually does and where>`
@@ -99,6 +115,13 @@ Write findings to $RUN_DIR/findings/<focus>.json before returning. The file MUST
99
115
 
100
116
  One idea per paragraph. Do not collapse them into a single wall of text. Do not invent extra labels. If a section doesn't apply, omit it. The interactive report and the GitHub comment both parse these labels and render them as section headers, so missing labels degrade the output.
101
117
 
118
+ **`suggestion.body` rules.** When present, `body` MUST be the literal source code that should replace lines `startLine..endLine` verbatim. It is fenced as `` ```suggestion `` on GitHub and rendered as a one-click "Apply" button; the bytes you write here get committed as-is to the PR. Therefore:
119
+
120
+ - Write code only. No leading "Strip the delimiter...", "Add a check that...", or other prose. The prose explanation belongs in `description` under `Suggested direction:`.
121
+ - Match the file's existing indentation and language exactly. Include only the lines being replaced; do not include unchanged surrounding context.
122
+ - If you cannot produce an exact, copy-pasteable replacement (you don't know the surrounding code, the fix spans multiple files, or the change is conceptual), OMIT the `suggestion` key entirely. A prose `Suggested direction:` in `description` is the right channel for that.
123
+ - Wrapping the code in a `` ``` `` fence inside `body` is tolerated (the poster hoists the inner code out), but bare code is preferred.
124
+
102
125
  If you have no findings, write []. Return as your final tool result a single line: `<focus>: <N> findings (<blocker>/<high>/<medium>/<low>)`. Do not include other prose.
103
126
  ```
104
127
 
@@ -4,18 +4,109 @@
4
4
  "workspaces": {
5
5
  "": {
6
6
  "name": "magpie",
7
+ "dependencies": {
8
+ "shiki": "^1.24.0",
9
+ },
7
10
  "devDependencies": {
8
11
  "@types/bun": "^1.2.0",
9
12
  },
10
13
  },
11
14
  },
12
15
  "packages": {
16
+ "@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="],
17
+
18
+ "@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="],
19
+
20
+ "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA=="],
21
+
22
+ "@shikijs/langs": ["@shikijs/langs@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ=="],
23
+
24
+ "@shikijs/themes": ["@shikijs/themes@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g=="],
25
+
26
+ "@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
27
+
28
+ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
29
+
13
30
  "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
14
31
 
32
+ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
33
+
34
+ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
35
+
15
36
  "@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="],
16
37
 
38
+ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
39
+
40
+ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
41
+
17
42
  "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
18
43
 
44
+ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
45
+
46
+ "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
47
+
48
+ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
49
+
50
+ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
51
+
52
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
53
+
54
+ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
55
+
56
+ "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
57
+
58
+ "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
59
+
60
+ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
61
+
62
+ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
63
+
64
+ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
65
+
66
+ "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
67
+
68
+ "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
69
+
70
+ "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
71
+
72
+ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
73
+
74
+ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
75
+
76
+ "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
77
+
78
+ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
79
+
80
+ "regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
81
+
82
+ "regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
83
+
84
+ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
85
+
86
+ "shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
87
+
88
+ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
89
+
90
+ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
91
+
92
+ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
93
+
19
94
  "undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="],
95
+
96
+ "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
97
+
98
+ "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
99
+
100
+ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
101
+
102
+ "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
103
+
104
+ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
105
+
106
+ "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
107
+
108
+ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
109
+
110
+ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
20
111
  }
21
112
  }
@@ -9,6 +9,9 @@
9
9
  "lint:fix": "biome check --write .",
10
10
  "typecheck": "test -z \"$(find bin scripts -name '*.ts' -print -quit 2>/dev/null)\" || tsc --noEmit"
11
11
  },
12
+ "dependencies": {
13
+ "shiki": "^1.24.0"
14
+ },
12
15
  "devDependencies": {
13
16
  "@types/bun": "^1.2.0"
14
17
  }
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ getHighlighter,
4
+ highlightCodeBlock,
5
+ highlightDiffSide,
6
+ languageFromPath,
7
+ } from '../highlight.ts'
8
+
9
+ describe('languageFromPath', () => {
10
+ test('maps known extensions to languages', () => {
11
+ expect(languageFromPath('src/foo.ts')).toBe('typescript')
12
+ expect(languageFromPath('src/foo.tsx')).toBe('tsx')
13
+ expect(languageFromPath('a/b/c.py')).toBe('python')
14
+ expect(languageFromPath('main.go')).toBe('go')
15
+ expect(languageFromPath('lib.rs')).toBe('rust')
16
+ expect(languageFromPath('App.java')).toBe('java')
17
+ expect(languageFromPath('app.rb')).toBe('ruby')
18
+ expect(languageFromPath('run.sh')).toBe('bash')
19
+ expect(languageFromPath('data.json')).toBe('json')
20
+ expect(languageFromPath('ci.yml')).toBe('yaml')
21
+ expect(languageFromPath('readme.md')).toBe('markdown')
22
+ expect(languageFromPath('page.html')).toBe('html')
23
+ expect(languageFromPath('styles.css')).toBe('css')
24
+ expect(languageFromPath('schema.sql')).toBe('sql')
25
+ })
26
+
27
+ test('falls back to plaintext for unknown/empty', () => {
28
+ expect(languageFromPath('LICENSE')).toBe('plaintext')
29
+ expect(languageFromPath('file.xyz')).toBe('plaintext')
30
+ expect(languageFromPath('')).toBe('plaintext')
31
+ })
32
+
33
+ test('is case-insensitive on extension', () => {
34
+ expect(languageFromPath('FOO.TS')).toBe('typescript')
35
+ })
36
+ })
37
+
38
+ describe('highlightCodeBlock', () => {
39
+ test('wraps output in shiki pre/code', async () => {
40
+ const hl = await getHighlighter()
41
+ const html = highlightCodeBlock(hl, 'const x = 1', 'typescript')
42
+ expect(html).toContain('<pre')
43
+ expect(html).toContain('class="shiki')
44
+ expect(html).toContain('<code>')
45
+ expect(html).toContain('<span class="line">')
46
+ })
47
+
48
+ test('falls back to plaintext for unregistered langs without throwing', async () => {
49
+ const hl = await getHighlighter()
50
+ const html = highlightCodeBlock(hl, 'hello world', 'klingon')
51
+ expect(html).toContain('<pre')
52
+ expect(html).toContain('hello world')
53
+ })
54
+
55
+ test('emits dual-theme CSS variables for prefers-color-scheme', async () => {
56
+ const hl = await getHighlighter()
57
+ const html = highlightCodeBlock(hl, 'const x = 1', 'typescript')
58
+ expect(html).toMatch(/--shiki-dark/)
59
+ })
60
+ })
61
+
62
+ describe('highlightDiffSide', () => {
63
+ test('returns one entry per source line', async () => {
64
+ const hl = await getHighlighter()
65
+ const code = 'const a = 1\nconst b = 2\nconst c = 3'
66
+ const lines = highlightDiffSide(hl, code, 'typescript')
67
+ expect(lines.length).toBe(3)
68
+ })
69
+
70
+ test('handles multi-line template literal without losing lines', async () => {
71
+ const hl = await getHighlighter()
72
+ const code = 'const x = `line1\nline2\nline3`\nconst y = 2'
73
+ const lines = highlightDiffSide(hl, code, 'typescript')
74
+ expect(lines.length).toBe(4)
75
+ expect(lines[0]).toContain('line1')
76
+ expect(lines[1]).toContain('line2')
77
+ expect(lines[2]).toContain('line3')
78
+ expect(lines[3]).toContain('y')
79
+ })
80
+
81
+ test('returns empty array for empty input', async () => {
82
+ const hl = await getHighlighter()
83
+ expect(highlightDiffSide(hl, '', 'typescript')).toEqual([])
84
+ })
85
+
86
+ test('plaintext fallback still splits per line', async () => {
87
+ const hl = await getHighlighter()
88
+ const lines = highlightDiffSide(hl, 'a\nb\nc', 'plaintext')
89
+ expect(lines.length).toBe(3)
90
+ })
91
+ })
@@ -1,4 +1,6 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { beforeAll, describe, expect, test } from 'bun:test'
2
+ import type { Highlighter } from 'shiki'
3
+ import { getHighlighter } from '../highlight.ts'
2
4
  import { renderAnnotation } from '../render-annotation.ts'
3
5
  import type { ReviewFinding } from '../types.ts'
4
6
 
@@ -14,54 +16,171 @@ const finding: ReviewFinding = {
14
16
  suggestion: { body: 'await lock.acquire()', startLine: 42, endLine: 42 },
15
17
  }
16
18
 
19
+ let hl: Highlighter
20
+ beforeAll(async () => {
21
+ hl = await getHighlighter()
22
+ })
23
+
17
24
  describe('renderAnnotation', () => {
18
25
  test('emits a div with data-finding-id and severity class', () => {
19
- const html = renderAnnotation(finding, { checked: false, posted: false, asCard: false })
26
+ const html = renderAnnotation(finding, {
27
+ checked: false,
28
+ posted: false,
29
+ asCard: false,
30
+ highlighter: hl,
31
+ })
20
32
  expect(html).toContain(`data-finding-id="f-1"`)
21
33
  expect(html).toContain('annot')
22
34
  expect(html).toContain('data-severity="high"')
23
35
  })
24
36
 
25
37
  test('renders severity label, domain chip, and title', () => {
26
- const html = renderAnnotation(finding, { checked: false, posted: false, asCard: false })
38
+ const html = renderAnnotation(finding, {
39
+ checked: false,
40
+ posted: false,
41
+ asCard: false,
42
+ highlighter: hl,
43
+ })
27
44
  expect(html).toContain('HIGH')
28
45
  expect(html).toContain('Bugs')
29
46
  expect(html).toContain('Race in cache.refresh()')
30
47
  })
31
48
 
32
49
  test('renders parsed description sections', () => {
33
- const html = renderAnnotation(finding, { checked: false, posted: false, asCard: false })
50
+ const html = renderAnnotation(finding, {
51
+ checked: false,
52
+ posted: false,
53
+ asCard: false,
54
+ highlighter: hl,
55
+ })
34
56
  expect(html).toContain('Observation')
35
57
  expect(html).toContain('two refreshes overlap')
36
58
  expect(html).toContain('Why it matters')
37
59
  })
38
60
 
39
61
  test('renders the suggestion code block when present', () => {
40
- const html = renderAnnotation(finding, { checked: false, posted: false, asCard: false })
62
+ const html = renderAnnotation(finding, {
63
+ checked: false,
64
+ posted: false,
65
+ asCard: false,
66
+ highlighter: hl,
67
+ })
41
68
  expect(html).toContain('<pre')
42
- expect(html).toContain('await lock.acquire()')
69
+ // Shiki tokenizes identifiers into separate spans; check for a known token
70
+ expect(html).toContain('acquire')
43
71
  })
44
72
 
45
73
  test('renders risk dimensions footer', () => {
46
- const html = renderAnnotation(finding, { checked: false, posted: false, asCard: false })
74
+ const html = renderAnnotation(finding, {
75
+ checked: false,
76
+ posted: false,
77
+ asCard: false,
78
+ highlighter: hl,
79
+ })
47
80
  expect(html).toContain('Impact: <span class="tag">high</span>')
48
81
  expect(html).toContain('Likelihood: <span class="tag">likely</span>')
49
82
  })
50
83
 
51
84
  test('marks posted findings and disables checkbox', () => {
52
- const html = renderAnnotation(finding, { checked: true, posted: true, asCard: false })
85
+ const html = renderAnnotation(finding, {
86
+ checked: true,
87
+ posted: true,
88
+ asCard: false,
89
+ highlighter: hl,
90
+ })
53
91
  expect(html).toContain('data-posted="true"')
54
92
  expect(html).toContain('checked disabled')
55
93
  })
56
94
 
57
95
  test('marks suggestion-only findings with data-suggestion="true"', () => {
58
96
  const suggestion: ReviewFinding = { ...finding, risk: { ...finding.risk, action: 'optional' } }
59
- const html = renderAnnotation(suggestion, { checked: false, posted: false, asCard: false })
97
+ const html = renderAnnotation(suggestion, {
98
+ checked: false,
99
+ posted: false,
100
+ asCard: false,
101
+ highlighter: hl,
102
+ })
60
103
  expect(html).toContain('data-suggestion="true"')
61
104
  })
62
105
 
63
106
  test('asCard=true wraps in .issue-card', () => {
64
- const html = renderAnnotation(finding, { checked: false, posted: false, asCard: true })
107
+ const html = renderAnnotation(finding, {
108
+ checked: false,
109
+ posted: false,
110
+ asCard: true,
111
+ highlighter: hl,
112
+ })
65
113
  expect(html).toContain('class="issue-card')
66
114
  })
115
+
116
+ test('renders backtick spans in description as <code class="inline-code">', () => {
117
+ const f: ReviewFinding = {
118
+ id: 'f-2',
119
+ file: 'a.ts',
120
+ line: 1,
121
+ severity: 'high',
122
+ title: 't',
123
+ description: 'Observation: `scheduler.ts` wires `.finally(...)` only; no `.catch(...)`.',
124
+ risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
125
+ domain: 'bugs',
126
+ }
127
+ const html = renderAnnotation(f, {
128
+ highlighter: hl,
129
+ checked: false,
130
+ posted: false,
131
+ asCard: true,
132
+ })
133
+ expect(html).toContain('<code class="inline-code">scheduler.ts</code>')
134
+ expect(html).toContain('<code class="inline-code">.finally(...)</code>')
135
+ expect(html).toContain('<code class="inline-code">.catch(...)</code>')
136
+ expect(html).not.toContain('<scheduler.ts')
137
+ })
138
+
139
+ test('escapes HTML inside backtick spans', () => {
140
+ const f: ReviewFinding = {
141
+ id: 'f-3',
142
+ file: 'a.ts',
143
+ line: 1,
144
+ severity: 'low',
145
+ title: 't',
146
+ description: 'Observation: `<Foo & Bar>` should not break out.',
147
+ risk: { impact: 'low', likelihood: 'edge-case', confidence: 'low', action: 'consider' },
148
+ domain: 'code-smells',
149
+ }
150
+ const html = renderAnnotation(f, {
151
+ highlighter: hl,
152
+ checked: false,
153
+ posted: false,
154
+ asCard: true,
155
+ })
156
+ expect(html).toContain('<code class="inline-code">&lt;Foo &amp; Bar&gt;</code>')
157
+ })
158
+
159
+ test('renders suggestion body with shiki highlighting', () => {
160
+ const f: ReviewFinding = {
161
+ id: 'f-1',
162
+ file: 'scheduler.ts',
163
+ line: 12,
164
+ severity: 'high',
165
+ title: 'unhandled rejection',
166
+ description: 'Observation: scheduler drops errors.',
167
+ risk: { impact: 'high', likelihood: 'possible', confidence: 'high', action: 'should-fix' },
168
+ domain: 'bugs',
169
+ suggestion: {
170
+ body: 'const x: number = 1\nconsole.log(x)',
171
+ startLine: 1,
172
+ endLine: 2,
173
+ },
174
+ }
175
+ const html = renderAnnotation(f, {
176
+ highlighter: hl,
177
+ checked: false,
178
+ posted: false,
179
+ asCard: true,
180
+ })
181
+ expect(html).toContain('class="suggestion shiki')
182
+ expect(html).toContain('<span class="line">')
183
+ // Shiki tokenizes identifiers separately, so check for the token text
184
+ expect(html).toContain('console')
185
+ })
67
186
  })
@@ -1,5 +1,7 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { beforeAll, describe, expect, test } from 'bun:test'
2
+ import type { Highlighter } from 'shiki'
2
3
  import { parseUnifiedDiffToHunks } from '../diff-utils.ts'
4
+ import { getHighlighter } from '../highlight.ts'
3
5
  import { renderSplitDiff, renderUnifiedDiff } from '../render-diff.ts'
4
6
  import type { ReviewFinding } from '../types.ts'
5
7
 
@@ -24,6 +26,11 @@ const finding: ReviewFinding = {
24
26
  domain: 'bugs',
25
27
  }
26
28
 
29
+ let hl: Highlighter
30
+ beforeAll(async () => {
31
+ hl = await getHighlighter()
32
+ })
33
+
27
34
  describe('renderUnifiedDiff', () => {
28
35
  test('emits one row per diff line', () => {
29
36
  const html = renderUnifiedDiff({
@@ -31,6 +38,8 @@ describe('renderUnifiedDiff', () => {
31
38
  findings: [],
32
39
  postStatus: {},
33
40
  selectedIds: new Set(),
41
+ highlighter: hl,
42
+ file: 'x.ts',
34
43
  })
35
44
  expect((html.match(/class="diff-row/g) ?? []).length).toBe(5)
36
45
  })
@@ -42,6 +51,8 @@ describe('renderUnifiedDiff', () => {
42
51
  findings: [finding],
43
52
  postStatus: {},
44
53
  selectedIds: new Set(),
54
+ highlighter: hl,
55
+ file: 'x.ts',
45
56
  })
46
57
  const idx = html.indexOf('data-finding-id="f-1"')
47
58
  const before = html.lastIndexOf('class="diff-row', idx)
@@ -55,6 +66,8 @@ describe('renderUnifiedDiff', () => {
55
66
  findings: [],
56
67
  postStatus: {},
57
68
  selectedIds: new Set(),
69
+ highlighter: hl,
70
+ file: 'x.ts',
58
71
  })
59
72
  expect(html).toContain('No changes')
60
73
  })
@@ -65,11 +78,57 @@ describe('renderUnifiedDiff', () => {
65
78
  findings: [finding],
66
79
  postStatus: { 'f-1': 'posted' },
67
80
  selectedIds: new Set(),
81
+ highlighter: hl,
82
+ file: 'x.ts',
68
83
  })
69
84
  expect(html).toContain('data-posted="true"')
70
85
  })
71
86
  })
72
87
 
88
+ test('emits shiki-highlighted line content in unified mode', () => {
89
+ const diff = `--- a/x.ts
90
+ +++ b/x.ts
91
+ @@ -1,3 +1,3 @@
92
+ const a = 1
93
+ -const b = 2
94
+ +const b = 3
95
+ `
96
+ const html = renderUnifiedDiff({
97
+ hunks: parseUnifiedDiffToHunks(diff),
98
+ findings: [],
99
+ postStatus: {},
100
+ selectedIds: new Set(),
101
+ highlighter: hl,
102
+ file: 'x.ts',
103
+ })
104
+ expect(html).toContain('const')
105
+ expect(html).toContain('<span style=')
106
+ expect(html).toContain('class="gutter"')
107
+ expect(html).toContain('class="sign"')
108
+ })
109
+
110
+ test('keeps multi-line tokens consistent across diff rows', () => {
111
+ const diff = `--- a/x.ts
112
+ +++ b/x.ts
113
+ @@ -1,5 +1,5 @@
114
+ const x = \`first
115
+ second
116
+ -third\`
117
+ +THIRD\`
118
+ const y = 1
119
+ `
120
+ const html = renderUnifiedDiff({
121
+ hunks: parseUnifiedDiffToHunks(diff),
122
+ findings: [],
123
+ postStatus: {},
124
+ selectedIds: new Set(),
125
+ highlighter: hl,
126
+ file: 'x.ts',
127
+ })
128
+ const rowCount = (html.match(/class="diff-row /g) || []).length
129
+ expect(rowCount).toBe(5)
130
+ })
131
+
73
132
  describe('renderSplitDiff', () => {
74
133
  test('emits left/right cells per row', () => {
75
134
  const html = renderSplitDiff({
@@ -77,8 +136,31 @@ describe('renderSplitDiff', () => {
77
136
  findings: [],
78
137
  postStatus: {},
79
138
  selectedIds: new Set(),
139
+ highlighter: hl,
140
+ file: 'x.ts',
80
141
  })
81
142
  expect((html.match(/diff-row split/g) ?? []).length).toBeGreaterThan(0)
82
143
  expect(html).toContain('diff-cell')
83
144
  })
84
145
  })
146
+
147
+ test('emits shiki-highlighted line content in split mode', () => {
148
+ const diff = `--- a/x.ts
149
+ +++ b/x.ts
150
+ @@ -1,3 +1,3 @@
151
+ const a = 1
152
+ -const b = 2
153
+ +const b = 3
154
+ `
155
+ const html = renderSplitDiff({
156
+ hunks: parseUnifiedDiffToHunks(diff),
157
+ findings: [],
158
+ postStatus: {},
159
+ selectedIds: new Set(),
160
+ highlighter: hl,
161
+ file: 'x.ts',
162
+ })
163
+ expect(html).toContain('class="diff-row split"')
164
+ expect(html).toContain('<span style=')
165
+ expect(html).toContain('class="gutter"')
166
+ })