phyll 0.4.1
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/LICENSE +21 -0
- package/README.md +44 -0
- package/bin/phyll.mjs +6 -0
- package/package.json +42 -0
- package/skill/data/tells.json +1641 -0
- package/skill/schema/config.schema.json +51 -0
- package/skill/schema/tells.schema.json +156 -0
- package/skill/scripts/capture.mjs +224 -0
- package/skill/scripts/lib/cli.mjs +16 -0
- package/skill/scripts/lib/config.mjs +19 -0
- package/skill/scripts/lib/detectors.mjs +301 -0
- package/skill/scripts/lib/files.mjs +134 -0
- package/skill/scripts/lib/score.mjs +51 -0
- package/skill/scripts/lib/structure.mjs +287 -0
- package/skill/scripts/lib/version.mjs +3 -0
- package/skill/scripts/probe.js +384 -0
- package/skill/scripts/scan.mjs +175 -0
- package/src/browser.mjs +317 -0
- package/src/cli.mjs +165 -0
- package/src/credentials.mjs +40 -0
- package/src/engine.mjs +40 -0
- package/src/mcp.mjs +145 -0
- package/src/paths.mjs +19 -0
- package/src/review.mjs +226 -0
- package/src/setup.mjs +69 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Turns the detectors declared in tells.json into matches over source files.
|
|
2
|
+
// Detector kinds: regex, phrase, classCombo (utility-class lists) and metric (from the structure scan).
|
|
3
|
+
|
|
4
|
+
const ALL_CATEGORIES = ["markup", "script", "style"];
|
|
5
|
+
const DEFAULT_CATEGORIES = {
|
|
6
|
+
regex: ALL_CATEGORIES,
|
|
7
|
+
phrase: ["markup", "script"],
|
|
8
|
+
classCombo: ALL_CATEGORIES,
|
|
9
|
+
metric: [],
|
|
10
|
+
};
|
|
11
|
+
export const MAX_LOCATIONS = 10;
|
|
12
|
+
|
|
13
|
+
// ---------- lines ----------
|
|
14
|
+
|
|
15
|
+
export function makeLineIndex(text) {
|
|
16
|
+
const starts = [0];
|
|
17
|
+
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1);
|
|
18
|
+
return (index) => {
|
|
19
|
+
let lo = 0;
|
|
20
|
+
let hi = starts.length - 1;
|
|
21
|
+
while (lo < hi) {
|
|
22
|
+
const mid = (lo + hi + 1) >> 1;
|
|
23
|
+
if (starts[mid] <= index) lo = mid;
|
|
24
|
+
else hi = mid - 1;
|
|
25
|
+
}
|
|
26
|
+
return lo + 1;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function lineAt(text, index) {
|
|
31
|
+
return makeLineIndex(text)(index);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------- string literal reading ----------
|
|
35
|
+
|
|
36
|
+
// Returns the index of the closing quote, or the index before a newline for unterminated strings.
|
|
37
|
+
function skipQuoted(text, start, quote) {
|
|
38
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
39
|
+
const c = text[i];
|
|
40
|
+
if (c === "\\") {
|
|
41
|
+
i++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (c === quote) return i;
|
|
45
|
+
if (c === "\n") return i - 1;
|
|
46
|
+
}
|
|
47
|
+
return text.length - 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Reads a template literal that starts at text[start] === "`".
|
|
51
|
+
// Collects its static parts and any string literals inside ${...}, in order.
|
|
52
|
+
function readTemplate(text, start) {
|
|
53
|
+
const parts = [];
|
|
54
|
+
let current = "";
|
|
55
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
56
|
+
const c = text[i];
|
|
57
|
+
if (c === "\\") {
|
|
58
|
+
current += text[i + 1] ?? "";
|
|
59
|
+
i++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (c === "`") {
|
|
63
|
+
parts.push(current);
|
|
64
|
+
return { end: i, parts };
|
|
65
|
+
}
|
|
66
|
+
if (c === "$" && text[i + 1] === "{") {
|
|
67
|
+
parts.push(current);
|
|
68
|
+
current = "";
|
|
69
|
+
const inner = readBalanced(text, i + 1, "{", "}");
|
|
70
|
+
parts.push(...inner.strings);
|
|
71
|
+
i = inner.end;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
current += c;
|
|
75
|
+
}
|
|
76
|
+
parts.push(current);
|
|
77
|
+
return { end: text.length - 1, parts };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Reads from text[start] === open to its matching close, collecting string literal contents.
|
|
81
|
+
function readBalanced(text, start, open, close) {
|
|
82
|
+
let depth = 0;
|
|
83
|
+
const strings = [];
|
|
84
|
+
for (let i = start; i < text.length; i++) {
|
|
85
|
+
const c = text[i];
|
|
86
|
+
if (c === '"' || c === "'") {
|
|
87
|
+
const end = skipQuoted(text, i, c);
|
|
88
|
+
strings.push(text.slice(i + 1, end));
|
|
89
|
+
i = end;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (c === "`") {
|
|
93
|
+
const { end, parts } = readTemplate(text, i);
|
|
94
|
+
strings.push(...parts);
|
|
95
|
+
i = end;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (c === open) depth++;
|
|
99
|
+
else if (c === close) {
|
|
100
|
+
depth--;
|
|
101
|
+
if (depth === 0) return { end: i, strings };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { end: text.length - 1, strings };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const tokenize = (s) => s.split(/\s+/).filter(Boolean);
|
|
108
|
+
|
|
109
|
+
// ---------- class lists ----------
|
|
110
|
+
|
|
111
|
+
const CLASS_ATTR = /(?<![\w$.-])(?::class|v-bind:class|\[class\]|\[ngClass\]|className|class)\s*=\s*/g;
|
|
112
|
+
const CLASS_CALL = /(?<![\w$.])(?:cn|clsx|classnames|classNames|twMerge|twJoin|cva|tv|cx)\s*\(/g;
|
|
113
|
+
const APPLY = /@apply\s+([^;{}]+);/g;
|
|
114
|
+
const LOOSE_LITERAL = /"([^"\n]*)"|'([^'\n]*)'/g;
|
|
115
|
+
const UTILITY_TOKEN = /^!?(?:[a-z0-9:_\-/.%#&>~*]|\[[^\]\s]*\])+$/;
|
|
116
|
+
|
|
117
|
+
function looksLikeClassList(content) {
|
|
118
|
+
const tokens = tokenize(content);
|
|
119
|
+
if (tokens.length < 2) return false;
|
|
120
|
+
if (!tokens.some((t) => t.includes("-"))) return false;
|
|
121
|
+
return tokens.every((t) => UTILITY_TOKEN.test(t));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Returns [{ index, classes }] for every class list found in the text, in source order.
|
|
125
|
+
// category "style" only reads @apply; other categories read attributes, class helper calls,
|
|
126
|
+
// @apply inside embedded style blocks, and loose string literals that look like utility lists.
|
|
127
|
+
export function extractClassLists(text, category = "markup") {
|
|
128
|
+
const lists = [];
|
|
129
|
+
const consumed = [];
|
|
130
|
+
const inConsumed = (index) => consumed.some(([a, b]) => index >= a && index <= b);
|
|
131
|
+
|
|
132
|
+
if (category !== "style") {
|
|
133
|
+
for (const m of text.matchAll(CLASS_ATTR)) {
|
|
134
|
+
const pos = m.index + m[0].length;
|
|
135
|
+
const c = text[pos];
|
|
136
|
+
if (c === '"' || c === "'") {
|
|
137
|
+
const end = skipQuoted(text, pos, c);
|
|
138
|
+
lists.push({ index: pos, classes: tokenize(text.slice(pos + 1, end)) });
|
|
139
|
+
consumed.push([m.index, end]);
|
|
140
|
+
} else if (c === "{") {
|
|
141
|
+
const { end, strings } = readBalanced(text, pos, "{", "}");
|
|
142
|
+
lists.push({ index: pos, classes: tokenize(strings.join(" ")) });
|
|
143
|
+
consumed.push([m.index, end]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const m of text.matchAll(CLASS_CALL)) {
|
|
147
|
+
if (inConsumed(m.index)) continue;
|
|
148
|
+
const open = m.index + m[0].length - 1;
|
|
149
|
+
const { end, strings } = readBalanced(text, open, "(", ")");
|
|
150
|
+
lists.push({ index: m.index, classes: tokenize(strings.join(" ")) });
|
|
151
|
+
consumed.push([m.index, end]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const m of text.matchAll(APPLY)) {
|
|
156
|
+
lists.push({ index: m.index, classes: tokenize(m[1]) });
|
|
157
|
+
consumed.push([m.index, m.index + m[0].length]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (category !== "style") {
|
|
161
|
+
for (const m of text.matchAll(LOOSE_LITERAL)) {
|
|
162
|
+
if (inConsumed(m.index)) continue;
|
|
163
|
+
const content = m[1] ?? m[2] ?? "";
|
|
164
|
+
if (looksLikeClassList(content)) lists.push({ index: m.index, classes: tokenize(content) });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return lists
|
|
169
|
+
.filter((l) => l.classes.length > 0)
|
|
170
|
+
.sort((a, b) => a.index - b.index);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------- detectors ----------
|
|
174
|
+
|
|
175
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
176
|
+
|
|
177
|
+
function uniqueFlags(extra = "") {
|
|
178
|
+
return [...new Set(("gu" + extra).split(""))].join("");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The regular expressions here always carry the g flag, which matchAll requires.
|
|
182
|
+
function allMatches(re, text) {
|
|
183
|
+
return Array.from(text.matchAll(re), (m) => ({ index: m.index, match: m[0] }));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const TYPOGRAPHIC_APOSTROPHE = String.fromCharCode(0x2019);
|
|
187
|
+
|
|
188
|
+
function phraseSource(phrase) {
|
|
189
|
+
return escapeRegExp(phrase.trim())
|
|
190
|
+
.replace(/\s+/g, "\\s+")
|
|
191
|
+
.replace(/'/g, `['${TYPOGRAPHIC_APOSTROPHE}]`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function compileDetector(detector) {
|
|
195
|
+
const compiled = compileKind(detector);
|
|
196
|
+
compiled.skipIf = new Set(detector.skipIf ?? []);
|
|
197
|
+
return compiled;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// True when a project trait from the structure scan switches the detector off.
|
|
201
|
+
function skipped(det, structure) {
|
|
202
|
+
return det.skipIf.has("darkTheme") && structure?.theme === "dark";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function compileKind(detector) {
|
|
206
|
+
const categories = new Set(detector.in ?? DEFAULT_CATEGORIES[detector.kind] ?? ALL_CATEGORIES);
|
|
207
|
+
switch (detector.kind) {
|
|
208
|
+
case "regex": {
|
|
209
|
+
const re = new RegExp(detector.pattern, uniqueFlags(detector.flags));
|
|
210
|
+
return { kind: "regex", categories, run: (text) => allMatches(re, text) };
|
|
211
|
+
}
|
|
212
|
+
case "phrase": {
|
|
213
|
+
const alternatives = detector.phrases.map(phraseSource).join("|");
|
|
214
|
+
const re = new RegExp(`(?<![\\p{L}\\p{N}])(?:${alternatives})(?![\\p{L}\\p{N}])`, "giu");
|
|
215
|
+
return { kind: "phrase", categories, run: (text) => allMatches(re, text) };
|
|
216
|
+
}
|
|
217
|
+
case "classCombo": {
|
|
218
|
+
const all = detector.all.map((p) => new RegExp(p, "u"));
|
|
219
|
+
const none = (detector.none ?? []).map((p) => new RegExp(p, "u"));
|
|
220
|
+
const run = (text, lists = extractClassLists(text)) =>
|
|
221
|
+
lists
|
|
222
|
+
.filter(
|
|
223
|
+
(l) =>
|
|
224
|
+
all.every((re) => l.classes.some((c) => re.test(c))) &&
|
|
225
|
+
!none.some((re) => l.classes.some((c) => re.test(c))),
|
|
226
|
+
)
|
|
227
|
+
.map((l) => ({ index: l.index, match: l.classes.join(" ") }));
|
|
228
|
+
return { kind: "classCombo", categories, run };
|
|
229
|
+
}
|
|
230
|
+
case "metric":
|
|
231
|
+
return { kind: "metric", categories, metric: detector.metric, min: detector.min };
|
|
232
|
+
default:
|
|
233
|
+
throw new Error(`Unknown detector kind: ${detector.kind}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const clip = (s) => s.replace(/\s+/g, " ").trim().slice(0, 100);
|
|
238
|
+
|
|
239
|
+
function metricLocations(detector, structure) {
|
|
240
|
+
if (!structure) return [];
|
|
241
|
+
if (detector.metric === "longForms") {
|
|
242
|
+
const min = detector.min ?? 7;
|
|
243
|
+
return (structure.forms ?? [])
|
|
244
|
+
.filter((f) => f.fields >= min)
|
|
245
|
+
.map((f) => ({ file: f.file, line: f.line, match: `form with ${f.fields} fields` }));
|
|
246
|
+
}
|
|
247
|
+
if (detector.metric === "modals") {
|
|
248
|
+
return (structure.modals?.locations ?? []).map((l) => ({ file: l.file, line: l.line, match: l.match }));
|
|
249
|
+
}
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Runs the static detectors of every tell over the files.
|
|
254
|
+
// readText(file) returns the file content; structure comes from structure.mjs and feeds metric detectors.
|
|
255
|
+
export function runTells(tells, files, readText, structure = null) {
|
|
256
|
+
const compiled = tells
|
|
257
|
+
.filter((t) => (t.detectors ?? []).length > 0)
|
|
258
|
+
.map((t) => ({ tell: t, detectors: t.detectors.map(compileDetector) }));
|
|
259
|
+
const results = new Map(compiled.map(({ tell }) => [tell.id, { id: tell.id, hits: 0, locations: [] }]));
|
|
260
|
+
|
|
261
|
+
const record = (id, locations) => {
|
|
262
|
+
const result = results.get(id);
|
|
263
|
+
result.hits += locations.length;
|
|
264
|
+
for (const loc of locations) {
|
|
265
|
+
if (result.locations.length >= MAX_LOCATIONS) break;
|
|
266
|
+
result.locations.push(loc);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
for (const file of files) {
|
|
271
|
+
const text = readText(file);
|
|
272
|
+
const lineOf = makeLineIndex(text);
|
|
273
|
+
let lists = null;
|
|
274
|
+
for (const { tell, detectors } of compiled) {
|
|
275
|
+
for (const det of detectors) {
|
|
276
|
+
if (det.kind === "metric" || !det.categories.has(file.category) || skipped(det, structure)) continue;
|
|
277
|
+
let matches;
|
|
278
|
+
if (det.kind === "classCombo") {
|
|
279
|
+
lists ??= extractClassLists(text, file.category);
|
|
280
|
+
matches = det.run(text, lists);
|
|
281
|
+
} else {
|
|
282
|
+
matches = det.run(text);
|
|
283
|
+
}
|
|
284
|
+
if (matches.length) {
|
|
285
|
+
record(
|
|
286
|
+
tell.id,
|
|
287
|
+
matches.map((m) => ({ file: file.rel, line: lineOf(m.index), match: clip(m.match) })),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const { tell, detectors } of compiled) {
|
|
295
|
+
for (const det of detectors) {
|
|
296
|
+
if (det.kind === "metric") record(tell.id, metricLocations(det, structure));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return [...results.values()];
|
|
301
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Walks a project and returns the UI source files Phyll reads.
|
|
2
|
+
import { readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { extname, join, relative, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const CATEGORY_BY_EXT = Object.freeze({
|
|
6
|
+
".html": "markup",
|
|
7
|
+
".htm": "markup",
|
|
8
|
+
".jsx": "markup",
|
|
9
|
+
".tsx": "markup",
|
|
10
|
+
".vue": "markup",
|
|
11
|
+
".svelte": "markup",
|
|
12
|
+
".astro": "markup",
|
|
13
|
+
".mdx": "markup",
|
|
14
|
+
".js": "script",
|
|
15
|
+
".ts": "script",
|
|
16
|
+
".mjs": "script",
|
|
17
|
+
".cjs": "script",
|
|
18
|
+
".css": "style",
|
|
19
|
+
".scss": "style",
|
|
20
|
+
".sass": "style",
|
|
21
|
+
".less": "style",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Folders that hold dependencies or build output. Phyll never enters them.
|
|
25
|
+
// Any folder whose name starts with a dot is skipped as well (.git, .next, .svelte-kit, .phyll).
|
|
26
|
+
const SKIPPED_DIRS = new Set([
|
|
27
|
+
"node_modules",
|
|
28
|
+
"bower_components",
|
|
29
|
+
"dist",
|
|
30
|
+
"build",
|
|
31
|
+
"out",
|
|
32
|
+
"coverage",
|
|
33
|
+
"vendor",
|
|
34
|
+
"storybook-static",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Files that exist in most projects but say nothing about the product's own interface.
|
|
38
|
+
// components/ui holds copied library primitives (shadcn/ui and similar); counting their
|
|
39
|
+
// rounded corners and shadows would flag every project that uses such a kit.
|
|
40
|
+
export const DEFAULT_IGNORE = Object.freeze([
|
|
41
|
+
"**/components/ui/**",
|
|
42
|
+
"**/*.test.*",
|
|
43
|
+
"**/*.spec.*",
|
|
44
|
+
"**/__tests__/**",
|
|
45
|
+
"**/__mocks__/**",
|
|
46
|
+
"**/*.stories.*",
|
|
47
|
+
"**/*.min.*",
|
|
48
|
+
"**/*.d.ts",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const SPECIAL = new Set(["\\", "^", "$", ".", "|", "+", "(", ")", "[", "]", "{", "}"]);
|
|
52
|
+
|
|
53
|
+
// Converts a glob to a RegExp over forward-slash relative paths.
|
|
54
|
+
// ** crosses folders, * stays inside one folder, ? is one character.
|
|
55
|
+
export function globToRegExp(glob) {
|
|
56
|
+
let re = "";
|
|
57
|
+
for (let i = 0; i < glob.length; i++) {
|
|
58
|
+
const c = glob[i];
|
|
59
|
+
if (c === "*") {
|
|
60
|
+
if (glob[i + 1] === "*") {
|
|
61
|
+
if (glob[i + 2] === "/") {
|
|
62
|
+
re += "(?:.*/)?";
|
|
63
|
+
i += 2;
|
|
64
|
+
} else {
|
|
65
|
+
re += ".*";
|
|
66
|
+
i += 1;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
re += "[^/]*";
|
|
70
|
+
}
|
|
71
|
+
} else if (c === "?") {
|
|
72
|
+
re += "[^/]";
|
|
73
|
+
} else if (SPECIAL.has(c)) {
|
|
74
|
+
re += "\\" + c;
|
|
75
|
+
} else {
|
|
76
|
+
re += c;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return new RegExp("^" + re + "$");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A pattern without wildcards is treated as a path prefix, so "src/legacy" ignores the folder.
|
|
83
|
+
function toMatcher(pattern) {
|
|
84
|
+
const clean = pattern.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
85
|
+
if (!/[*?]/.test(clean)) return (rel) => rel === clean || rel.startsWith(clean + "/");
|
|
86
|
+
const re = globToRegExp(clean);
|
|
87
|
+
return (rel) => re.test(rel);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function toRel(root, abs) {
|
|
91
|
+
return relative(root, abs).split(sep).join("/");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function walk(root, { ignore = [], maxBytes = 512 * 1024 } = {}) {
|
|
95
|
+
const matchers = [...DEFAULT_IGNORE, ...ignore].map(toMatcher);
|
|
96
|
+
const files = [];
|
|
97
|
+
let ignored = 0;
|
|
98
|
+
|
|
99
|
+
const visit = (dir) => {
|
|
100
|
+
let entries;
|
|
101
|
+
try {
|
|
102
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
103
|
+
} catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (entry.isSymbolicLink()) continue;
|
|
108
|
+
const abs = join(dir, entry.name);
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
if (entry.name.startsWith(".") || SKIPPED_DIRS.has(entry.name)) continue;
|
|
111
|
+
visit(abs);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!entry.isFile()) continue;
|
|
115
|
+
const ext = extname(entry.name).toLowerCase();
|
|
116
|
+
const category = CATEGORY_BY_EXT[ext];
|
|
117
|
+
if (!category) continue;
|
|
118
|
+
const rel = toRel(root, abs);
|
|
119
|
+
if (matchers.some((m) => m(rel))) {
|
|
120
|
+
ignored++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (statSync(abs).size > maxBytes) {
|
|
124
|
+
ignored++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
files.push({ abs, rel, ext, category });
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
visit(root);
|
|
132
|
+
files.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
133
|
+
return { files, ignored };
|
|
134
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The AI tell index: a weighted share of the catalog's tells that are present, from 0 to 100.
|
|
2
|
+
// Each tell counts up to its weight. It reaches full weight once its hits reach its cap,
|
|
3
|
+
// so the index reflects how many distinct tells appear, not how large the app is.
|
|
4
|
+
|
|
5
|
+
const clamp01 = (n) => Math.max(0, Math.min(1, n));
|
|
6
|
+
|
|
7
|
+
export function strengthOf(tell, entry) {
|
|
8
|
+
if (!entry || entry.status !== "present") return 0;
|
|
9
|
+
if (typeof entry.strength === "number") return clamp01(entry.strength);
|
|
10
|
+
if (typeof entry.hits === "number" && entry.hits > 0) return Math.min(1, entry.hits / (tell.cap || 1));
|
|
11
|
+
return 1;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// function tells get in the way of using the product; style tells are about the generated look.
|
|
15
|
+
export const kindOf = (tell) => tell?.kind ?? "function";
|
|
16
|
+
|
|
17
|
+
// tellsById: Map of catalog tells. entries: [{ id, status, hits?, strength? }].
|
|
18
|
+
// kind limits the index to function or style tells. Unverified entries and unknown ids
|
|
19
|
+
// are left out. Returns null when nothing can be scored.
|
|
20
|
+
export function computeIndex(tellsById, entries, kind = null) {
|
|
21
|
+
let weighted = 0;
|
|
22
|
+
let total = 0;
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const tell = tellsById.get(entry.id);
|
|
25
|
+
if (!tell || entry.status === "unverified") continue;
|
|
26
|
+
if (kind && kindOf(tell) !== kind) continue;
|
|
27
|
+
total += tell.weight;
|
|
28
|
+
weighted += tell.weight * strengthOf(tell, entry);
|
|
29
|
+
}
|
|
30
|
+
return total === 0 ? null : Math.round((100 * weighted) / total);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Entries for every tell the scanner can see, built from scan results ({ id, hits }).
|
|
34
|
+
export function staticEntries(tells, scanTells) {
|
|
35
|
+
const hitsById = new Map(scanTells.map((t) => [t.id, t.hits]));
|
|
36
|
+
return tells
|
|
37
|
+
.filter((t) => t.detection !== "dynamic")
|
|
38
|
+
.map((t) => {
|
|
39
|
+
const hits = hitsById.get(t.id) ?? 0;
|
|
40
|
+
return { id: t.id, status: hits > 0 ? "present" : "absent", hits, source: "static" };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Reviewer entries replace scan entries with the same id; the rest keep their order.
|
|
45
|
+
export function mergeEntries(base, overrides = []) {
|
|
46
|
+
const byId = new Map(overrides.map((e) => [e.id, e]));
|
|
47
|
+
const merged = base.map((e) => byId.get(e.id) ?? e);
|
|
48
|
+
const known = new Set(base.map((e) => e.id));
|
|
49
|
+
for (const e of overrides) if (!known.has(e.id)) merged.push(e);
|
|
50
|
+
return merged;
|
|
51
|
+
}
|