@ericrisco/rsc 1.0.23 → 1.0.25
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/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/knowledge-doctor.js +51 -9
- package/scripts/lib/knowledge-doctor.js +152 -10
- package/skills/design/SKILL.md +31 -1
- package/skills/design/evals/cases.yaml +1 -1
- package/skills/design/references/ai-tells.md +75 -0
- package/skills/design/references/design-systems.md +41 -0
- package/skills/design/references/motion-and-interaction.md +35 -0
- package/skills/design/scripts/verify.sh +116 -10
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -19,9 +19,16 @@ import { diagnose, CLASSES } from './lib/knowledge-doctor.js';
|
|
|
19
19
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
20
|
const WIKI = join(REPO, '02-DOCS', 'wiki');
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
// withFileTypes, NOT statSync — this matches scripts/drift-check.js:167, the precedent the plan cited
|
|
23
|
+
// as exact and then diverged from. statSync FOLLOWS symlinks, so a symlink under the wiki let the walk
|
|
24
|
+
// read outside the declared zone (reporting the logical path, hiding the escape), a dangling symlink
|
|
25
|
+
// crashed the whole report with ENOENT, and a symlink cycle crashed it with ELOOP. dirent.isDirectory()
|
|
26
|
+
// does none of that: a symlink is neither a file nor a directory here, so it is skipped.
|
|
27
|
+
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
28
|
+
const p = join(dir, e.name);
|
|
29
|
+
if (e.isDirectory()) return walk(p);
|
|
30
|
+
if (e.isFile() && p.endsWith('.md')) return [p];
|
|
31
|
+
return []; // symlinks, sockets, anything else: not our business, and not a crash
|
|
25
32
|
});
|
|
26
33
|
|
|
27
34
|
// Injected so the pure library never touches git. Cached: the same ref is asked about repeatedly.
|
|
@@ -48,19 +55,49 @@ function main() {
|
|
|
48
55
|
const indexPath = join(WIKI, 'index.md');
|
|
49
56
|
const indexText = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : null;
|
|
50
57
|
|
|
58
|
+
// AC7 says an unreadable document is a candidate with its reason, never a silent skip. The first
|
|
59
|
+
// version read "unreadable" as "frontmatter unparseable" only, so a chmod-000 file or a dangling
|
|
60
|
+
// symlink threw out of readFileSync and took the WHOLE report with it — worse than a silent skip.
|
|
61
|
+
const unreadable = [];
|
|
51
62
|
const docs = files.map((p) => {
|
|
52
63
|
const rel = relative(WIKI, p);
|
|
64
|
+
let text;
|
|
65
|
+
try {
|
|
66
|
+
text = readFileSync(p, 'utf8');
|
|
67
|
+
} catch (e) {
|
|
68
|
+
unreadable.push({
|
|
69
|
+
path: relative(REPO, p), line: 1, cls: 'fuera-de-sitio', uncertain: true,
|
|
70
|
+
signal: `no se pudo leer el fichero (${e.code || e.message})`,
|
|
71
|
+
expected: 'un documento legible', found: e.code || 'error de lectura',
|
|
72
|
+
});
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
53
75
|
return {
|
|
54
76
|
path: relative(REPO, p),
|
|
55
77
|
rel,
|
|
56
78
|
dir: dirname(rel) === '.' ? '' : dirname(rel),
|
|
57
|
-
text
|
|
79
|
+
text,
|
|
58
80
|
// Only artifacts the chain consumes are expected in the Knowledge map; an article is not.
|
|
59
81
|
indexable: rel.startsWith('sdd/specs/') || rel.startsWith('sdd/verifications/'),
|
|
60
82
|
};
|
|
61
|
-
});
|
|
83
|
+
}).filter(Boolean);
|
|
84
|
+
|
|
85
|
+
// Project artifacts outside the wiki: skill ids and script basenames. The library cannot read the
|
|
86
|
+
// repo (it is pure by design), so the caller that can, does.
|
|
87
|
+
const extraSlugs = [];
|
|
88
|
+
for (const [dir, strip] of [['skills', null], ['scripts', /\.(js|mjs|sh)$/], ['scripts/lib', /\.(js|mjs)$/]]) {
|
|
89
|
+
try {
|
|
90
|
+
for (const e of readdirSync(join(REPO, dir), { withFileTypes: true })) {
|
|
91
|
+
if (strip && e.isFile()) extraSlugs.push(e.name.replace(strip, ''));
|
|
92
|
+
else if (!strip && e.isDirectory()) extraSlugs.push(e.name);
|
|
93
|
+
}
|
|
94
|
+
} catch { /* a missing directory is not an error here */ }
|
|
95
|
+
}
|
|
62
96
|
|
|
63
|
-
const report = diagnose({ docs, indexText, refExists });
|
|
97
|
+
const report = diagnose({ docs, indexText, refExists, extraSlugs });
|
|
98
|
+
// Unreadable files are candidates too, and they sort first: a document nobody can open is the one
|
|
99
|
+
// whose staleness nobody can check.
|
|
100
|
+
if (unreadable.length) report.candidates = [...unreadable, ...report.candidates];
|
|
64
101
|
if (asJson) { console.log(JSON.stringify(report, null, 2)); process.exit(0); }
|
|
65
102
|
|
|
66
103
|
const byClass = (c) => report.candidates.filter((x) => x.cls === c);
|
|
@@ -69,10 +106,15 @@ function main() {
|
|
|
69
106
|
console.log('Sin candidatos. Nada que triar.\n');
|
|
70
107
|
} else {
|
|
71
108
|
console.log(`${report.candidates.length} candidato(s), ordenados por coste de equivocarse:\n`);
|
|
109
|
+
// Filenames are interpolated into markdown, and a filename may contain newlines and backticks. A
|
|
110
|
+
// crafted name rendered a structurally valid FORGED candidate while the tally below still read 0 —
|
|
111
|
+
// and the consumer of this report (knowledge-ops) archives and overwrites files. Neutralise the
|
|
112
|
+
// three characters that can break out of the line.
|
|
113
|
+
const safe = (s) => String(s).replace(/[\r\n]+/g, '⏎').replace(/`/g, "'");
|
|
72
114
|
for (const c of report.candidates) {
|
|
73
|
-
console.log(`- **${c.cls}** · \`${c.path}:${c.line}\`${c.uncertain ? ' · _incierto_' : ''}`);
|
|
74
|
-
console.log(` - señal: ${c.signal}`);
|
|
75
|
-
console.log(` - esperado: ${c.expected} · encontrado: ${c.found}`);
|
|
115
|
+
console.log(`- **${c.cls}** · \`${safe(c.path)}:${c.line}\`${c.uncertain ? ' · _incierto_' : ''}`);
|
|
116
|
+
console.log(` - señal: ${safe(c.signal)}`);
|
|
117
|
+
console.log(` - esperado: ${safe(c.expected)} · encontrado: ${safe(c.found)}`);
|
|
76
118
|
}
|
|
77
119
|
console.log('');
|
|
78
120
|
for (const [label, cls] of [['se contradice', CLASSES.CONTRADICTS], ['ya no aplica', CLASSES.STALE], ['fuera de sitio', CLASSES.MISPLACED]]) {
|
|
@@ -27,7 +27,11 @@ export const CLASSES = { STALE: 'ya-no-aplica', MISPLACED: 'fuera-de-sitio', CON
|
|
|
27
27
|
|
|
28
28
|
// Out of scope, named so their absence is not read as cleanliness.
|
|
29
29
|
export const NOT_LOOKED_AT = [
|
|
30
|
-
|
|
30
|
+
// This entry described the PRE-FIX dead-code behaviour and survived the fix that removed it: a stale
|
|
31
|
+
// claim inside the tool built to find stale claims. Rewritten to name the mechanism that actually
|
|
32
|
+
// ships — the 75% dominant-share threshold — and its real blind spot.
|
|
33
|
+
'Un tipo con pocos documentos: una carpeta solo se reconoce como el hogar de un `type:` cuando concentra el 75% de sus documentos, así que un tipo con 3 o menos ejemplares no tiene hogar y su ubicación nunca se juzga. Deliberado — la alternativa es marcar trabajo correcto (`article` vive legítimamente en tres carpetas).',
|
|
34
|
+
'Documentos alcanzados por symlink: el recorrido usa `withFileTypes` y salta los enlaces, así que un `.md` enlazado dentro del wiki no se audita. Es el precio de impedir que el recorrido se escape de `02-DOCS/` siguiendo un enlace.',
|
|
31
35
|
'Contradicción general entre documentos (exige comparación semántica; fuera por clarify 18-08).',
|
|
32
36
|
'Obsolescencia semántica de prosa: si un párrafo sobre arquitectura sigue siendo cierto.',
|
|
33
37
|
'La memoria del proyecto: drift-check ya resuelve sus rutas vía memoryDir(); su auditoría semántica queda fuera.',
|
|
@@ -58,9 +62,43 @@ export function parseFrontmatter(text) {
|
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/** Does this document declare itself the single source of a fact? Matched on normalized prose. */
|
|
65
|
+
/**
|
|
66
|
+
* Does this document declare ITSELF the single source of a fact?
|
|
67
|
+
*
|
|
68
|
+
* The first version matched `fuente única` / `single source of` anywhere in the file, and an adversarial
|
|
69
|
+
* review measured what that admits: on a 51-document wiki, **7 documents passed and only 1 actually
|
|
70
|
+
* declared single-sourcehood**. The other six were meta-documents merely DISCUSSING the concept — this
|
|
71
|
+
* detector's own spec, its own plan, and three verification records — and five of them had an unrelated
|
|
72
|
+
* first table sitting there as a denominator. One ordinary edit adding "probado 9 veces" to any of their
|
|
73
|
+
* descriptions would have fabricated a contradiction against a table with nothing to do with the number.
|
|
74
|
+
* That is over-firing on correct work, the direction P7 and the v1.0.17 rule both name as the dangerous
|
|
75
|
+
* one, and the whole narrow scope agreed in clarify rested on this gate being narrow.
|
|
76
|
+
*
|
|
77
|
+
* So the phrase must now be SELF-REFERENTIAL: the document has to point at itself ("esta tabla", "this
|
|
78
|
+
* table", "aquí") as the place the fact lives. Talking about single sources in the abstract no longer
|
|
79
|
+
* qualifies.
|
|
80
|
+
*/
|
|
61
81
|
export function declaresSingleSource(text) {
|
|
62
82
|
const p = normalizeProse(text);
|
|
63
|
-
|
|
83
|
+
// A locator ("en esta tabla", "in this table", "aquí") within a short window of the exclusivity
|
|
84
|
+
// claim. The window keeps it one sentence: a doc that says "esta tabla" in one paragraph and
|
|
85
|
+
// "fuente única" three pages later is not declaring anything.
|
|
86
|
+
const claim = /(en esta (tabla|lista)|in this (table|list)|aqu[ií] y en ning|here and nowhere)/i;
|
|
87
|
+
const exclusive = /(en ning[uú]n otro sitio|and nowhere else|nowhere else|fuente [uú]nica de|single source of truth for)/i;
|
|
88
|
+
if (!claim.test(p) || !exclusive.test(p)) return false;
|
|
89
|
+
// And they must be near each other, or two unrelated sentences pass by coincidence.
|
|
90
|
+
const ci = p.search(claim);
|
|
91
|
+
const ei = p.search(exclusive);
|
|
92
|
+
if (Math.abs(ci - ei) > 160) return false;
|
|
93
|
+
// A QUOTED phrase is a citation, not a declaration. Measured: two documents still passed after the
|
|
94
|
+
// narrowing above, and both were quoting this repo's own pattern registry — a spec and a verification
|
|
95
|
+
// record. A document declares in its own voice; one discussing the idea quotes someone else's. Both
|
|
96
|
+
// are exactly the kind of document whose description naturally gains "probado 9 veces", which is how
|
|
97
|
+
// the fabricated contradiction would have arrived.
|
|
98
|
+
const around = p.slice(Math.max(0, ei - 60), ei + 60);
|
|
99
|
+
const quoted = /["«“][^"»”]{0,120}$/.test(p.slice(Math.max(0, ei - 120), ei))
|
|
100
|
+
&& /^[^"«“]{0,120}["»”]/.test(p.slice(ei));
|
|
101
|
+
return !quoted || /\*\*/.test(around) === false ? !quoted : false;
|
|
64
102
|
}
|
|
65
103
|
|
|
66
104
|
const WORD_NUMBERS = {
|
|
@@ -72,9 +110,14 @@ const WORD_NUMBERS = {
|
|
|
72
110
|
* A count asserted in the FRONTMATTER only. Body prose is not a usable signal: the live case's own
|
|
73
111
|
* document contains "7 veces" inside a table cell describing something else entirely.
|
|
74
112
|
*/
|
|
113
|
+
export const MAX_REFS_PER_DOC = 32;
|
|
114
|
+
// A frontmatter value longer than this is not a title or a description. Bound before matching: the
|
|
115
|
+
// digit-run pattern is quadratic, measured at 21.5s on an 160k-character value.
|
|
116
|
+
const MAX_FM_VALUE = 2000;
|
|
117
|
+
|
|
75
118
|
export function statedCountInFrontmatter(fields) {
|
|
76
119
|
for (const key of ['description', 'title']) {
|
|
77
|
-
const v = String(fields[key] || '');
|
|
120
|
+
const v = String(fields[key] || '').slice(0, MAX_FM_VALUE);
|
|
78
121
|
const digits = /(\d+)\s+(?:veces|times|apariciones|occurrences)/i.exec(v);
|
|
79
122
|
if (digits) return { value: Number(digits[1]), source: key, literal: digits[0] };
|
|
80
123
|
const words = new RegExp(`\\b(${Object.keys(WORD_NUMBERS).join('|')})\\s+(?:veces|times)`, 'i').exec(v);
|
|
@@ -83,9 +126,22 @@ export function statedCountInFrontmatter(fields) {
|
|
|
83
126
|
return null;
|
|
84
127
|
}
|
|
85
128
|
|
|
86
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Blank out fenced code blocks, keeping the line count intact so reported line numbers stay true.
|
|
131
|
+
* Without this, an illustrative table inside ``` fences counted as the canonical one: a real 3-row
|
|
132
|
+
* table read as 1 row, and the report printed two wrong numbers as its evidence.
|
|
133
|
+
*/
|
|
134
|
+
export function stripFences(body) {
|
|
135
|
+
let inFence = false;
|
|
136
|
+
return String(body || '').split('\n').map((l) => {
|
|
137
|
+
if (/^\s*(```|~~~)/.test(l)) { inFence = !inFence; return ''; }
|
|
138
|
+
return inFence ? '' : l;
|
|
139
|
+
}).join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Data rows of the first markdown table (header and separator excluded), fences excluded. */
|
|
87
143
|
export function firstTableRowCount(body) {
|
|
88
|
-
const lines =
|
|
144
|
+
const lines = stripFences(body).split('\n');
|
|
89
145
|
let i = lines.findIndex((l) => /^\s*\|.*\|/.test(l) && /\|/.test(l));
|
|
90
146
|
if (i === -1) return null;
|
|
91
147
|
if (!/^\s*\|[\s:|-]+\|/.test(lines[i + 1] || '')) return null; // header must be followed by a separator
|
|
@@ -107,9 +163,29 @@ export function findStaleClaims({ path, text, refExists }) {
|
|
|
107
163
|
if (!status) return [];
|
|
108
164
|
const out = [];
|
|
109
165
|
const refs = new Set();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
for
|
|
166
|
+
// A short hex run is only a commit if it is NOT all digits: `20260818` is a compact date, and the
|
|
167
|
+
// old pattern reported it as a missing commit. Verified: `status: implementada el 20260818` produced
|
|
168
|
+
// a candidate for a ref that never existed as a ref.
|
|
169
|
+
for (const m of status.matchAll(/\b([0-9a-f]{7,8})\b/g)) {
|
|
170
|
+
if (/^\d+$/.test(m[1])) continue;
|
|
171
|
+
refs.add(m[1]);
|
|
172
|
+
}
|
|
173
|
+
// A version is only a tag claim when the text presents it as one. `requiere node 18.0.0 y ajv 8.18.0`
|
|
174
|
+
// used to yield two candidates for tags `v18.0.0` and `v8.18.0` — dependency versions read as releases.
|
|
175
|
+
for (const m of status.matchAll(/\b(?:v|versión |version |publicada en |released in |released as )(\d+\.\d+\.\d+)\b/gi)) {
|
|
176
|
+
refs.add(`v${m[1]}`);
|
|
177
|
+
}
|
|
178
|
+
// Cap the spawns a single document can cause: one status: with 3000 distinct refs spawned 6002 git
|
|
179
|
+
// processes and took 76s. A document citing more than this is not making a traceable claim.
|
|
180
|
+
const capped = [...refs].slice(0, MAX_REFS_PER_DOC);
|
|
181
|
+
if (refs.size > MAX_REFS_PER_DOC) {
|
|
182
|
+
out.push({
|
|
183
|
+
path, line: lineOf(text, 'status:'), cls: CLASSES.STALE, uncertain: true,
|
|
184
|
+
signal: `status: cita ${refs.size} referencias; solo se comprobaron las primeras ${MAX_REFS_PER_DOC}`,
|
|
185
|
+
expected: `un status: con referencias acotadas`, found: `${refs.size} referencias`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
for (const ref of capped) {
|
|
113
189
|
if (refExists(ref)) continue;
|
|
114
190
|
out.push({
|
|
115
191
|
path, line: lineOf(text, 'status:'), cls: CLASSES.STALE,
|
|
@@ -120,6 +196,21 @@ export function findStaleClaims({ path, text, refExists }) {
|
|
|
120
196
|
return out;
|
|
121
197
|
}
|
|
122
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Is there an actual index ROW for this path — a list item whose markdown link targets it?
|
|
201
|
+
*
|
|
202
|
+
* The first version was `indexText.includes(doc.rel)`, a substring test, so a path merely MENTIONED in
|
|
203
|
+
* prose silenced the check: "Borramos sdd/specs/x.md porque ya no aplica" counted as indexed. That is
|
|
204
|
+
* verbatim the mistake this repo recorded as having shipped twice in one day — "the path appears in the
|
|
205
|
+
* string" is not "the row exists" — and it fails in the under-firing direction, which is the quiet one.
|
|
206
|
+
*/
|
|
207
|
+
export function hasIndexRow(indexText, rel) {
|
|
208
|
+
if (!rel) return false;
|
|
209
|
+
const escaped = rel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
210
|
+
const row = new RegExp(`^\\s*[-*]\\s.*\\]\\([^)]*${escaped}\\)`, 'm');
|
|
211
|
+
return row.test(String(indexText || ''));
|
|
212
|
+
}
|
|
213
|
+
|
|
123
214
|
/**
|
|
124
215
|
* Class 2 — out of place. Conventions are DERIVED from the wiki on every run, never hardcoded:
|
|
125
216
|
* a hardcoded table is a second copy of the truth and drifts from it.
|
|
@@ -214,7 +305,7 @@ export function findMisplaced({ doc, conventions, indexText }) {
|
|
|
214
305
|
});
|
|
215
306
|
}
|
|
216
307
|
}
|
|
217
|
-
if (indexText != null && doc.indexable && !indexText
|
|
308
|
+
if (indexText != null && doc.indexable && !hasIndexRow(indexText, doc.rel)) {
|
|
218
309
|
out.push({
|
|
219
310
|
path: doc.path, line: 1, cls: CLASSES.MISPLACED,
|
|
220
311
|
signal: 'artefacto sin fila en el Knowledge map',
|
|
@@ -224,6 +315,44 @@ export function findMisplaced({ doc, conventions, indexText }) {
|
|
|
224
315
|
return out;
|
|
225
316
|
}
|
|
226
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Class 2b — an entry in a canonical log that does not describe what that log says it contains.
|
|
320
|
+
*
|
|
321
|
+
* AC2 of the spec, and it was NOT implemented in the shipped version — found by two independent
|
|
322
|
+
* refuter lenses. It is not a nice-to-have: one of the three incidents that motivated the whole spec
|
|
323
|
+
* was four entries written into `decisions.md` by eval agents, about features nobody was building.
|
|
324
|
+
* Worse than absent, the disclosure list did not name it, so the report presented the canonical log as
|
|
325
|
+
* scanned and clean — affirming a check nobody performed.
|
|
326
|
+
*
|
|
327
|
+
* The signal has to be narrow or this becomes the noisiest detector in the tool. A canonical log
|
|
328
|
+
* declares its own subject in its opening prose ("Procedimientos que el asistente observó",
|
|
329
|
+
* "decisiones de alcance"). An entry is a candidate when its heading names a SLUG that exists nowhere
|
|
330
|
+
* in the project — because a decision about `magic-link-login` when no such spec, plan or artifact
|
|
331
|
+
* exists is either about someone else's work or about work nobody is doing.
|
|
332
|
+
*/
|
|
333
|
+
export function findLogIntruders({ path, text, knownSlugs }) {
|
|
334
|
+
if (!/decisions\.md$|automation-gaps\.md$/.test(path)) return [];
|
|
335
|
+
const out = [];
|
|
336
|
+
const lines = String(text || '').split('\n');
|
|
337
|
+
for (let i = 0; i < lines.length; i++) {
|
|
338
|
+
const h = /^##\s+\d{4}-\d{2}-\d{2}\s*[—·-]\s*(.+)$/.exec(lines[i]);
|
|
339
|
+
if (!h) continue;
|
|
340
|
+
// A slug-shaped token in the heading: lowercase words joined by hyphens, at least two parts.
|
|
341
|
+
const slugs = (h[1].match(/`?\b([a-z][a-z0-9]+(?:-[a-z0-9]+)+)\b`?/g) || [])
|
|
342
|
+
.map((s) => s.replace(/`/g, ''));
|
|
343
|
+
const orphans = slugs.filter((s) => !knownSlugs.has(s));
|
|
344
|
+
// Only flag when EVERY slug in the heading is unknown: a heading mixing known and unknown terms is
|
|
345
|
+
// ordinary prose, not an entry about work that does not exist.
|
|
346
|
+
if (!slugs.length || orphans.length !== slugs.length) continue;
|
|
347
|
+
out.push({
|
|
348
|
+
path, line: i + 1, cls: CLASSES.MISPLACED, uncertain: true,
|
|
349
|
+
signal: `entrada cuyo asunto (${orphans.map((s) => `\`${s}\``).join(', ')}) no corresponde a ningún artefacto del proyecto`,
|
|
350
|
+
expected: 'una entrada sobre trabajo que existe', found: 'asunto sin artefacto',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
227
356
|
/** Class 3 — a frontmatter count contradicting the table the document declares its single source. */
|
|
228
357
|
export function findContradictions({ path, text }) {
|
|
229
358
|
if (!declaresSingleSource(text)) return [];
|
|
@@ -253,13 +382,26 @@ export function rank(candidates) {
|
|
|
253
382
|
}
|
|
254
383
|
|
|
255
384
|
/** The whole pass, over already-read documents. Pure: no fs, no git — both are injected. */
|
|
256
|
-
export function diagnose({ docs, indexText, refExists }) {
|
|
385
|
+
export function diagnose({ docs, indexText, refExists, extraSlugs = [] }) {
|
|
257
386
|
const cands = [];
|
|
258
387
|
const conventions = deriveConventions(docs);
|
|
388
|
+
// Slugs the project actually has, derived from the documents themselves (P3: the content is the
|
|
389
|
+
// ledger). A log entry whose subject is not in here is about work that does not exist.
|
|
390
|
+
// extraSlugs comes from the CLI, which knows the repo: skill ids and script basenames are project
|
|
391
|
+
// artifacts too. Deriving only from wiki filenames flagged `drift-check` — a real script — as work
|
|
392
|
+
// that does not exist.
|
|
393
|
+
const knownSlugs = new Set(extraSlugs);
|
|
394
|
+
for (const doc of docs) {
|
|
395
|
+
const base = (doc.rel || doc.path).split('/').pop().replace(/\.md$/, '').replace(/\.plan$/, '');
|
|
396
|
+
knownSlugs.add(base);
|
|
397
|
+
const fmSlug = parseFrontmatter(doc.text).fields.slug;
|
|
398
|
+
if (fmSlug) knownSlugs.add(fmSlug);
|
|
399
|
+
}
|
|
259
400
|
for (const doc of docs) {
|
|
260
401
|
cands.push(...findStaleClaims({ path: doc.path, text: doc.text, refExists }));
|
|
261
402
|
cands.push(...findMisplaced({ doc, conventions, indexText }));
|
|
262
403
|
cands.push(...findContradictions({ path: doc.path, text: doc.text }));
|
|
404
|
+
cands.push(...findLogIntruders({ path: doc.path, text: doc.text, knownSlugs }));
|
|
263
405
|
}
|
|
264
406
|
return { candidates: rank(cands), scanned: docs.length, notLookedAt: NOT_LOOKED_AT };
|
|
265
407
|
}
|
package/skills/design/SKILL.md
CHANGED
|
@@ -234,6 +234,36 @@ img { outline: 1px solid oklch(0 0 0 / 0.1); outline-offset: -1px; }
|
|
|
234
234
|
|
|
235
235
|
Depth recipes, glass, noise, concentric math → `references/visual-system.md`.
|
|
236
236
|
|
|
237
|
+
## The three locks, and who checks them
|
|
238
|
+
|
|
239
|
+
A long page generated in passes drifts against itself. Three things must be **one** thing for
|
|
240
|
+
the whole page, and they are not preferences — a page that breaks one of them is defective:
|
|
241
|
+
|
|
242
|
+
1. **Theme lock.** One theme for the page (light, dark, or auto). No section flipping to
|
|
243
|
+
inverted halfway down because it looked good alone.
|
|
244
|
+
2. **Accent lock.** One accent, used identically in every section. **This one is judgement**:
|
|
245
|
+
deciding which token is the accent needs to read the system's intent, so no script claims
|
|
246
|
+
to check it — you do, before you claim done.
|
|
247
|
+
3. **Radius lock.** One radius system. Four different `rounded-*` scales in one tree means
|
|
248
|
+
there is no system, and `scripts/verify.sh` counts them.
|
|
249
|
+
|
|
250
|
+
## The tells the script owns, and the ones you own
|
|
251
|
+
|
|
252
|
+
`scripts/verify.sh` carries a **registry of mechanical tells** and reports each with file and
|
|
253
|
+
line: dashes in rendered copy, scroll cues, numbered eyebrows, build stamps on marketing
|
|
254
|
+
pages, unstable viewport heights, middot chains, hype words, plus counted ratios (eyebrows
|
|
255
|
+
against sections, double-bordered rows, radius scales). Run it; do not re-derive that list
|
|
256
|
+
from memory, and do not treat its silence as a design review.
|
|
257
|
+
|
|
258
|
+
What it cannot see is the other half: fabricated substance (a product UI built from divs,
|
|
259
|
+
invented photo credits, the Jane Doe cast), decoration posing as information, labels that say
|
|
260
|
+
nothing, and composition habits like three identical feature cards. That half is
|
|
261
|
+
`references/ai-tells.md`, and it is read with your eyes on the page.
|
|
262
|
+
|
|
263
|
+
When the brief corresponds to an official, maintained design system rather than an aesthetic —
|
|
264
|
+
UK or US public sector, Shopify admin, Carbon, Atlassian, Primer, Fluent — install the
|
|
265
|
+
official package instead of approximating it: `references/design-systems.md`.
|
|
266
|
+
|
|
237
267
|
## Anti-patterns
|
|
238
268
|
|
|
239
269
|
| Rationalization | Reality / Fix |
|
|
@@ -314,6 +344,6 @@ Score = Σ(dimension × weight), max 115 (normalize to /100 by ×100/115 if you
|
|
|
314
344
|
Two records, both indexed in `02-DOCS/wiki/index.md` (the Knowledge map; root `CLAUDE.md` keeps only a short pointer to it), both read first on every use so outputs stay consistent with them:
|
|
315
345
|
|
|
316
346
|
- The **brand study** at `02-DOCS/wiki/brand/` — the hard gate above: missing or incomplete → ask until complete before designing.
|
|
317
|
-
- The **design-system decisions** at `02-DOCS/wiki/stack/design.md` — the chosen tokens (color/OKLCH, type scale, spacing, radius, shadow, motion), the 2026 direction picked, and the reference sites. Recorded, not gated; create/update it as decisions are made.
|
|
347
|
+
- The **design-system decisions** at `02-DOCS/wiki/stack/design.md` — the chosen tokens (color/OKLCH, type scale, spacing, radius, shadow, motion), the 2026 direction picked, and the reference sites. Recorded, not gated; create/update it as decisions are made. **Read it before fixing type and palette on a new surface**, and either reuse what is there or state why you are departing — otherwise every surface of the project drifts into a different design. (Across *different* projects this record cannot help: it lives inside each one. Distrusting your own second default is the only guard there — see the repetition note in `references/ai-tells.md`.)
|
|
318
348
|
|
|
319
349
|
The wiki article protocol both follow is `../harness/SKILL.md`'s.
|
|
@@ -49,7 +49,7 @@ capability:
|
|
|
49
49
|
- scenario: "User asks: 'Here's our current pricing page — do a design review and tell me how good it actually is.'"
|
|
50
50
|
must_include:
|
|
51
51
|
- "Applies the design-review QA checklist (value prop in 5s, contrast >= 4.5:1, visible focus, one <h1>, semantic landmarks, LCP image priority, next/font, no transition: all, tokens not magic numbers, ban-list-clean copy, 360px fit, empty/loading/error states)."
|
|
52
|
-
- "Scores the
|
|
52
|
+
- "Scores the 11-dimension visual-audit rubric 0-10 with weights and reports the weighted total on its /115 scale, mapped to a band (<69 generic / 69-91 competent / 92-108 premium / 109+ award-tier)."
|
|
53
53
|
- "For each dimension scoring below 8, gives a concrete actionable fix (e.g. exact spacing/token change), not a vague 'improve spacing'."
|
|
54
54
|
- "Checks copy against the ban-list and flags hype/vague value props that fail the 5s test."
|
|
55
55
|
- "Flags any transition: all, magic hex/px, cards-in-cards, or purple->blue everything gradient as defects, not preferences."
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# AI tells — the ones only a reader can catch
|
|
2
|
+
|
|
3
|
+
The mechanical tells are **not here**. `scripts/verify.sh` carries a registry of them and
|
|
4
|
+
flags them with file and line, so re-teaching them as prose would be paying twice for the
|
|
5
|
+
same rule. This file holds only what a `grep` cannot decide: presences you have to *look* at
|
|
6
|
+
the page to see.
|
|
7
|
+
|
|
8
|
+
Part of this corpus is adapted from [Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill)
|
|
9
|
+
(MIT), whose lists came out of real production tests. The split into checkable-vs-judged, the
|
|
10
|
+
thresholds, and the wording are ours.
|
|
11
|
+
|
|
12
|
+
Every item below is a **default**, overridable by a brief that explicitly asks for it. What
|
|
13
|
+
makes them tells is that they show up *unasked*, as the model's idea of "looking designed".
|
|
14
|
+
|
|
15
|
+
## Fabricated substance
|
|
16
|
+
|
|
17
|
+
- **A product UI built out of divs.** A fake task list, fake terminal or fake dashboard
|
|
18
|
+
assembled from styled rectangles is the single most reliable signature of generated work.
|
|
19
|
+
Use a real screenshot, a real component, a generated image, or show nothing.
|
|
20
|
+
- **Hand-rolled decorative SVG.** Abstract squiggles and blobs authored inline to fill space.
|
|
21
|
+
Icons come from a maintained set; decoration comes from a real asset or not at all.
|
|
22
|
+
- **The Jane Doe cast.** "John Doe", "Sarah Chan", egg avatars, `99.99%`, `50%`, round
|
|
23
|
+
thousands, and startup-slop brand names ("Acme", "Nexus", "SmartFlow"). Real work has
|
|
24
|
+
specific, slightly awkward names and messy numbers (`47.2%`, not `50%`).
|
|
25
|
+
- **Testimonials nobody said.** A quote with no attributable person, or five quotes with the
|
|
26
|
+
same cadence, reads as filler. One real quote beats three invented ones.
|
|
27
|
+
|
|
28
|
+
## Decoration wearing the costume of information
|
|
29
|
+
|
|
30
|
+
- **Atmospheric locale strips.** A city, a timezone, a temperature in the nav or footer. Only
|
|
31
|
+
earns its place for a genuinely distributed studio, a travel brand, or a physical venue.
|
|
32
|
+
- **Status dots with no status.** A coloured dot before nav items, list rows or badges. Keep
|
|
33
|
+
it only where it reports real state, and then only once per section.
|
|
34
|
+
- **Invented photo credits.** `Field study no. 12 · Ines Caetano` under a stock image.
|
|
35
|
+
Credit a real photographer or write a functional caption.
|
|
36
|
+
- **Pills laid over images.** Tags floated on top of a photo. Either the image speaks alone,
|
|
37
|
+
or the caption sits below it, outside the frame.
|
|
38
|
+
- **Decoration strips under the hero.** `BRAND. MOTION. SPATIAL.` in small mono caps. Only
|
|
39
|
+
legitimate when those words are real navigation or real status.
|
|
40
|
+
|
|
41
|
+
## Labels that say nothing
|
|
42
|
+
|
|
43
|
+
- **Poetic section labels.** "Field notes", "From the field", "On our desks", "Currently on
|
|
44
|
+
the bench". Use the functional name ("Testimonials", "Latest writing") or no label.
|
|
45
|
+
- **Generic step labels.** "Stage 1 / Stage 2", "Phase 01 / 02". The step content *is* the
|
|
46
|
+
label: "Install", "Configure", "Ship".
|
|
47
|
+
- **Mock-humble asides** and micro-meta sentences under a heading ("The list will stay short
|
|
48
|
+
on purpose"). Eyebrow, headline, body. Nothing else.
|
|
49
|
+
- **"Quietly trusted by".** Say "Used at", "Customers include", or let the logos speak.
|
|
50
|
+
|
|
51
|
+
## Composition habits
|
|
52
|
+
|
|
53
|
+
- **Three identical feature cards.** The default feature row. Reach for a two-column zig-zag,
|
|
54
|
+
an asymmetric grid, or a horizontal rail instead — and do not run the same split three
|
|
55
|
+
sections in a row.
|
|
56
|
+
- **The corner floater.** A giant left-aligned headline with a small explainer paragraph
|
|
57
|
+
floating in the section's top-right, aligned to nothing. Stack it, or build a real
|
|
58
|
+
two-column header.
|
|
59
|
+
- **Rotated vertical text** and crosshair grid lines drawn only to look designed. Both are
|
|
60
|
+
agency-portfolio clichés unless they organise real content.
|
|
61
|
+
- **A bento with empty cells.** N items means N cells, and at least two cells need real
|
|
62
|
+
visual variation — otherwise it is a white card grid with extra steps.
|
|
63
|
+
|
|
64
|
+
## The second default (repetition)
|
|
65
|
+
|
|
66
|
+
Obeying every rule produces correct pages that all look the same. Before fixing type and
|
|
67
|
+
palette, read the project's recorded design decisions and either reuse them deliberately or
|
|
68
|
+
say why you are departing. Two specific defaults to distrust: reaching for the same display
|
|
69
|
+
serif again, and answering every premium-consumer brief with the beige/brass/oxblood family.
|
|
70
|
+
|
|
71
|
+
## Surface treatments to distrust by default
|
|
72
|
+
|
|
73
|
+
Outer glows and neon; pure `#000000` (use an off-black); oversaturated accents that refuse to
|
|
74
|
+
sit with the neutrals; gradient fills on large headings; custom cursors (outdated, hostile to
|
|
75
|
+
accessibility and to performance); glass on every surface rather than on floating ones.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# When the brief is a design system, not an aesthetic
|
|
2
|
+
|
|
3
|
+
This skill's default is Tailwind v4 + Next.js, and for most briefs that is right. But some
|
|
4
|
+
briefs correspond to a **maintained, official design system**, and there the correct answer is
|
|
5
|
+
to install it. Recreating its CSS by hand produces something that looks like it, fails its
|
|
6
|
+
accessibility work, and drifts on its next release.
|
|
7
|
+
|
|
8
|
+
The list is deliberately short: only the cases where using the official package is
|
|
9
|
+
**obligatory or strongly expected**, and where the default instinct would be to hand-roll it.
|
|
10
|
+
Free choices (Bootstrap, Material for a non-Google product, Radix Themes, shadcn/ui) are not
|
|
11
|
+
here — they are ordinary picks, and shadcn/ui in particular is already this skill's territory,
|
|
12
|
+
where the rule is simply never to ship it in its default state.
|
|
13
|
+
|
|
14
|
+
| The brief reads as | Install | Why it is not a choice |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| A UK public-sector service | `govuk-frontend` | Expected by the Service Standard; deviating is a finding at assessment |
|
|
17
|
+
| A US federal or trust-first public service | `uswds` | Same, under the US Web Design System |
|
|
18
|
+
| A Shopify admin surface or embedded app | `@shopify/polaris` (or Polaris web components) | Required for admin UI to be accepted |
|
|
19
|
+
| IBM-flavoured enterprise or dense analytics | `@carbon/react` + `@carbon/styles` | Mature data-density patterns you will otherwise reinvent badly |
|
|
20
|
+
| An Atlassian / Jira-adjacent product surface | `@atlaskit/*` + `@atlaskit/tokens` | Official DS; token-driven theming |
|
|
21
|
+
| A GitHub-style devtool or community page | `@primer/css`, or `@primer/react-brand` for marketing | Official Primer; the Brand variant is the marketing half |
|
|
22
|
+
| A Microsoft / Fluent-flavoured product | `@fluentui/react-components` | Official Fluent, Microsoft tokens, accessibility already done |
|
|
23
|
+
|
|
24
|
+
## The three rules that come with them
|
|
25
|
+
|
|
26
|
+
1. **Do not recreate it.** If the brief names one of these, install the package. Importing its
|
|
27
|
+
tokens and then overriding ninety percent of them is the same mistake with extra steps.
|
|
28
|
+
2. **One system per project.** No Fluent components inside a Carbon tree, no shadcn/ui
|
|
29
|
+
dropped into a Material app. Mixing two systems produces a third, worse one.
|
|
30
|
+
3. **Name what is an approximation.** When the brief is an *aesthetic* rather than a system —
|
|
31
|
+
glassmorphism, bento tiles, brutalism, editorial, aurora gradients, kinetic type — there is
|
|
32
|
+
no official package, and saying so is part of the work. Apple's Liquid Glass is documented
|
|
33
|
+
for Apple platforms only: there is no official web implementation, so a web version is a
|
|
34
|
+
`backdrop-filter` approximation and must be labelled as one, in the code and to the user.
|
|
35
|
+
|
|
36
|
+
## What still applies
|
|
37
|
+
|
|
38
|
+
Everything in `../SKILL.md` that is not styling: the brand gate, the accessibility floor,
|
|
39
|
+
the Core Web Vitals budget, the 5-second value-prop test, and the tell registry in
|
|
40
|
+
`../scripts/verify.sh`. An official design system settles which components you use. It does
|
|
41
|
+
not settle whether the page says anything.
|
|
@@ -174,6 +174,41 @@ export function Toast({ open, message }: { open: boolean; message: string }) {
|
|
|
174
174
|
|
|
175
175
|
Once you are in `motion/react`, defer the mechanics (spring config, variants, `layoutId` shared transitions) to the external *motion-foundations* and *motion-ui* references.
|
|
176
176
|
|
|
177
|
+
## Escalating past the native path
|
|
178
|
+
|
|
179
|
+
Native `animation-timeline: view()` covers reveals, progress rails and parallax. Two effects it
|
|
180
|
+
cannot express are **pinning** (a section holds still while its content advances) and a
|
|
181
|
+
**horizontal pan** driven by vertical scroll. Those need a scroll library, and the improvised
|
|
182
|
+
version is always wrong in the same three ways. Escalate deliberately:
|
|
183
|
+
|
|
184
|
+
**Never listen to scroll directly.** `window.addEventListener('scroll')` fires off the main
|
|
185
|
+
thread's budget, blows INP, and never gets a passive-listener or cleanup story right. The
|
|
186
|
+
allowed inputs are `IntersectionObserver`, a scroll-driven CSS timeline, `useScroll()` from
|
|
187
|
+
`motion/react`, or GSAP's ScrollTrigger. Nothing else.
|
|
188
|
+
|
|
189
|
+
**Sticky stack** (pin a section, advance its layers):
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// 'use client' leaf only. One trigger per pinned section, killed on unmount.
|
|
193
|
+
const st = ScrollTrigger.create({
|
|
194
|
+
trigger: sectionRef.current,
|
|
195
|
+
start: "top top", // pin the moment the section's top reaches the viewport top
|
|
196
|
+
end: () => `+=${panels.length * window.innerHeight}`,
|
|
197
|
+
pin: true,
|
|
198
|
+
pinSpacing: true, // without this the next section slides under the pin
|
|
199
|
+
scrub: 1, // 1 = follow scroll with a 1s catch-up, not a hard 1:1 jump
|
|
200
|
+
invalidateOnRefresh: true, // recompute on resize, or the end point is stale on rotate
|
|
201
|
+
});
|
|
202
|
+
// cleanup is not optional: return () => st.kill();
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Horizontal pan** is the same trigger with `end: () => "+=" + track.scrollWidth` and the
|
|
206
|
+
track moved via `x`, not `left` — `transform` is compositor-only, `left` triggers layout.
|
|
207
|
+
|
|
208
|
+
Both are opt-in above a motion budget, both stay inside
|
|
209
|
+
`@media (prefers-reduced-motion: no-preference)`, and both collapse to a plain vertical stack
|
|
210
|
+
below `768px` rather than pinning on a phone.
|
|
211
|
+
|
|
177
212
|
## See Also
|
|
178
213
|
|
|
179
214
|
- `visual-system.md` — the easing and duration tokens these animations consume.
|
|
@@ -7,9 +7,17 @@
|
|
|
7
7
|
# performance + accessibility audit and checks Core Web Vitals against the
|
|
8
8
|
# skill's hard thresholds (LCP < 2.5s, CLS < 0.1, INP proxy via TBT < 200ms,
|
|
9
9
|
# a11y score >= 0.9).
|
|
10
|
-
# 2. Always runs static, network-free design-review
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# 2. Always runs static, network-free design-review checks, in three groups:
|
|
11
|
+
# - four judgement-shaped ones (more than one <h1>, image without alt,
|
|
12
|
+
# hardcoded hex despite a token system, animation with no reduced-motion
|
|
13
|
+
# guard) — see static_checks();
|
|
14
|
+
# - the tell registry: one row per checkable AI tell, iterated generically
|
|
15
|
+
# (dashes in rendered copy, scroll cues, numbered eyebrows, build stamps,
|
|
16
|
+
# unstable viewport heights, middot chains, transition-all, hype words)
|
|
17
|
+
# — see tell_table(); every row has a fixture in the repo's test suite;
|
|
18
|
+
# - three counters that need a ratio rather than a match (eyebrows against
|
|
19
|
+
# sections, double-bordered rows, radius scales) — see counter_checks().
|
|
20
|
+
# Everything here WARNS. Nothing here fails, unless --strict is passed.
|
|
13
21
|
# 3. If Lighthouse did not run, prints the manual QA checklist. For a graded
|
|
14
22
|
# 0-10 critique, use the visual-audit rubric in SKILL.md instead.
|
|
15
23
|
#
|
|
@@ -78,7 +86,10 @@ have() { command -v "$1" >/dev/null 2>&1; }
|
|
|
78
86
|
SELF_NAME="$(basename "$0")"
|
|
79
87
|
search() {
|
|
80
88
|
[ "$#" -gt 0 ] || return 1
|
|
81
|
-
|
|
89
|
+
# VERIFY_FORCE_GREP=1 pins the grep branch. It exists so the test suite can prove BOTH
|
|
90
|
+
# engines agree on the multibyte patterns (em-dash, middot) on any machine, instead of
|
|
91
|
+
# only exercising whichever one happens to be installed.
|
|
92
|
+
if have rg && [ -z "${VERIFY_FORCE_GREP:-}" ]; then
|
|
82
93
|
# rg already ignores .gitignore'd paths (node_modules, .next, dist); also skip self.
|
|
83
94
|
rg -n --no-heading --glob "!$SELF_NAME" "$@"
|
|
84
95
|
else
|
|
@@ -165,6 +176,102 @@ run_lighthouse() {
|
|
|
165
176
|
fi
|
|
166
177
|
}
|
|
167
178
|
|
|
179
|
+
# --- the tell registry ------------------------------------------------------
|
|
180
|
+
# One row per checkable AI tell. A row is DATA, not code, so the test suite can enumerate
|
|
181
|
+
# every row and demand a fixture for each: a check nobody has ever seen fire cannot ship.
|
|
182
|
+
# Add a tell by adding a row plus tests/fixtures/design-tells/tells/<id>.tsx — nothing else.
|
|
183
|
+
#
|
|
184
|
+
# Columns, delimited by %% (the pattern column needs `|` for ERE alternation):
|
|
185
|
+
# id %% sev %% extensions %% POSIX-ERE pattern %% what to do instead / the legit exception
|
|
186
|
+
#
|
|
187
|
+
# Patterns are POSIX ERE: no lookahead, no \b, no \s — they must behave identically under rg
|
|
188
|
+
# and under grep -E. Part of the corpus is adapted from Leonxlnx/taste-skill (MIT).
|
|
189
|
+
tell_table() {
|
|
190
|
+
cat <<'TELLS'
|
|
191
|
+
em-dash%%warn%%tsx,jsx,html,vue,svelte,mdx%%—|–%%em-dash or en-dash in rendered copy is the loudest AI tell. Use a period, a comma, a colon or parentheses instead
|
|
192
|
+
scroll-cue%%warn%%tsx,jsx,html,vue,svelte%%[Ss]croll to (explore|walk|discover|see|begin)|↓ *[Ss]croll%%a scroll cue explains scrolling to someone already looking at the hero. Delete it, or use a real anchor link instead
|
|
193
|
+
numbered-eyebrow%%warn%%tsx,jsx,html,vue,svelte%%(^|[^0-9A-Za-z])[0-9]{2,3} +(·|/) +[A-Za-z]%%numbered section eyebrow (001 · Capabilities). Name the topic in plain language instead
|
|
194
|
+
version-stamp%%warn%%tsx,jsx,html,vue,svelte%%v[0-9]+\.[0-9]+\.[0-9]+|Build [0-9]{3,4}%%a build or version stamp is devtool furniture on a marketing page. Delete it. Legitimate exception: a real devtool footer, a changelog or a release page
|
|
195
|
+
viewport-unit%%warn%%tsx,jsx,html,vue,svelte,css%%h-screen%%h-screen jumps when mobile browser chrome slides away. Use min-h-[100dvh] instead
|
|
196
|
+
middot-chain%%warn%%tsx,jsx,html,vue,svelte%%·[^·]*·%%more than one middot on a line is decoration pretending to be metadata. Use line breaks, columns or a hairline instead
|
|
197
|
+
transition-all%%warn%%tsx,jsx,css,html,vue,svelte%%transition:[[:space:]]*all|transition-all%%transition-all animates layout properties and janks. List the exact properties instead
|
|
198
|
+
ban-list%%warn%%tsx,jsx,html,md,mdx%%revolutionary|game-?changer|cutting-edge|supercharge|seamless|unlock%%hype words carry no value prop. Use the concrete benefit and a real number instead
|
|
199
|
+
TELLS
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
run_tell_table() {
|
|
203
|
+
local line id sev globs pat msg hits exts rest
|
|
204
|
+
# NOT `IFS='%%'`: IFS is a set of characters, so it would split on a single `%` and put an
|
|
205
|
+
# empty field between every pair. Parameter expansion splits on the two-character
|
|
206
|
+
# delimiter exactly, which is what the pattern column needs to keep its ERE `|`.
|
|
207
|
+
while IFS= read -r line; do
|
|
208
|
+
case "$line" in ''|'#'*) continue ;; esac
|
|
209
|
+
id="${line%%"%%"*}"; rest="${line#*"%%"}"
|
|
210
|
+
sev="${rest%%"%%"*}"; rest="${rest#*"%%"}"
|
|
211
|
+
globs="${rest%%"%%"*}"; rest="${rest#*"%%"}"
|
|
212
|
+
pat="${rest%%"%%"*}"; msg="${rest#*"%%"}"
|
|
213
|
+
exts="${globs//,/|}"
|
|
214
|
+
# Two post-filters, both learned from a real run over this repo:
|
|
215
|
+
# 1. keep only the extensions the row declares (anchored before the first colon, so it
|
|
216
|
+
# behaves the same under rg and grep and never matches content that names a file);
|
|
217
|
+
# 2. drop lines whose match sits in a code comment. A dash in `/* tokens — lifted */`
|
|
218
|
+
# is not rendered copy, and flagging it trains the reader to ignore the tool.
|
|
219
|
+
hits="$(search "$pat" 2>/dev/null \
|
|
220
|
+
| grep -E "^[^:]*\.($exts):" \
|
|
221
|
+
| grep -vE "^[^:]*:[0-9]+:[[:space:]]*(//|/\*|\*[^a-zA-Z]|#|<!--|\{/\*)" || true)"
|
|
222
|
+
if [ -n "$hits" ]; then
|
|
223
|
+
# The sev column is real, not decoration: anything other than `warn` breaks the build.
|
|
224
|
+
case "$sev" in
|
|
225
|
+
warn) warn "$id: $msg" ;;
|
|
226
|
+
*) fail "$id: $msg" ;;
|
|
227
|
+
esac
|
|
228
|
+
printf '%s\n' "$hits" | head -n 5
|
|
229
|
+
fi
|
|
230
|
+
done <<EOF
|
|
231
|
+
$(tell_table)
|
|
232
|
+
EOF
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# --- counters: the tells that are a ratio or a repetition, not a pattern -----
|
|
236
|
+
# These cannot be table rows because one match proves nothing — it takes a count. Each one
|
|
237
|
+
# carries its own named test instead of riding the generic row mechanism.
|
|
238
|
+
counter_checks() {
|
|
239
|
+
local eyebrows sections ceiling hits variants v
|
|
240
|
+
|
|
241
|
+
# Eyebrow ceiling. Threshold ceil(sections/3) is BORROWED from taste-skill and has never
|
|
242
|
+
# been calibrated against a page of ours — the warning says so rather than implying rigour.
|
|
243
|
+
eyebrows="$(search 'uppercase[^"]*tracking|tracking[^"]*uppercase' 2>/dev/null | grep -cE '^[^:]*\.(tsx|jsx|html|vue|svelte):' || true)"
|
|
244
|
+
sections="$(search '<section' 2>/dev/null | grep -cE '^[^:]*\.(tsx|jsx|html|vue|svelte):' || true)"
|
|
245
|
+
[ -z "$eyebrows" ] && eyebrows=0
|
|
246
|
+
[ -z "$sections" ] && sections=0
|
|
247
|
+
if [ "$sections" -eq 0 ]; then
|
|
248
|
+
if [ "$eyebrows" -gt 0 ]; then
|
|
249
|
+
skip "eyebrow ceiling: no <section> found, so there is no denominator — not guessing one"
|
|
250
|
+
fi
|
|
251
|
+
else
|
|
252
|
+
ceiling=$(( (sections + 2) / 3 ))
|
|
253
|
+
if [ "$eyebrows" -gt "$ceiling" ]; then
|
|
254
|
+
warn "eyebrow count $eyebrows exceeds the ceiling $ceiling for $sections sections (uncalibrated threshold, borrowed): drop the uppercase micro-labels, or let the headline carry it instead"
|
|
255
|
+
fi
|
|
256
|
+
fi
|
|
257
|
+
|
|
258
|
+
# A hairline above AND below every row of a long list is the laziest possible layout.
|
|
259
|
+
hits="$(search 'border-t[^"]*border-b|border-b[^"]*border-t' 2>/dev/null | grep -cE '^[^:]*\.(tsx|jsx|html|vue|svelte):' || true)"
|
|
260
|
+
[ -z "$hits" ] && hits=0
|
|
261
|
+
if [ "$hits" -gt 3 ]; then
|
|
262
|
+
warn "double border on $hits list rows: pick one edge, or use spacing instead of hairlines"
|
|
263
|
+
fi
|
|
264
|
+
|
|
265
|
+
# More than two radius scales in one tree means no radius system at all.
|
|
266
|
+
variants=0
|
|
267
|
+
for v in none sm md lg xl 2xl 3xl full; do
|
|
268
|
+
if search "rounded-$v" >/dev/null 2>&1; then variants=$((variants + 1)); fi
|
|
269
|
+
done
|
|
270
|
+
if [ "$variants" -gt 2 ]; then
|
|
271
|
+
warn "$variants different radius scales in use: one radius system per project — use a --radius-* token instead"
|
|
272
|
+
fi
|
|
273
|
+
}
|
|
274
|
+
|
|
168
275
|
# --- static design-review checks (always run, no network) -------------------
|
|
169
276
|
static_checks() {
|
|
170
277
|
local hits
|
|
@@ -182,9 +289,8 @@ static_checks() {
|
|
|
182
289
|
done < <(grep -rl --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=.next '<h1' . 2>/dev/null || true)
|
|
183
290
|
fi
|
|
184
291
|
|
|
185
|
-
# 2. transition: all
|
|
186
|
-
|
|
187
|
-
if [ -n "$hits" ]; then warn "transition: all / transition-all found:"; printf '%s\n' "$hits" | head -n 5; fi
|
|
292
|
+
# 2. (moved) `transition: all` is now a row in tell_table() — it is a plain pattern, so it
|
|
293
|
+
# gets the generic mechanism and its fixture for free.
|
|
188
294
|
|
|
189
295
|
# 3. hardcoded hex when a token system exists (\b is not portable in ERE; a
|
|
190
296
|
# 3-8 char hex run after # is a sufficient heuristic without the boundary).
|
|
@@ -209,9 +315,7 @@ static_checks() {
|
|
|
209
315
|
fi
|
|
210
316
|
fi
|
|
211
317
|
|
|
212
|
-
# 6. ban-list
|
|
213
|
-
hits="$(search 'revolutionary|game-?changer|cutting-edge|supercharge|seamless|unlock' 2>/dev/null | grep -iE '\.(tsx|jsx|html|md|mdx)' || true)"
|
|
214
|
-
if [ -n "$hits" ]; then warn "ban-list marketing words found in copy:"; printf '%s\n' "$hits" | head -n 5; fi
|
|
318
|
+
# 6. (moved) the marketing ban-list is now a row in tell_table(), same reason as #2.
|
|
215
319
|
}
|
|
216
320
|
|
|
217
321
|
# --- fallback manual checklist ----------------------------------------------
|
|
@@ -239,6 +343,8 @@ EOF
|
|
|
239
343
|
# --- run --------------------------------------------------------------------
|
|
240
344
|
run_lighthouse
|
|
241
345
|
static_checks
|
|
346
|
+
run_tell_table
|
|
347
|
+
counter_checks
|
|
242
348
|
[ "$LH_RAN" -eq 0 ] && print_checklist
|
|
243
349
|
|
|
244
350
|
printf '\nok=%d skip=%d warn=%d fail=%d\n' "$ok_count" "$skip_count" "$warn_count" "$fail_count"
|