@bompus/house-rules 0.0.0-stage → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/CONTRIBUTING.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +347 -2
  4. package/THIRD_PARTY_NOTICES.md +188 -0
  5. package/compose.mjs +119 -0
  6. package/composition.mjs +353 -0
  7. package/config-view.mjs +194 -0
  8. package/config.mjs +320 -0
  9. package/docs/assets/banner-dark.png +0 -0
  10. package/docs/assets/banner-light.png +0 -0
  11. package/docs/assets/overview.png +0 -0
  12. package/docs/configuration.md +193 -0
  13. package/docs/npm-release.md +45 -0
  14. package/docs/skill-names.md +50 -0
  15. package/examples/person/house-rules.json +15 -0
  16. package/examples/person/rules/10-tooling.md +8 -0
  17. package/examples/person/rules/20-reporting.md +9 -0
  18. package/examples/project/AGENTS.md +18 -0
  19. package/package.json +40 -4
  20. package/rule-references.mjs +91 -0
  21. package/rules/core.md +431 -0
  22. package/rules/modifiers/coded-offers.md +40 -0
  23. package/rules/modifiers/effort-estimates.md +41 -0
  24. package/rules/modifiers/land-when-done.md +12 -0
  25. package/rules/modifiers/low-quota-handoff.md +19 -0
  26. package/rules/modifiers/multi-agent.md +34 -0
  27. package/rules/modifiers/no-attribution.md +10 -0
  28. package/rules/modifiers/plan-files.md +50 -0
  29. package/rules/modifiers/question-cards.md +48 -0
  30. package/rules/modifiers/release-batching.md +13 -0
  31. package/rules/modifiers/scratch-on-disk.md +15 -0
  32. package/rules/modifiers/shared-host-load.md +48 -0
  33. package/rules/modifiers/solo-operator.md +13 -0
  34. package/rules/modifiers/squash-landing.md +22 -0
  35. package/rules/modifiers/swarmail.md +12 -0
  36. package/rules/references/github-actions.md +45 -0
  37. package/rules/references/landing.md +39 -0
  38. package/rules/references/multi-agent.md +47 -0
  39. package/rules/references/release-batching.md +43 -0
  40. package/rules/references/squash-landing.md +44 -0
  41. package/setup.mjs +375 -0
  42. package/skills/hr-agent-guidance-audit/LICENSE +21 -0
  43. package/skills/hr-agent-guidance-audit/SKILL.md +86 -0
  44. package/skills/hr-agent-guidance-audit/agents/openai.yaml +2 -0
  45. package/skills/hr-agent-guidance-audit/references/records-and-lessons.md +60 -0
  46. package/skills/hr-agent-guidance-refresh/LICENSE +21 -0
  47. package/skills/hr-agent-guidance-refresh/SKILL.md +68 -0
  48. package/skills/hr-agent-guidance-refresh/agents/openai.yaml +2 -0
  49. package/skills/hr-agent-guidance-refresh/references/verification.md +47 -0
  50. package/skills/hr-api-exposure-check/SKILL.md +159 -0
  51. package/skills/hr-api-exposure-check/agents/openai.yaml +3 -0
  52. package/skills/hr-audit-choices/SKILL.md +67 -0
  53. package/skills/hr-audit-choices/agents/openai.yaml +2 -0
  54. package/skills/hr-benchmarking/NOTICE.md +36 -0
  55. package/skills/hr-benchmarking/SKILL.md +316 -0
  56. package/skills/hr-benchmarking/agents/openai.yaml +2 -0
  57. package/skills/hr-benchmarking/references/javascript.md +17 -0
  58. package/skills/hr-benchmarking/references/priority-findings-20261006.md +115 -0
  59. package/skills/hr-benchmarking/references/tools.md +160 -0
  60. package/skills/hr-benchmarking/scripts/process-attribution.ps1 +20 -0
  61. package/skills/hr-benchmarking/scripts/process-attribution.py +107 -0
  62. package/skills/hr-better-accessibility/LICENSE +22 -0
  63. package/skills/hr-better-accessibility/NOTICE.md +46 -0
  64. package/skills/hr-better-accessibility/SKILL.md +119 -0
  65. package/skills/hr-better-accessibility/agents/openai.yaml +3 -0
  66. package/skills/hr-better-accessibility/focus-and-keyboard.md +130 -0
  67. package/skills/hr-better-accessibility/forms.md +82 -0
  68. package/skills/hr-better-accessibility/hit-areas.md +98 -0
  69. package/skills/hr-better-accessibility/motion-and-zoom.md +81 -0
  70. package/skills/hr-better-accessibility/screen-readers.md +101 -0
  71. package/skills/hr-better-accessibility/semantics-and-aria.md +84 -0
  72. package/skills/hr-better-colors/LICENSE +21 -0
  73. package/skills/hr-better-colors/NOTICE.md +37 -0
  74. package/skills/hr-better-colors/SKILL.md +114 -0
  75. package/skills/hr-better-colors/agents/openai.yaml +3 -0
  76. package/skills/hr-better-colors/color-formats.md +90 -0
  77. package/skills/hr-better-colors/color-usage.md +140 -0
  78. package/skills/hr-better-colors/contrast.md +80 -0
  79. package/skills/hr-better-colors/palette-generation.md +104 -0
  80. package/skills/hr-better-colors/palette-structure.md +76 -0
  81. package/skills/hr-better-colors/token-naming.md +97 -0
  82. package/skills/hr-better-layout/LICENSE +22 -0
  83. package/skills/hr-better-layout/NOTICE.md +48 -0
  84. package/skills/hr-better-layout/SKILL.md +92 -0
  85. package/skills/hr-better-layout/agents/openai.yaml +3 -0
  86. package/skills/hr-better-layout/grouping-and-alignment.md +144 -0
  87. package/skills/hr-better-layout/spacing-and-adaptivity.md +215 -0
  88. package/skills/hr-better-typography/LICENSE +22 -0
  89. package/skills/hr-better-typography/NOTICE.md +46 -0
  90. package/skills/hr-better-typography/SKILL.md +172 -0
  91. package/skills/hr-better-typography/agents/openai.yaml +3 -0
  92. package/skills/hr-better-typography/choosing-fonts.md +69 -0
  93. package/skills/hr-better-typography/css-cheat-sheet.md +65 -0
  94. package/skills/hr-better-typography/details-and-accessibility.md +119 -0
  95. package/skills/hr-better-typography/spacing-and-sizing.md +131 -0
  96. package/skills/hr-better-typography/variable-fonts-and-opentype.md +105 -0
  97. package/skills/hr-better-typography/wrapping-and-punctuation.md +57 -0
  98. package/skills/hr-better-writing/LICENSE +22 -0
  99. package/skills/hr-better-writing/NOTICE.md +46 -0
  100. package/skills/hr-better-writing/SKILL.md +122 -0
  101. package/skills/hr-better-writing/agents/openai.yaml +3 -0
  102. package/skills/hr-change-impact/SKILL.md +113 -0
  103. package/skills/hr-change-impact/agents/openai.yaml +5 -0
  104. package/skills/hr-code-review/LICENSE +21 -0
  105. package/skills/hr-code-review/NOTICE.md +26 -0
  106. package/skills/hr-code-review/SKILL.md +147 -0
  107. package/skills/hr-code-review/agents/openai.yaml +3 -0
  108. package/skills/hr-code-review/references/evidence.md +57 -0
  109. package/skills/hr-design-exploration/SKILL.md +82 -0
  110. package/skills/hr-design-exploration/agents/openai.yaml +4 -0
  111. package/skills/hr-diagnosing-bugs/NOTICE.md +45 -0
  112. package/skills/hr-diagnosing-bugs/SKILL.md +108 -0
  113. package/skills/hr-diagnosing-bugs/agents/openai.yaml +3 -0
  114. package/skills/hr-diagnosing-bugs/references/reporting.md +72 -0
  115. package/skills/hr-explain-code/NOTICE.md +37 -0
  116. package/skills/hr-explain-code/SKILL.md +110 -0
  117. package/skills/hr-explain-code/agents/openai.yaml +5 -0
  118. package/skills/hr-explain-code/references/helper-brief.md +39 -0
  119. package/skills/hr-extract-shared-steps/SKILL.md +134 -0
  120. package/skills/hr-extract-shared-steps/agents/openai.yaml +3 -0
  121. package/skills/hr-handoff/SKILL.md +91 -0
  122. package/skills/hr-handoff/agents/openai.yaml +5 -0
  123. package/skills/hr-handoff/assets/ledger.example.json +10 -0
  124. package/skills/hr-handoff/assets/receipt.example.json +76 -0
  125. package/skills/hr-handoff/assets/source.example.json +20 -0
  126. package/skills/hr-handoff/references/reconciliation.md +105 -0
  127. package/skills/hr-handoff/scripts/check-reconciliation.mjs +282 -0
  128. package/skills/hr-house-rules-setup/SKILL.md +100 -0
  129. package/skills/hr-house-rules-setup/agents/openai.yaml +5 -0
  130. package/skills/hr-issue-tracker-setup/SKILL.md +49 -0
  131. package/skills/hr-issue-tracker-setup/agents/openai.yaml +5 -0
  132. package/skills/hr-lean-plan/SKILL.md +142 -0
  133. package/skills/hr-lean-plan/agents/openai.yaml +5 -0
  134. package/skills/hr-maintainability-review/SKILL.md +176 -0
  135. package/skills/hr-maintainability-review/agents/openai.yaml +5 -0
  136. package/skills/hr-manual-qa/NOTICE.md +36 -0
  137. package/skills/hr-manual-qa/SKILL.md +37 -0
  138. package/skills/hr-manual-qa/agents/openai.yaml +3 -0
  139. package/skills/hr-navigation-retro/LICENSE +21 -0
  140. package/skills/hr-navigation-retro/NOTICE.md +5 -0
  141. package/skills/hr-navigation-retro/SKILL.md +86 -0
  142. package/skills/hr-navigation-retro/agents/openai.yaml +4 -0
  143. package/skills/hr-navigation-retro/references/inputs.md +67 -0
  144. package/skills/hr-navigation-retro/scripts/retro.py +552 -0
  145. package/skills/hr-navigation-retro/scripts/transcripts.py +205 -0
  146. package/skills/hr-ordering-tests/SKILL.md +51 -0
  147. package/skills/hr-ordering-tests/agents/openai.yaml +2 -0
  148. package/skills/hr-plain-prose/SKILL.md +261 -0
  149. package/skills/hr-plain-prose/agents/openai.yaml +3 -0
  150. package/skills/hr-plain-prose/scripts/check.mjs +331 -0
  151. package/skills/hr-pr-followup/LICENSE +21 -0
  152. package/skills/hr-pr-followup/NOTICE.md +9 -0
  153. package/skills/hr-pr-followup/SKILL.md +71 -0
  154. package/skills/hr-pr-followup/agents/openai.yaml +2 -0
  155. package/skills/hr-pr-followup/references/review-helpers.md +90 -0
  156. package/skills/hr-pr-followup/scripts/check-pr-review.ts +510 -0
  157. package/skills/hr-pr-followup/scripts/resolve-pr-thread.ts +219 -0
  158. package/skills/hr-progress-report/SKILL.md +49 -0
  159. package/skills/hr-progress-report/agents/openai.yaml +5 -0
  160. package/skills/hr-read-reddit/SKILL.md +44 -0
  161. package/skills/hr-read-reddit/agents/openai.yaml +2 -0
  162. package/skills/hr-read-reddit/scripts/reddit.ts +252 -0
  163. package/skills/hr-read-x-links/SKILL.md +42 -0
  164. package/skills/hr-read-x-links/agents/openai.yaml +2 -0
  165. package/skills/hr-read-x-links/scripts/x-post.ts +264 -0
  166. package/skills/hr-split-to-prs/LICENSE +21 -0
  167. package/skills/hr-split-to-prs/NOTICE.md +9 -0
  168. package/skills/hr-split-to-prs/SKILL.md +61 -0
  169. package/skills/hr-split-to-prs/agents/openai.yaml +2 -0
  170. package/skills/hr-stock-ui-audit/SKILL.md +132 -0
  171. package/skills/hr-stock-ui-audit/agents/openai.yaml +3 -0
  172. package/skills/hr-stock-ui-audit/scripts/scan.mjs +619 -0
  173. package/skills/hr-tdd/LICENSE +21 -0
  174. package/skills/hr-tdd/NOTICE.md +10 -0
  175. package/skills/hr-tdd/SKILL.md +40 -0
  176. package/skills/hr-tdd/agents/openai.yaml +3 -0
  177. package/skills/hr-tdd/mocking.md +39 -0
  178. package/skills/hr-tdd/tests.md +77 -0
  179. package/skills/hr-test-audit/SKILL.md +146 -0
  180. package/skills/hr-test-audit/agents/openai.yaml +3 -0
  181. package/skills/hr-test-audit/scripts/floor-guard.ts +137 -0
  182. package/skills/hr-what-next/SKILL.md +61 -0
  183. package/skills/hr-what-next/agents/openai.yaml +4 -0
  184. package/skills/hr-what-next/references/reporting.md +68 -0
  185. package/skills/hr-writing-for-agents/NOTICE.md +46 -0
  186. package/skills/hr-writing-for-agents/SKILL-MECHANICS.md +83 -0
  187. package/skills/hr-writing-for-agents/SKILL.md +108 -0
  188. package/skills/hr-writing-for-agents/agents/openai.yaml +3 -0
  189. package/skills/hr-writing-pr/NOTICE.md +48 -0
  190. package/skills/hr-writing-pr/SKILL.md +104 -0
  191. package/skills/hr-writing-pr/agents/openai.yaml +2 -0
@@ -0,0 +1,619 @@
1
+ #!/usr/bin/env node
2
+ // First-pass scanner for template-default styling in frontend source.
3
+ // Every finding is a candidate for human or agent triage, not a verdict.
4
+ // Runs on Node 22+ or Bun with no dependencies.
5
+ //
6
+ // scan.mjs [--json] [--fail-on high|medium|low] <file-or-dir>...
7
+ //
8
+ // Exit codes: 0 nothing at or above the failing severity, 1 findings at or
9
+ // above it, 2 bad usage (unknown flag, no paths, missing path, zero files).
10
+
11
+ import { readdirSync, readFileSync, statSync } from "node:fs";
12
+ import { extname, join, relative, resolve, sep } from "node:path";
13
+
14
+ const EXTENSIONS = new Set([
15
+ ".html",
16
+ ".css",
17
+ ".scss",
18
+ ".js",
19
+ ".ts",
20
+ ".jsx",
21
+ ".tsx",
22
+ ".vue",
23
+ ".svelte",
24
+ ".astro",
25
+ ]);
26
+ const SKIP_DIRS = new Set([
27
+ "node_modules",
28
+ "bower_components",
29
+ "vendor",
30
+ "dist",
31
+ "build",
32
+ "out",
33
+ "coverage",
34
+ ".next",
35
+ ".nuxt",
36
+ ".svelte-kit",
37
+ ".output",
38
+ ".vercel",
39
+ ".netlify",
40
+ ".turbo",
41
+ ".cache",
42
+ ".parcel-cache",
43
+ ".astro",
44
+ ".git",
45
+ "storybook-static",
46
+ ]);
47
+ const SEVERITY_RANK = { low: 1, medium: 2, high: 3 };
48
+
49
+ const HINTS = {
50
+ "purple-blue-gradient":
51
+ "Indigo, violet or purple blended with blue is a stock hero look. Keep it only if the brand palette calls for it.",
52
+ "violet-accent":
53
+ "Violet or indigo accent. Confirm the brief or design tokens chose this hue rather than a starter template.",
54
+ "gradient-text":
55
+ "Gradient-filled text. Check it is a deliberate display treatment, readable, and not repeated across every heading.",
56
+ "single-stock-font":
57
+ "Choose type that fits the product, or record why this stock face was chosen.",
58
+ "frosted-glass":
59
+ "Backdrop blur panel. Fine for overlays over busy content; on flat backgrounds it only costs contrast and paint time.",
60
+ "emoji-icon":
61
+ "Emoji standing in for an icon in a heading, control or nav item. Use the project's icon set or plain text.",
62
+ "neon-glow":
63
+ "Colored glow shadow. Check it signals focus or state rather than decorating everything.",
64
+ "library-default-token":
65
+ "Unmodified component-library default value. Replace it with the product's own token if the theme was never customized.",
66
+ "tracked-caps":
67
+ "Wide-tracked all-caps label. Check it is used sparingly and that the text stays readable at small sizes.",
68
+ };
69
+ const SEVERITY = {
70
+ "purple-blue-gradient": "high",
71
+ "violet-accent": "medium",
72
+ "gradient-text": "medium",
73
+ "single-stock-font": "medium",
74
+ "emoji-icon": "medium",
75
+ "neon-glow": "medium",
76
+ "frosted-glass": "low",
77
+ "library-default-token": "low",
78
+ "tracked-caps": "low",
79
+ };
80
+
81
+ // ---------- color parsing ----------
82
+
83
+ function rgbToHsl(r, g, b) {
84
+ const max = Math.max(r, g, b);
85
+ const min = Math.min(r, g, b);
86
+ const l = (max + min) / 2;
87
+ if (max === min) return { h: 0, s: 0, l };
88
+ const d = max - min;
89
+ const s = d / (1 - Math.abs(2 * l - 1));
90
+ let h;
91
+ if (max === r) h = ((g - b) / d) % 6;
92
+ else if (max === g) h = (b - r) / d + 2;
93
+ else h = (r - g) / d + 4;
94
+ h *= 60;
95
+ if (h < 0) h += 360;
96
+ return { h, s: Math.min(1, s), l };
97
+ }
98
+
99
+ function oklchToHsl(L, C, H) {
100
+ const hr = (H * Math.PI) / 180;
101
+ const a = C * Math.cos(hr);
102
+ const b = C * Math.sin(hr);
103
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
104
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
105
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
106
+ const lin = [
107
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
108
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
109
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
110
+ ];
111
+ const [r, g, bl] = lin.map((x) => {
112
+ const v = x <= 0.0031308 ? 12.92 * x : 1.055 * Math.sign(x) * Math.abs(x) ** (1 / 2.4) - 0.055;
113
+ return Math.min(1, Math.max(0, v));
114
+ });
115
+ return rgbToHsl(r, g, bl);
116
+ }
117
+
118
+ const channel = (v) => (v.endsWith("%") ? parseFloat(v) / 100 : parseFloat(v) / 255);
119
+ const hueUnits = (v, unit) =>
120
+ unit === "turn" ? v * 360 : unit === "rad" ? (v * 180) / Math.PI : v;
121
+
122
+ // Returns every color literal on a line as { h, s, l, index }.
123
+ function colorsIn(line) {
124
+ const out = [];
125
+ for (const m of line.matchAll(/#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?![0-9a-z_-])/gi)) {
126
+ let hex = m[1];
127
+ if (hex.length <= 4) hex = [...hex].map((c) => c + c).join("");
128
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
129
+ out.push({ ...rgbToHsl(r, g, b), index: m.index });
130
+ }
131
+ for (const m of line.matchAll(/rgba?\(\s*([\d.]+%?)[\s,]+([\d.]+%?)[\s,]+([\d.]+%?)/gi)) {
132
+ out.push({ ...rgbToHsl(channel(m[1]), channel(m[2]), channel(m[3])), index: m.index });
133
+ }
134
+ for (const m of line.matchAll(
135
+ /hsla?\(\s*([\d.]+)(deg|turn|rad)?[\s,]+([\d.]+)%?[\s,]+([\d.]+)%?/gi,
136
+ )) {
137
+ out.push({
138
+ h: hueUnits(parseFloat(m[1]), m[2]) % 360,
139
+ s: m[3] / 100,
140
+ l: m[4] / 100,
141
+ index: m.index,
142
+ });
143
+ }
144
+ // Bare HSL triplets in custom properties, as theme generators emit them.
145
+ for (const m of line.matchAll(/--[\w-]+\s*:\s*([\d.]+)\s+([\d.]+)%\s+([\d.]+)%/g)) {
146
+ out.push({ h: parseFloat(m[1]) % 360, s: m[2] / 100, l: m[3] / 100, index: m.index });
147
+ }
148
+ for (const m of line.matchAll(/oklch\(\s*([\d.]+)(%?)\s+([\d.]+)(%?)\s+([\d.]+)/gi)) {
149
+ const L = m[2] ? m[1] / 100 : parseFloat(m[1]);
150
+ const C = m[4] ? (m[3] / 100) * 0.4 : parseFloat(m[3]);
151
+ out.push({ ...oklchToHsl(L, C, parseFloat(m[5])), index: m.index });
152
+ }
153
+ return out;
154
+ }
155
+
156
+ const chromatic = (c) => c.s >= 0.35 && c.l >= 0.15 && c.l <= 0.9;
157
+ const isViolet = (c) => chromatic(c) && c.l >= 0.2 && c.l <= 0.85 && c.h >= 235 && c.h <= 290;
158
+ const isCool = (c) => c.h >= 185 && c.h <= 320;
159
+
160
+ // ---------- line rules ----------
161
+
162
+ const TW_VIOLET =
163
+ /\b(?:bg|text|border|ring|outline|fill|stroke|accent|decoration|divide|caret|shadow|from|via|to)-(?:indigo|violet|purple)-\d{2,3}\b/;
164
+ const TW_GRADIENT_STOP = /\b(?:from|via|to)-([a-z]+)-\d{2,3}\b/g;
165
+ const TW_COOL = new Set(["blue", "sky", "cyan", "indigo", "violet", "purple", "fuchsia"]);
166
+ const TW_VIOLET_NAMES = new Set(["indigo", "violet", "purple"]);
167
+ const TW_CHROMA =
168
+ "red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
169
+
170
+ // Longest gradient expression read; real ones are far shorter. The bound keeps
171
+ // a stray "gradient(" with no closing parenthesis from reading the whole file.
172
+ const MAX_GRADIENT = 2000;
173
+
174
+ // Offset of the parenthesis closing the one at `open`, skipping /* */ comments,
175
+ // or -1 when none closes within MAX_GRADIENT characters.
176
+ function closingParen(text, open) {
177
+ const limit = Math.min(text.length, open + MAX_GRADIENT);
178
+ let depth = 0;
179
+ let comment = false;
180
+ for (let i = open; i < limit; i++) {
181
+ if (comment) {
182
+ if (text[i] === "*" && text[i + 1] === "/") {
183
+ comment = false;
184
+ i++;
185
+ }
186
+ } else if (text[i] === "/" && text[i + 1] === "*") {
187
+ comment = true;
188
+ i++;
189
+ } else if (text[i] === "(") depth++;
190
+ else if (text[i] === ")" && --depth === 0) return i;
191
+ }
192
+ return -1;
193
+ }
194
+
195
+ // CSS gradient functions often span several lines, so these are read from the
196
+ // whole file: each purple-to-blue one as its start and end offsets. An unclosed
197
+ // one is read to the end of its line.
198
+ function cssGradients(text) {
199
+ const out = [];
200
+ for (const m of text.matchAll(/(?:linear|radial|conic)-gradient\(/gi)) {
201
+ let end = closingParen(text, m.index + m[0].length - 1);
202
+ if (end === -1) {
203
+ end = text.indexOf("\n", m.index);
204
+ if (end === -1) end = text.length;
205
+ }
206
+ const body = text.slice(m.index, end + 1).replaceAll(/\/\*[\s\S]*?\*\//g, " ");
207
+ const stops = colorsIn(body).filter(chromatic);
208
+ if (stops.length >= 2 && stops.every(isCool) && stops.some(isViolet)) {
209
+ out.push({ start: m.index, end });
210
+ }
211
+ }
212
+ return out;
213
+ }
214
+
215
+ function twGradientHit(line) {
216
+ const names = [...line.matchAll(TW_GRADIENT_STOP)];
217
+ if (names.length >= 2) {
218
+ const colors = names.map((m) => m[1]);
219
+ if (colors.every((c) => TW_COOL.has(c)) && colors.some((c) => TW_VIOLET_NAMES.has(c))) {
220
+ return names[0].index;
221
+ }
222
+ }
223
+ return -1;
224
+ }
225
+
226
+ function violetHit(line) {
227
+ const c = colorsIn(line).find(isViolet);
228
+ if (c) return c.index;
229
+ const m = line.match(TW_VIOLET);
230
+ return m ? m.index : -1;
231
+ }
232
+
233
+ function gradientTextHit(line) {
234
+ return line.search(
235
+ /(?:-webkit-)?background-clip\s*:\s*text|(?:Webkit)?[bB]ackgroundClip\s*:\s*['"]text|\bbg-clip-text\b/,
236
+ );
237
+ }
238
+
239
+ function frostedHit(line) {
240
+ return line.search(
241
+ /backdrop-filter\s*:[^;]*blur\(|backdropFilter\s*:\s*['"`][^'"`]*blur\(|\bbackdrop-blur\b/,
242
+ );
243
+ }
244
+
245
+ function glowHit(line) {
246
+ const tw = line.match(new RegExp(`\\bshadow-(?:${TW_CHROMA})-\\d{2,3}\\b`));
247
+ if (tw) return tw.index;
248
+ const prop = line.search(/box-shadow|text-shadow|boxShadow|textShadow|drop-shadow|shadow-\[/);
249
+ if (prop === -1) return -1;
250
+ const off = line.match(/(?:^|[\s:,'"(_[])0(?:px)?[\s_]+0(?:px)?[\s_]+(\d+(?:\.\d+)?)px/);
251
+ if (!off || parseFloat(off[1]) < 10) return -1;
252
+ const saturated = colorsIn(line).some((c) => c.s >= 0.5 && c.l >= 0.25 && c.l <= 0.8);
253
+ return saturated ? prop : -1;
254
+ }
255
+
256
+ const EMOJI_CONTEXT =
257
+ /<(?:h[1-6]|button|a|nav|li|summary|label|Button|Link|NavLink|MenuItem|Tab|Badge)\b|\b(?:icon|emoji|label|title)\s*[:=]/;
258
+
259
+ function emojiHit(line, prev) {
260
+ for (const m of line.matchAll(/\p{Extended_Pictographic}/gu)) {
261
+ // Below U+2600 sit (c), (R), TM, arrows and other text symbols, not icons.
262
+ if (m[0].codePointAt(0) < 0x2600) continue;
263
+ if (EMOJI_CONTEXT.test(line) || EMOJI_CONTEXT.test(prev)) return m.index;
264
+ }
265
+ return -1;
266
+ }
267
+
268
+ const LIBRARY_DEFAULTS = [
269
+ [/#0d6efd(?![0-9a-f])/i, "Bootstrap 5 primary"],
270
+ [/#007bff(?![0-9a-f])/i, "Bootstrap 4 primary"],
271
+ [/#6200ee(?![0-9a-f])/i, "Material Design baseline primary"],
272
+ [/#1976d2(?![0-9a-f])/i, "MUI default primary"],
273
+ [/#1677ff(?![0-9a-f])/i, "Ant Design 5 primary"],
274
+ [/#1890ff(?![0-9a-f])/i, "Ant Design 4 primary"],
275
+ [/#3182ce(?![0-9a-f])/i, "Chakra UI blue.500"],
276
+ [/#646cff(?![0-9a-f])/i, "Vite starter accent"],
277
+ [/222\.2 47\.4% 11\.2%/, "shadcn/ui slate theme primary"],
278
+ [/222\.2 84% 4\.9%/, "shadcn/ui slate theme foreground"],
279
+ [/oklch\(0\.205 0 0\)/, "shadcn/ui neutral theme primary"],
280
+ ];
281
+
282
+ function libraryHit(line) {
283
+ for (const [re, source] of LIBRARY_DEFAULTS) {
284
+ const m = line.match(re);
285
+ if (m) return { index: m.index, source };
286
+ }
287
+ return null;
288
+ }
289
+
290
+ function twTrackedCapsHit(line) {
291
+ if (!/(?:^|[\s"'`])uppercase(?=[\s"'`]|$)/.test(line)) return -1;
292
+ const m = line.match(/\btracking-(?:wider|widest|\[([\d.]+)(em|rem|px)\])/);
293
+ if (!m) return -1;
294
+ if (m[1] && !wideSpacing(parseFloat(m[1]), m[2])) return -1;
295
+ return m.index;
296
+ }
297
+
298
+ function wideSpacing(value, unit) {
299
+ if (unit === "em" || unit === "rem") return value >= 0.08;
300
+ return value >= 1.5; // px, or a unitless number in a JS style object
301
+ }
302
+
303
+ // Rule blocks: innermost { ... } in CSS, SCSS or JS style objects.
304
+ function trackedCapsBlocks(text) {
305
+ const hits = [];
306
+ for (const m of text.matchAll(/\{[^{}]*\}/g)) {
307
+ const body = m[0];
308
+ const caps = body.search(/text-transform\s*:\s*uppercase|textTransform\s*:\s*['"]uppercase/);
309
+ if (caps === -1) continue;
310
+ const sp = body.match(
311
+ /letter-spacing\s*:\s*([\d.]+)(em|rem|px)|letterSpacing\s*:\s*['"]?([\d.]+)(em|rem|px)?/,
312
+ );
313
+ if (!sp) continue;
314
+ const ok = sp[1]
315
+ ? wideSpacing(parseFloat(sp[1]), sp[2])
316
+ : wideSpacing(parseFloat(sp[3]), sp[4] ?? "px");
317
+ if (ok) hits.push(m.index + caps);
318
+ }
319
+ return hits;
320
+ }
321
+
322
+ // ---------- font aggregation ----------
323
+
324
+ const GENERIC_FONTS = new Set([
325
+ "serif",
326
+ "sans-serif",
327
+ "monospace",
328
+ "cursive",
329
+ "fantasy",
330
+ "system-ui",
331
+ "ui-sans-serif",
332
+ "ui-serif",
333
+ "ui-monospace",
334
+ "ui-rounded",
335
+ "-apple-system",
336
+ "blinkmacsystemfont",
337
+ "segoe ui",
338
+ "helvetica",
339
+ "helvetica neue",
340
+ "arial",
341
+ "inherit",
342
+ "initial",
343
+ "unset",
344
+ "revert",
345
+ "emoji",
346
+ "math",
347
+ "apple color emoji",
348
+ "segoe ui emoji",
349
+ "noto color emoji",
350
+ ]);
351
+ const STOCK_SANS = new Set([
352
+ "inter",
353
+ "roboto",
354
+ "open sans",
355
+ "poppins",
356
+ "montserrat",
357
+ "lato",
358
+ "geist",
359
+ "dm sans",
360
+ "plus jakarta sans",
361
+ "nunito",
362
+ "manrope",
363
+ "outfit",
364
+ ]);
365
+
366
+ function normalizeFamily(raw) {
367
+ return raw
368
+ .replace(/["'`[\]]/g, "")
369
+ .trim()
370
+ .toLowerCase()
371
+ .replace(/[_-]+/g, " ")
372
+ .replace(/\s*(?:var|variable)$/, "")
373
+ .replace(/^intervariable$/, "inter");
374
+ }
375
+
376
+ function primaryFamily(list) {
377
+ const first = list.split(",")[0]?.trim() ?? "";
378
+ if (!first || first.startsWith("var(") || first.startsWith("$")) return null;
379
+ const name = normalizeFamily(first);
380
+ if (!/^[a-z0-9 ]+$/.test(name) || GENERIC_FONTS.has(name)) return null;
381
+ if (/mono|code|courier|consolas|menlo/.test(name)) return null;
382
+ return name;
383
+ }
384
+
385
+ function fontDeclarations(line) {
386
+ const lists = [];
387
+ for (const m of line.matchAll(/font-family\s*:\s*([^;{}]+)/gi)) lists.push([m[1], m.index]);
388
+ for (const m of line.matchAll(/fontFamily\s*:\s*(.+)/g)) lists.push([m[1], m.index]);
389
+ for (const m of line.matchAll(/\b(?:sans|display|heading|body|brand)\s*:\s*\[([^\]]*)/g))
390
+ lists.push([m[1], m.index]);
391
+ for (const m of line.matchAll(/\bfont\s*:[^;]*?\d(?:px|rem|em|%)(?:\/[\d.]+\w*)?\s+([^;]+)/g))
392
+ lists.push([m[1], m.index]);
393
+ const gf = line.match(/fonts\.googleapis\.com\/css2?\?[^"'\s)]*/);
394
+ if (gf) {
395
+ for (const m of gf[0].matchAll(/family=([^&:;"')]+)/g)) {
396
+ lists.push([decodeURIComponent(m[1].replace(/\+/g, " ")), gf.index]);
397
+ }
398
+ }
399
+ const nf = line.match(/import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]/);
400
+ if (nf) for (const name of nf[1].split(",")) lists.push([name.split(/\s+as\s+/)[0], nf.index]);
401
+ for (const m of line.matchAll(/@fontsource(?:-variable)?\/([\w-]+)/g))
402
+ lists.push([m[1], m.index]);
403
+ return lists
404
+ .map(([list, index]) => ({ family: primaryFamily(list), index }))
405
+ .filter((d) => d.family);
406
+ }
407
+
408
+ // ---------- scanning ----------
409
+
410
+ function excerpt(line, index) {
411
+ const at = Math.max(0, index);
412
+ if (line.trim().length <= 120) return line.trim();
413
+ const start = Math.max(0, at - 40);
414
+ const piece = line.slice(start, start + 120).trim();
415
+ return `${start > 0 ? "..." : ""}${piece}${start + 120 < line.length ? "..." : ""}`;
416
+ }
417
+
418
+ function scanFile(path, display, fonts) {
419
+ const text = readFileSync(path, "utf8");
420
+ const lines = text.split(/\r?\n/);
421
+ const findings = [];
422
+ const seen = new Set();
423
+ const add = (rule, lineNo, index, hint = HINTS[rule]) => {
424
+ const key = `${rule}:${lineNo}`;
425
+ if (seen.has(key)) return;
426
+ seen.add(key);
427
+ findings.push({
428
+ rule,
429
+ severity: SEVERITY[rule],
430
+ file: display,
431
+ line: lineNo,
432
+ match: excerpt(lines[lineNo - 1], index),
433
+ hint,
434
+ });
435
+ };
436
+
437
+ const starts = [0];
438
+ for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1);
439
+ const lineAt = (offset) => {
440
+ let lo = 0;
441
+ let hi = starts.length - 1;
442
+ while (lo < hi) {
443
+ const mid = (lo + hi + 1) >> 1;
444
+ if (starts[mid] <= offset) lo = mid;
445
+ else hi = mid - 1;
446
+ }
447
+ return lo;
448
+ };
449
+
450
+ // A gradient is reported once, at its first line. Its text is blanked before
451
+ // the violet check, so its stops are not reported again as violet accents but
452
+ // other colors on the same lines still are.
453
+ let blanked = "";
454
+ let from = 0;
455
+ for (const { start, end } of cssGradients(text)) {
456
+ const first = lineAt(start);
457
+ add("purple-blue-gradient", first + 1, start - starts[first]);
458
+ const at = Math.max(from, start);
459
+ blanked += text.slice(from, at) + text.slice(at, end + 1).replaceAll(/[^\r\n]/g, " ");
460
+ from = Math.max(from, end + 1);
461
+ }
462
+ const violetLines = (blanked + text.slice(from)).split(/\r?\n/);
463
+
464
+ lines.forEach((line, i) => {
465
+ const n = i + 1;
466
+ let at = twGradientHit(line);
467
+ if (at !== -1) add("purple-blue-gradient", n, at);
468
+ else if ((at = violetHit(violetLines[i])) !== -1) add("violet-accent", n, at);
469
+ if ((at = gradientTextHit(line)) !== -1) add("gradient-text", n, at);
470
+ if ((at = frostedHit(line)) !== -1) add("frosted-glass", n, at);
471
+ if ((at = glowHit(line)) !== -1) add("neon-glow", n, at);
472
+ if ((at = emojiHit(line, lines[i - 1] ?? "")) !== -1) add("emoji-icon", n, at);
473
+ if ((at = twTrackedCapsHit(line)) !== -1) add("tracked-caps", n, at);
474
+ const lib = libraryHit(line);
475
+ if (lib)
476
+ add(
477
+ "library-default-token",
478
+ n,
479
+ lib.index,
480
+ `${lib.source}. ${HINTS["library-default-token"]}`,
481
+ );
482
+ for (const d of fontDeclarations(line))
483
+ fonts.push({ ...d, file: display, line: n, text: line });
484
+ });
485
+
486
+ for (const offset of trackedCapsBlocks(text)) {
487
+ const lo = lineAt(offset);
488
+ add("tracked-caps", lo + 1, offset - starts[lo]);
489
+ }
490
+ return findings;
491
+ }
492
+
493
+ function fontFinding(fonts) {
494
+ const families = new Set(fonts.map((d) => d.family));
495
+ if (families.size !== 1 || fonts.length < 2) return null;
496
+ const [family] = families;
497
+ if (!STOCK_SANS.has(family)) return null;
498
+ const first = fonts[0];
499
+ return {
500
+ rule: "single-stock-font",
501
+ severity: SEVERITY["single-stock-font"],
502
+ file: first.file,
503
+ line: first.line,
504
+ match: excerpt(first.text, first.index),
505
+ hint: `"${family}" is the only named sans face across ${fonts.length} declarations. ${HINTS["single-stock-font"]}`,
506
+ };
507
+ }
508
+
509
+ function collect(paths) {
510
+ const files = new Set();
511
+ const walk = (dir) => {
512
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
513
+ const full = join(dir, entry.name);
514
+ if (entry.isDirectory()) {
515
+ if (!SKIP_DIRS.has(entry.name)) walk(full);
516
+ } else if (entry.isFile() && wanted(entry.name)) {
517
+ files.add(full);
518
+ }
519
+ }
520
+ };
521
+ for (const p of paths) {
522
+ const abs = resolve(p);
523
+ const st = statSync(abs);
524
+ if (st.isDirectory()) walk(abs);
525
+ else if (st.isFile() && wanted(abs)) files.add(abs);
526
+ }
527
+ return [...files].sort();
528
+ }
529
+
530
+ function wanted(name) {
531
+ return EXTENSIONS.has(extname(name).toLowerCase()) && !/\.min\.(?:js|css)$/i.test(name);
532
+ }
533
+
534
+ // ---------- CLI ----------
535
+
536
+ const USAGE = `Usage: scan.mjs [--json] [--fail-on high|medium|low] <file-or-dir>...
537
+
538
+ Flags template-default styling for triage. Exit 0 when nothing reaches the
539
+ failing severity (default high), 1 when something does, 2 on bad usage.`;
540
+
541
+ function usageError(message) {
542
+ process.stderr.write(`scan: ${message}\n\n${USAGE}\n`);
543
+ process.exit(2);
544
+ }
545
+
546
+ function main(argv) {
547
+ let json = false;
548
+ let failOn = "high";
549
+ const paths = [];
550
+ for (let i = 0; i < argv.length; i++) {
551
+ const arg = argv[i];
552
+ if (arg === "--") {
553
+ paths.push(...argv.slice(i + 1));
554
+ break;
555
+ } else if (arg === "-h" || arg === "--help") {
556
+ process.stdout.write(`${USAGE}\n`);
557
+ process.exit(0);
558
+ } else if (arg === "--json") {
559
+ json = true;
560
+ } else if (arg === "--fail-on" || arg.startsWith("--fail-on=")) {
561
+ const value = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : argv[++i];
562
+ if (!(value in SEVERITY_RANK))
563
+ usageError(`--fail-on needs high, medium or low, got ${value ?? "nothing"}`);
564
+ failOn = value;
565
+ } else if (arg.startsWith("-")) {
566
+ usageError(`unknown flag ${arg}`);
567
+ } else {
568
+ paths.push(arg);
569
+ }
570
+ }
571
+ if (paths.length === 0) usageError("no files or directories given");
572
+ for (const p of paths) {
573
+ try {
574
+ statSync(p);
575
+ } catch {
576
+ usageError(`no such file or directory: ${p}`);
577
+ }
578
+ }
579
+
580
+ const files = collect(paths);
581
+ if (files.length === 0)
582
+ usageError(`no scannable files (${[...EXTENSIONS].join(" ")}) under the given paths`);
583
+
584
+ const fonts = [];
585
+ const findings = files.flatMap((f) =>
586
+ scanFile(f, (relative(process.cwd(), f) || f).split(sep).join("/"), fonts),
587
+ );
588
+ const font = fontFinding(fonts);
589
+ if (font) findings.push(font);
590
+ findings.sort(
591
+ (a, b) =>
592
+ a.file.localeCompare(b.file) ||
593
+ a.line - b.line ||
594
+ SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity],
595
+ );
596
+
597
+ const counts = { high: 0, medium: 0, low: 0 };
598
+ for (const f of findings) counts[f.severity]++;
599
+ const failing = findings.some((f) => SEVERITY_RANK[f.severity] >= SEVERITY_RANK[failOn]);
600
+
601
+ if (json) {
602
+ process.stdout.write(
603
+ `${JSON.stringify({ filesScanned: files.length, failOn, counts, findings }, null, 2)}\n`,
604
+ );
605
+ } else {
606
+ for (const f of findings) {
607
+ process.stdout.write(
608
+ `${f.file}:${f.line} ${f.severity.padEnd(6)} ${f.rule}\n ${f.match}\n hint: ${f.hint}\n`,
609
+ );
610
+ }
611
+ process.stdout.write(
612
+ `Scanned ${files.length} file${files.length === 1 ? "" : "s"}: ${counts.high} high, ${counts.medium} medium, ${counts.low} low (failing at ${failOn}).\n`,
613
+ );
614
+ }
615
+ // exitCode, not exit(): exit() drops piped output that has not drained yet.
616
+ process.exitCode = failing ? 1 : 0;
617
+ }
618
+
619
+ main(process.argv.slice(2));
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Pocock
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,10 @@
1
+ # Third-party notices
2
+
3
+ Adapted from mattpocock/skills, `skills/engineering/tdd/`, at revision
4
+ `f3fc5632f401156837ee3872f14fe33ccf1024ea`.
5
+
6
+ Source: https://github.com/mattpocock/skills/tree/f3fc5632f401156837ee3872f14fe33ccf1024ea/skills/engineering/tdd
7
+
8
+ Copyright (c) 2026 Matt Pocock
9
+
10
+ The MIT permission and warranty notice is included in `LICENSE`.
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: hr-tdd
3
+ description: Test-driven development (red-green; refactoring is deferred to review). Use when the user wants to build features or fix bugs test-first or mentions TDD or "red-green-refactor".
4
+ ---
5
+
6
+ # Test-driven development
7
+
8
+ TDD is the red-green loop. This skill is the reference that makes that loop produce tests worth keeping: what a good test is, where tests go, the anti-patterns, and the rules of the loop. Every section applies on every cycle: consult them before and during the loop, not after.
9
+
10
+ When exploring the codebase, read `CONTEXT.md` (if it exists) so test names and interface vocabulary match the project's domain language, and respect ADRs in the area you're touching.
11
+
12
+ ## What a good test is
13
+
14
+ Tests verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't. A good test reads like a specification: "user can checkout with valid cart" tells you exactly what capability exists, and it survives refactors because it doesn't care about internal structure.
15
+
16
+ See [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines.
17
+
18
+ ## Seams: where tests go
19
+
20
+ A **seam** is the public boundary you test at: the interface where you observe behavior without reaching inside. Tests live at seams, never against internals.
21
+
22
+ Use the existing or already approved public boundary. Ask only when choosing a different boundary materially changes scope. Identify the critical paths and complex logic in the change. Map each to a selected seam, and state any uncovered behavior.
23
+
24
+ When boundary limits affect a coverage decision, give each proposed boundary a one-line note on what it catches and misses.
25
+
26
+ When the interface is itself in question, follow the repository's design guidance before choosing a different boundary.
27
+
28
+ ## Anti-patterns
29
+
30
+ - **The repository's bar.** Apply its documented testing standards. Litmus: would `return input` / `return stub` still pass?
31
+ - **Implementation-coupled**: mocks internal collaborators, tests private methods, or inspects private state rather than observable behavior. Database assertions are appropriate when persisted state is part of the public contract. The tell: the test breaks when you refactor but behavior hasn't changed.
32
+ - **Tautological**: the assertion recomputes the expected value the way the code does (`expect(add(a, b)).toBe(a + b)`, a snapshot derived by hand the same way, a constant asserted equal to itself), so it passes by construction and can never disagree with the code. Expected values must come from an independent source of truth: a known-good literal, a worked example, the spec.
33
+ - **Horizontal slicing**: writing all tests first, then all implementation. Bulk tests verify _imagined_ behavior: you test the _shape_ of things rather than user-facing behavior, the tests go insensitive to real changes, and you commit to test structure before understanding the implementation. Work in **vertical slices** instead: write one test, then one implementation, and repeat, each test a **tracer bullet** that responds to what the last cycle taught you.
34
+
35
+ ## Rules of the loop
36
+
37
+ - **Red before green.** Write the failing test first, then only enough code to pass it. Don't anticipate future tests or add speculative features.
38
+ - **One slice at a time.** One seam, one test, one minimal implementation per cycle.
39
+ - **Refactoring is not part of the loop.** Do behavior-preserving simplification in the repository's review stage, after the red-green implementation cycles.
40
+ - **Keep only tests that earn it.** Before a test lands, apply the repository's testing standards and the `hr-test-audit` authoring gate when that skill is available.
@@ -0,0 +1,3 @@
1
+ interface:
2
+ display_name: "TDD"
3
+ short_description: "Test-driven red-green implementation"