trunative 1.0.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/dist/cli.js +105 -0
  4. package/dist/commands/build.js +35 -0
  5. package/dist/commands/detect.js +91 -0
  6. package/dist/commands/doctor.js +110 -0
  7. package/dist/commands/graph.js +106 -0
  8. package/dist/commands/install.js +76 -0
  9. package/dist/commands/lint.js +87 -0
  10. package/dist/commands/rubric.js +104 -0
  11. package/dist/commands/spec.js +300 -0
  12. package/dist/compile.js +235 -0
  13. package/dist/detect/rules.js +208 -0
  14. package/dist/detect/types.js +36 -0
  15. package/dist/emit.js +77 -0
  16. package/dist/graph.js +288 -0
  17. package/dist/heuristics.js +185 -0
  18. package/dist/lock.js +18 -0
  19. package/dist/mdx.js +84 -0
  20. package/dist/paths.js +62 -0
  21. package/dist/skill.js +69 -0
  22. package/package.json +51 -0
  23. package/src/skills/SKILL.md +125 -0
  24. package/src/skills/flow/build.md +57 -0
  25. package/src/skills/flow/firebase.md +102 -0
  26. package/src/skills/flow/init.md +116 -0
  27. package/src/skills/flow/review.md +186 -0
  28. package/src/skills/flow/spec.md +149 -0
  29. package/src/skills/heuristics/accessibility.md +124 -0
  30. package/src/skills/heuristics/ads.md +140 -0
  31. package/src/skills/heuristics/auth.md +130 -0
  32. package/src/skills/heuristics/background-work.md +129 -0
  33. package/src/skills/heuristics/buttons.md +99 -0
  34. package/src/skills/heuristics/camera.md +127 -0
  35. package/src/skills/heuristics/chat.md +125 -0
  36. package/src/skills/heuristics/colors.md +129 -0
  37. package/src/skills/heuristics/copy.md +157 -0
  38. package/src/skills/heuristics/data-display.md +124 -0
  39. package/src/skills/heuristics/feedback.md +122 -0
  40. package/src/skills/heuristics/forms.md +124 -0
  41. package/src/skills/heuristics/icons-and-imagery.md +135 -0
  42. package/src/skills/heuristics/layout.md +125 -0
  43. package/src/skills/heuristics/lists.md +129 -0
  44. package/src/skills/heuristics/localization.md +128 -0
  45. package/src/skills/heuristics/maps.md +129 -0
  46. package/src/skills/heuristics/media.md +130 -0
  47. package/src/skills/heuristics/motion.md +113 -0
  48. package/src/skills/heuristics/navigation.md +116 -0
  49. package/src/skills/heuristics/network.md +118 -0
  50. package/src/skills/heuristics/notifications.md +121 -0
  51. package/src/skills/heuristics/offline.md +124 -0
  52. package/src/skills/heuristics/onboarding.md +103 -0
  53. package/src/skills/heuristics/payments.md +138 -0
  54. package/src/skills/heuristics/performance.md +111 -0
  55. package/src/skills/heuristics/permissions.md +125 -0
  56. package/src/skills/heuristics/privacy-ui.md +112 -0
  57. package/src/skills/heuristics/scrolling.md +114 -0
  58. package/src/skills/heuristics/search.md +127 -0
  59. package/src/skills/heuristics/sense.md +128 -0
  60. package/src/skills/heuristics/settings.md +129 -0
  61. package/src/skills/heuristics/sharing.md +102 -0
  62. package/src/skills/heuristics/sound.md +95 -0
  63. package/src/skills/heuristics/splashscreen.md +111 -0
  64. package/src/skills/heuristics/states.md +120 -0
  65. package/src/skills/heuristics/touch.md +95 -0
  66. package/src/skills/heuristics/typography.md +99 -0
  67. package/src/skills/heuristics/updates.md +129 -0
  68. package/src/skills/heuristics/webviews.md +114 -0
  69. package/src/skills/heuristics/widgets.md +128 -0
  70. package/src/skills/references/capability-checks.md +59 -0
  71. package/src/skills/references/fonts.json +339 -0
  72. package/src/skills/references/icon-and-image-assets.md +103 -0
  73. package/src/skills/references/input-fields.md +82 -0
  74. package/src/skills/references/launch-surface.md +94 -0
  75. package/src/skills/references/motion-tokens.md +89 -0
  76. package/src/skills/references/navigation-containers.md +51 -0
  77. package/src/skills/references/search-controls.md +49 -0
  78. package/src/skills/references/type-scales.md +60 -0
  79. package/src/skills/references/wireframe-frame.md +209 -0
@@ -0,0 +1,235 @@
1
+ import { tokenize, values } from './mdx.js';
2
+ /** Agent values a block may carry. Zed and anything else reads the "other" build. */
3
+ export const AGENTS = ['claude', 'codex', 'antigravity', 'opencode', 'other'];
4
+ /** Stack values a block may carry, in the order STACK.md may record them. */
5
+ export const STACKS = ['flutter', 'expo', 'react-native', 'swiftui', 'compose', 'web'];
6
+ /** Expo runs React Native, so it follows that branch when it has none of its own. */
7
+ const INHERITS = { expo: 'react-native' };
8
+ const STACK_LABELS = {
9
+ flutter: 'Flutter',
10
+ expo: 'Expo',
11
+ 'react-native': 'React Native',
12
+ swiftui: 'SwiftUI',
13
+ compose: 'Jetpack Compose',
14
+ web: 'Mobile web',
15
+ other: 'Any other stack',
16
+ };
17
+ /**
18
+ * How a question reaches the user, per harness. The compiler writes one of
19
+ * these in front of the question, so no flow file carries the mechanism.
20
+ */
21
+ const ASK = {
22
+ claude: (header) => `Ask this with the AskUserQuestion tool: \`header\` is "${header}", the options below are the options in that order, and the recommended one carries "(recommended)" at the end of its label. Wait for the answer before writing anything.`,
23
+ codex: (header) => `Ask this with the harness's question tool when the session exposes one, labelled "${header}", the options below in that order with the recommended one first and named as the recommendation. When it does not, ask in plain text ending in a question mark. Wait for the answer before writing anything.`,
24
+ antigravity: (header) => `Ask this with the harness's question tool when the session exposes one, labelled "${header}", the options below in that order with the recommended one first and named as the recommendation. When it does not, ask in plain text ending in a question mark. Wait for the answer before writing anything.`,
25
+ opencode: (header) => `Ask this in plain text, ending in a question mark, under the label "${header}", with the options below listed in that order and the recommended one named as the recommendation. Wait for the answer before writing anything.`,
26
+ other: (header) => `Ask this in plain text, ending in a question mark, under the label "${header}", with the options below listed in that order and the recommended one named as the recommendation. Wait for the answer before writing anything.`,
27
+ };
28
+ function matches(tag, target) {
29
+ const agent = values(tag.attributes['agent']);
30
+ if (agent.length > 0) {
31
+ return agent.includes(target.agent);
32
+ }
33
+ const stack = values(tag.attributes['stack']);
34
+ if (stack.includes('undecided')) {
35
+ return !target.stack;
36
+ }
37
+ if (!target.stack) {
38
+ return 'label';
39
+ }
40
+ if (stack.includes(target.stack)) {
41
+ return true;
42
+ }
43
+ const inherited = INHERITS[target.stack];
44
+ return inherited ? stack.includes(inherited) : false;
45
+ }
46
+ /**
47
+ * Sibling blocks, meaning a run of <If> blocks with nothing but blank lines
48
+ * between them. An "other" branch answers for its own run, not for the file.
49
+ */
50
+ function groupsOf(tokens) {
51
+ const groups = new Map();
52
+ let group = [];
53
+ let depth = 0;
54
+ const flush = () => {
55
+ for (const tag of group) {
56
+ groups.set(tag, group);
57
+ }
58
+ group = [];
59
+ };
60
+ for (const token of tokens) {
61
+ if (token.type === 'tag') {
62
+ if (token.tag.name !== 'If') {
63
+ continue;
64
+ }
65
+ if (token.tag.kind === 'open') {
66
+ if (depth === 0) {
67
+ group.push(token.tag);
68
+ }
69
+ depth += 1;
70
+ }
71
+ else if (token.tag.kind === 'close') {
72
+ depth = Math.max(0, depth - 1);
73
+ }
74
+ continue;
75
+ }
76
+ if (depth === 0 && token.text.trim() !== '' && group.length > 0) {
77
+ flush();
78
+ }
79
+ }
80
+ flush();
81
+ return groups;
82
+ }
83
+ /** True when nothing else in the run matched, so the "other" branch answers. */
84
+ function otherWins(group, tag, target) {
85
+ return !(group ?? [tag]).some((sibling) => sibling !== tag && matches(sibling, target) === true);
86
+ }
87
+ function label(kind, tag) {
88
+ const list = values(tag.attributes[kind]);
89
+ return list.map((value) => STACK_LABELS[value] ?? value).join(' and ');
90
+ }
91
+ function collapse(lines) {
92
+ const out = [];
93
+ let fence = false;
94
+ for (const line of lines) {
95
+ if (/^\s*(```|~~~)/.test(line)) {
96
+ fence = !fence;
97
+ }
98
+ if (!fence && line.trim() === '' && out.length > 0 && out[out.length - 1].trim() === '') {
99
+ continue;
100
+ }
101
+ out.push(line);
102
+ }
103
+ while (out.length > 0 && out[out.length - 1].trim() === '') {
104
+ out.pop();
105
+ }
106
+ return out;
107
+ }
108
+ /** One rule heading, as the shipped file spells it. */
109
+ function heading(tag) {
110
+ const id = tag.attributes['id'] ?? '';
111
+ const description = tag.attributes['description'] ?? '';
112
+ return `${tag.prefix}\`${id}\` ${description}`.trimEnd();
113
+ }
114
+ /**
115
+ * Turns one source file into the text a given agent reads. Every tag is either
116
+ * resolved or rendered as prose: nothing tagged survives into the output.
117
+ */
118
+ export function compile(source, target) {
119
+ const tokens = tokenize(source);
120
+ const groups = groupsOf(tokens);
121
+ const out = [];
122
+ let skipDepth = 0;
123
+ let ask = null;
124
+ for (const token of tokens) {
125
+ if (token.type === 'text') {
126
+ if (skipDepth > 0) {
127
+ continue;
128
+ }
129
+ if (ask) {
130
+ if (ask.current) {
131
+ ask.current.push(token.text);
132
+ }
133
+ else {
134
+ ask.question.push(token.text);
135
+ }
136
+ continue;
137
+ }
138
+ out.push(token.text);
139
+ continue;
140
+ }
141
+ const tag = token.tag;
142
+ if (tag.name === 'If') {
143
+ if (tag.kind === 'close') {
144
+ if (skipDepth > 0) {
145
+ skipDepth -= 1;
146
+ }
147
+ continue;
148
+ }
149
+ if (skipDepth > 0) {
150
+ skipDepth += 1;
151
+ continue;
152
+ }
153
+ const verdict = matches(tag, target);
154
+ if (verdict === 'label') {
155
+ out.push(`**${label('stack', tag)}**`, '');
156
+ continue;
157
+ }
158
+ const isOther = values(tag.attributes['agent']).includes('other') ||
159
+ values(tag.attributes['stack']).includes('other');
160
+ if (verdict === true || (isOther && otherWins(groups.get(tag), tag, target))) {
161
+ continue;
162
+ }
163
+ skipDepth = 1;
164
+ continue;
165
+ }
166
+ if (skipDepth > 0) {
167
+ continue;
168
+ }
169
+ if (tag.name === 'Rule') {
170
+ out.push(heading(tag));
171
+ continue;
172
+ }
173
+ if (tag.name === 'Index') {
174
+ continue;
175
+ }
176
+ if (tag.name === 'Check') {
177
+ if (tag.kind === 'open') {
178
+ const against = tag.attributes['against'] ?? 'line';
179
+ out.push('## Check', '', `Review answers each of these against the code, pointing at the ${against}:`, '');
180
+ }
181
+ continue;
182
+ }
183
+ if (tag.name === 'Verify') {
184
+ out.push(`- ${tag.content} \`${tag.attributes['rule'] ?? ''}\``);
185
+ continue;
186
+ }
187
+ if (tag.name === 'Device') {
188
+ out.push('', tag.content);
189
+ continue;
190
+ }
191
+ if (tag.name === 'Ask') {
192
+ if (tag.kind === 'open') {
193
+ ask = {
194
+ header: tag.attributes['header'] ?? '',
195
+ question: [],
196
+ options: [],
197
+ current: null,
198
+ recommended: false,
199
+ };
200
+ continue;
201
+ }
202
+ if (ask) {
203
+ const lead = (ASK[target.agent] ?? ASK['other'])(ask.header);
204
+ out.push(...collapse(ask.question), '', lead, '', ...ask.options);
205
+ ask = null;
206
+ }
207
+ continue;
208
+ }
209
+ if (tag.name === 'Option' && ask) {
210
+ if (tag.kind === 'open') {
211
+ ask.current = [];
212
+ ask.recommended = tag.flags.includes('recommended');
213
+ continue;
214
+ }
215
+ const text = collapse(ask.current ?? [])
216
+ .map((line) => line.trim())
217
+ .filter(Boolean)
218
+ .join(' ');
219
+ ask.options.push(`- ${text}${ask.recommended ? ' (recommended)' : ''}`);
220
+ ask.current = null;
221
+ ask.recommended = false;
222
+ continue;
223
+ }
224
+ }
225
+ return `${collapse(out).join('\n')}\n`;
226
+ }
227
+ /**
228
+ * A rule heading is the one tag that carries content rather than a decision, so
229
+ * a file with nothing but headings still differs between variants only through
230
+ * the blocks around it. Every stack gets a copy regardless: the entry file names
231
+ * the stack it was resolved for, and an undecided block is dropped from it.
232
+ */
233
+ export function variantStacks() {
234
+ return [...STACKS];
235
+ }
@@ -0,0 +1,208 @@
1
+ import { finding, lines, occurrences } from './types.js';
2
+ const DART = ['.dart'];
3
+ const XML = ['.xml'];
4
+ const BOTH = [...DART, ...XML];
5
+ /** Anything in the file that makes a widget pressable. */
6
+ const PRESSABLE = /GestureDetector|InkWell|IconButton|TextButton|ElevatedButton|OutlinedButton|FilledButton|onTap:|onPressed:/;
7
+ const SCREEN = /Scaffold|CupertinoPageScaffold/;
8
+ /** A literal size in a Flutter text style, which the type scale should own. */
9
+ const detectTypeScale = {
10
+ rule: 'type-scale',
11
+ extensions: BOTH,
12
+ run(file, source) {
13
+ const found = [];
14
+ for (const { line, text } of lines(source)) {
15
+ if (file.endsWith('.dart') && /\bfontSize:\s*\d/.test(text)) {
16
+ found.push(finding('type-scale', file, line, text, 'a literal font size, where a text style should carry it'));
17
+ }
18
+ const android = /android:textSize="(\d+(?:\.\d+)?)(dp|px|sp)"/.exec(text);
19
+ if (android && android[2] !== 'sp') {
20
+ found.push(finding('type-scale', file, line, text, `text sized in ${android[2]}, which does not follow the user's setting`));
21
+ }
22
+ }
23
+ return found;
24
+ },
25
+ };
26
+ /** A pressable drawn smaller than the platform floor. */
27
+ const detectTouchFloor = {
28
+ rule: 'touch-floor',
29
+ extensions: BOTH,
30
+ run(file, source) {
31
+ const found = [];
32
+ const pressable = PRESSABLE.test(source);
33
+ for (const { line, text } of lines(source)) {
34
+ if (file.endsWith('.dart') && pressable) {
35
+ for (const match of text.matchAll(/\b(width|height|size|minWidth|minHeight):\s*(\d+(?:\.\d+)?)/g)) {
36
+ const value = Number(match[2]);
37
+ if (value > 0 && value < 48) {
38
+ found.push(finding('touch-floor', file, line, text, `${match[1]} of ${value} on a file that draws a pressable, under the 48dp floor`));
39
+ }
40
+ }
41
+ }
42
+ const android = /android:(minWidth|minHeight|layout_width|layout_height)="(\d+(?:\.\d+)?)dp"/.exec(text);
43
+ if (android && Number(android[2]) > 0 && Number(android[2]) < 48 && /clickable="true"|Button/.test(source)) {
44
+ found.push(finding('touch-floor', file, line, text, `${android[1]} of ${android[2]}dp on a clickable view, under the 48dp floor`));
45
+ }
46
+ }
47
+ return found;
48
+ },
49
+ };
50
+ /** A screen that never reads an inset. */
51
+ const detectLayoutInsets = {
52
+ rule: 'layout-insets',
53
+ extensions: DART,
54
+ run(file, source) {
55
+ if (!SCREEN.test(source)) {
56
+ return [];
57
+ }
58
+ if (/SafeArea|viewPadding|viewInsets|MediaQuery\.(of\(context\)\.)?padding|padding\.of\(context\)/.test(source)) {
59
+ return [];
60
+ }
61
+ const at = lines(source).find(({ text }) => SCREEN.test(text));
62
+ return [
63
+ finding('layout-insets', file, at?.line ?? 0, at?.text ?? '', 'a screen with no inset read anywhere in the file'),
64
+ ];
65
+ },
66
+ };
67
+ /** An icon-only control with nothing to announce. */
68
+ const detectA11yName = {
69
+ rule: 'a11y-name',
70
+ extensions: BOTH,
71
+ run(file, source) {
72
+ const found = [];
73
+ if (file.endsWith('.dart')) {
74
+ for (const { line, text, window } of occurrences(source, /IconButton\(/)) {
75
+ if (!/tooltip:/.test(window) && !/Semantics\(|semanticsLabel|semanticLabel/.test(window)) {
76
+ found.push(finding('a11y-name', file, line, text, 'an IconButton with neither a tooltip nor a Semantics label'));
77
+ }
78
+ }
79
+ }
80
+ for (const { line, text, window } of occurrences(source, /<ImageButton|<ImageView/, 400)) {
81
+ if (file.endsWith('.xml') && !/android:contentDescription/.test(window)) {
82
+ found.push(finding('a11y-name', file, line, text, 'an image control with no contentDescription on it'));
83
+ }
84
+ }
85
+ return found;
86
+ },
87
+ };
88
+ /** A field that arrives with the wrong keyboard and no autofill. */
89
+ const detectFormInput = {
90
+ rule: 'form-input',
91
+ extensions: BOTH,
92
+ run(file, source) {
93
+ const found = [];
94
+ if (file.endsWith('.dart')) {
95
+ for (const { line, text, window } of occurrences(source, /\bTextF(?:ield|ormField)\(/)) {
96
+ if (!/keyboardType:/.test(window)) {
97
+ found.push(finding('form-input', file, line, text, 'a text field with no keyboardType, so every field opens the same keyboard'));
98
+ }
99
+ }
100
+ }
101
+ for (const { line, text, window } of occurrences(source, /<EditText/, 400)) {
102
+ if (file.endsWith('.xml') && !/android:inputType/.test(window)) {
103
+ found.push(finding('form-input', file, line, text, 'an EditText with no inputType'));
104
+ }
105
+ }
106
+ return found;
107
+ },
108
+ };
109
+ /** A field that the platform cannot fill. */
110
+ const detectFormAutofill = {
111
+ rule: 'form-autofill',
112
+ extensions: DART,
113
+ run(file, source) {
114
+ const found = [];
115
+ for (const { line, text, window } of occurrences(source, /\bTextF(?:ield|ormField)\(/)) {
116
+ if (!/autofillHints:/.test(window)) {
117
+ found.push(finding('form-autofill', file, line, text, 'a text field with no autofillHints, which the platform cannot fill'));
118
+ }
119
+ }
120
+ return found;
121
+ },
122
+ };
123
+ /** A client built without a deadline. */
124
+ const detectNetTimeout = {
125
+ rule: 'net-timeout',
126
+ extensions: DART,
127
+ run(file, source) {
128
+ const found = [];
129
+ const configured = /connectTimeout|receiveTimeout|sendTimeout|\.timeout\(/.test(source);
130
+ for (const { line, text } of lines(source)) {
131
+ if (/\b(http\.Client\(\)|Dio\(|IOClient\()/.test(text) && !configured) {
132
+ found.push(finding('net-timeout', file, line, text, 'an HTTP client with no timeout anywhere in the file'));
133
+ }
134
+ if (/\bawait\s+http\.(get|post|put|delete|patch)\(/.test(text) && !/\.timeout\(/.test(text) && !configured) {
135
+ found.push(finding('net-timeout', file, line, text, 'a request with no deadline on it'));
136
+ }
137
+ }
138
+ return found;
139
+ },
140
+ };
141
+ /** Text the catalogue never saw. */
142
+ const detectL10nStrings = {
143
+ rule: 'l10n-strings',
144
+ extensions: DART,
145
+ run(file, source) {
146
+ const found = [];
147
+ for (const { line, text } of lines(source)) {
148
+ const match = /\b(Text|SelectableText)\(\s*(['"])([^'"]{4,})\2/.exec(text);
149
+ if (!match) {
150
+ continue;
151
+ }
152
+ const literal = match[3];
153
+ if (!/[a-zA-Z]{3}/.test(literal) || /^\$/.test(literal)) {
154
+ continue;
155
+ }
156
+ found.push(finding('l10n-strings', file, line, text, `"${literal.slice(0, 40)}" is user-facing text written in code`));
157
+ }
158
+ return found;
159
+ },
160
+ };
161
+ /** Motion that never reads the system setting. */
162
+ const detectMotionReduced = {
163
+ rule: 'motion-reduced',
164
+ extensions: DART,
165
+ run(file, source) {
166
+ if (!/AnimationController|AnimatedContainer|TweenAnimationBuilder|\.animate\(/.test(source)) {
167
+ return [];
168
+ }
169
+ if (/disableAnimations|reduceMotion|accessibleNavigation/.test(source)) {
170
+ return [];
171
+ }
172
+ const at = lines(source).find(({ text }) => /AnimationController|AnimatedContainer|TweenAnimationBuilder|\.animate\(/.test(text));
173
+ return [
174
+ finding('motion-reduced', file, at?.line ?? 0, at?.text ?? '', 'animation with no read of the reduced-motion setting in the file'),
175
+ ];
176
+ },
177
+ };
178
+ /** A list built without recycling. */
179
+ const detectListVirtualise = {
180
+ rule: 'list-virtualise',
181
+ extensions: DART,
182
+ run(file, source) {
183
+ const found = [];
184
+ for (const { line, text, window } of occurrences(source, /\b(?:Column|ListBody)\(/)) {
185
+ if (/\.\.\.\w+\.map\(|\.\.\.\w+\.entries|for \(final/.test(window)) {
186
+ found.push(finding('list-virtualise', file, line, text, 'rows expanded into a Column, which builds every one of them'));
187
+ }
188
+ }
189
+ for (const { line, text, window } of occurrences(source, /\bListView\(/, 120)) {
190
+ if (/children:/.test(window)) {
191
+ found.push(finding('list-virtualise', file, line, text, 'a ListView with a children list rather than a builder'));
192
+ }
193
+ }
194
+ return found;
195
+ },
196
+ };
197
+ export const DETECTORS = [
198
+ detectTypeScale,
199
+ detectTouchFloor,
200
+ detectLayoutInsets,
201
+ detectA11yName,
202
+ detectFormInput,
203
+ detectFormAutofill,
204
+ detectNetTimeout,
205
+ detectL10nStrings,
206
+ detectMotionReduced,
207
+ detectListVirtualise,
208
+ ];
@@ -0,0 +1,36 @@
1
+ /** Lines with their 1-based numbers, with block comments already dropped. */
2
+ export function lines(source) {
3
+ return source
4
+ .replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, ' '))
5
+ .split(/\r?\n/)
6
+ .map((text, index) => ({ line: index + 1, text }))
7
+ .filter(({ text }) => !/^\s*(\/\/|#|<!--)/.test(text));
8
+ }
9
+ /**
10
+ * Every match of a pattern, with the line it sits on and the text that follows
11
+ * it. Widgets span lines, so a per-line test calls a labelled control unlabelled
12
+ * whenever the label is on the next line.
13
+ */
14
+ export function occurrences(source, pattern, span = 600) {
15
+ const clean = source.replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, ' '));
16
+ const found = [];
17
+ const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
18
+ for (const match of clean.matchAll(global)) {
19
+ const index = match.index ?? 0;
20
+ const before = clean.slice(0, index);
21
+ const start = before.lastIndexOf('\n') + 1;
22
+ const text = clean.slice(start, clean.indexOf('\n', index) === -1 ? undefined : clean.indexOf('\n', index));
23
+ if (/^\s*(\/\/|#|<!--)/.test(text)) {
24
+ continue;
25
+ }
26
+ found.push({
27
+ line: before.split('\n').length,
28
+ text,
29
+ window: clean.slice(index, index + span),
30
+ });
31
+ }
32
+ return found;
33
+ }
34
+ export function finding(rule, file, line, text, message) {
35
+ return { rule, file, line, snippet: text.trim().slice(0, 160), message };
36
+ }
package/dist/emit.js ADDED
@@ -0,0 +1,77 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
+ import { compile } from './compile.js';
4
+ import { packagedSkillDir, variantName } from './paths.js';
5
+ import { isSkillFile } from './skill.js';
6
+ const STACK_LABELS = {
7
+ flutter: 'Flutter',
8
+ expo: 'Expo',
9
+ 'react-native': 'React Native',
10
+ swiftui: 'SwiftUI',
11
+ compose: 'Jetpack Compose',
12
+ web: 'mobile web',
13
+ };
14
+ /** Every file of the source skill, project-relative, in a stable order. */
15
+ export async function listSkillFiles(root = packagedSkillDir) {
16
+ const found = [];
17
+ async function walk(dir) {
18
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
19
+ if (!isSkillFile(entry.name)) {
20
+ continue;
21
+ }
22
+ const full = join(dir, entry.name);
23
+ if (entry.isDirectory()) {
24
+ await walk(full);
25
+ }
26
+ else if (entry.isFile()) {
27
+ found.push(relative(root, full).split(sep).join('/'));
28
+ }
29
+ }
30
+ }
31
+ await walk(root);
32
+ return found.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
33
+ }
34
+ /** The written skill, read once and reused for every variant. */
35
+ export async function readSources(root = packagedSkillDir) {
36
+ const sources = new Map();
37
+ for (const file of await listSkillFiles(root)) {
38
+ sources.set(file, await readFile(join(root, file)));
39
+ }
40
+ return sources;
41
+ }
42
+ /**
43
+ * The entry file names the skill, and a stack variant is a different skill with
44
+ * a different name, or an agent would see several skills claiming one id.
45
+ */
46
+ function retitle(source, stack) {
47
+ if (!stack) {
48
+ return source;
49
+ }
50
+ const label = STACK_LABELS[stack] ?? stack;
51
+ return source
52
+ .replace(/^name:\s*(.+)$/m, (_, name) => `name: ${variantName(name.trim(), stack)}`)
53
+ .replace(/^description:\s*(.+)$/m, (_, text) => `description: ${text.trim().replace(/for any stack \([^)]*\)/, `in ${label}`)} Install this copy only in a ${label} project.`);
54
+ }
55
+ /** One variant, resolved in memory. Markdown is compiled, anything else copied. */
56
+ export function resolve(sources, target) {
57
+ const out = new Map();
58
+ for (const [file, content] of sources) {
59
+ if (!file.endsWith('.md')) {
60
+ out.set(file, content);
61
+ continue;
62
+ }
63
+ const compiled = compile(content.toString('utf8'), target);
64
+ out.set(file, Buffer.from(file === 'SKILL.md' ? retitle(compiled, target.stack) : compiled, 'utf8'));
65
+ }
66
+ return out;
67
+ }
68
+ /** Writes a resolved variant to disk, replacing whatever was there. */
69
+ export async function write(files, destination) {
70
+ await rm(destination, { recursive: true, force: true });
71
+ for (const [file, content] of files) {
72
+ const path = join(destination, ...file.split('/'));
73
+ await mkdir(dirname(path), { recursive: true });
74
+ await writeFile(path, content);
75
+ }
76
+ return files.size;
77
+ }