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.
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/dist/cli.js +105 -0
- package/dist/commands/build.js +35 -0
- package/dist/commands/detect.js +91 -0
- package/dist/commands/doctor.js +110 -0
- package/dist/commands/graph.js +106 -0
- package/dist/commands/install.js +76 -0
- package/dist/commands/lint.js +87 -0
- package/dist/commands/rubric.js +104 -0
- package/dist/commands/spec.js +300 -0
- package/dist/compile.js +235 -0
- package/dist/detect/rules.js +208 -0
- package/dist/detect/types.js +36 -0
- package/dist/emit.js +77 -0
- package/dist/graph.js +288 -0
- package/dist/heuristics.js +185 -0
- package/dist/lock.js +18 -0
- package/dist/mdx.js +84 -0
- package/dist/paths.js +62 -0
- package/dist/skill.js +69 -0
- package/package.json +51 -0
- package/src/skills/SKILL.md +125 -0
- package/src/skills/flow/build.md +57 -0
- package/src/skills/flow/firebase.md +102 -0
- package/src/skills/flow/init.md +116 -0
- package/src/skills/flow/review.md +186 -0
- package/src/skills/flow/spec.md +149 -0
- package/src/skills/heuristics/accessibility.md +124 -0
- package/src/skills/heuristics/ads.md +140 -0
- package/src/skills/heuristics/auth.md +130 -0
- package/src/skills/heuristics/background-work.md +129 -0
- package/src/skills/heuristics/buttons.md +99 -0
- package/src/skills/heuristics/camera.md +127 -0
- package/src/skills/heuristics/chat.md +125 -0
- package/src/skills/heuristics/colors.md +129 -0
- package/src/skills/heuristics/copy.md +157 -0
- package/src/skills/heuristics/data-display.md +124 -0
- package/src/skills/heuristics/feedback.md +122 -0
- package/src/skills/heuristics/forms.md +124 -0
- package/src/skills/heuristics/icons-and-imagery.md +135 -0
- package/src/skills/heuristics/layout.md +125 -0
- package/src/skills/heuristics/lists.md +129 -0
- package/src/skills/heuristics/localization.md +128 -0
- package/src/skills/heuristics/maps.md +129 -0
- package/src/skills/heuristics/media.md +130 -0
- package/src/skills/heuristics/motion.md +113 -0
- package/src/skills/heuristics/navigation.md +116 -0
- package/src/skills/heuristics/network.md +118 -0
- package/src/skills/heuristics/notifications.md +121 -0
- package/src/skills/heuristics/offline.md +124 -0
- package/src/skills/heuristics/onboarding.md +103 -0
- package/src/skills/heuristics/payments.md +138 -0
- package/src/skills/heuristics/performance.md +111 -0
- package/src/skills/heuristics/permissions.md +125 -0
- package/src/skills/heuristics/privacy-ui.md +112 -0
- package/src/skills/heuristics/scrolling.md +114 -0
- package/src/skills/heuristics/search.md +127 -0
- package/src/skills/heuristics/sense.md +128 -0
- package/src/skills/heuristics/settings.md +129 -0
- package/src/skills/heuristics/sharing.md +102 -0
- package/src/skills/heuristics/sound.md +95 -0
- package/src/skills/heuristics/splashscreen.md +111 -0
- package/src/skills/heuristics/states.md +120 -0
- package/src/skills/heuristics/touch.md +95 -0
- package/src/skills/heuristics/typography.md +99 -0
- package/src/skills/heuristics/updates.md +129 -0
- package/src/skills/heuristics/webviews.md +114 -0
- package/src/skills/heuristics/widgets.md +128 -0
- package/src/skills/references/capability-checks.md +59 -0
- package/src/skills/references/fonts.json +339 -0
- package/src/skills/references/icon-and-image-assets.md +103 -0
- package/src/skills/references/input-fields.md +82 -0
- package/src/skills/references/launch-surface.md +94 -0
- package/src/skills/references/motion-tokens.md +89 -0
- package/src/skills/references/navigation-containers.md +51 -0
- package/src/skills/references/search-controls.md +49 -0
- package/src/skills/references/type-scales.md +60 -0
- package/src/skills/references/wireframe-frame.md +209 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { STACKS } from '../compile.js';
|
|
4
|
+
import { readSources, resolve, write } from '../emit.js';
|
|
5
|
+
import { writeLock } from '../lock.js';
|
|
6
|
+
import { AGENT_ROOTS, DEFAULT_AGENT_ROOT, agentFor, briefCandidates, packagedSkillDir, variantName, } from '../paths.js';
|
|
7
|
+
import { hashFiles, hashSkill, readSkillName } from '../skill.js';
|
|
8
|
+
async function exists(path) {
|
|
9
|
+
try {
|
|
10
|
+
await stat(path);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Agent directories already present in the project. A project with no agent
|
|
19
|
+
* directory gets .claude, which is the common default.
|
|
20
|
+
*/
|
|
21
|
+
async function detectAgentRoots(cwd) {
|
|
22
|
+
const found = [];
|
|
23
|
+
for (const root of AGENT_ROOTS) {
|
|
24
|
+
if (await exists(join(cwd, root))) {
|
|
25
|
+
found.push(root);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return found.length > 0 ? found : [DEFAULT_AGENT_ROOT];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The stack the project recorded, from the "Stack:" line init writes at the top
|
|
32
|
+
* of STACK.md. Absent means the generic copy, which carries every branch.
|
|
33
|
+
*/
|
|
34
|
+
export async function readStack(cwd) {
|
|
35
|
+
for (const candidate of briefCandidates(cwd, 'STACK.md')) {
|
|
36
|
+
let source;
|
|
37
|
+
try {
|
|
38
|
+
source = await readFile(candidate, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const match = /^Stack:\s*([a-z-]+)\s*$/m.exec(source);
|
|
44
|
+
const stack = match?.[1];
|
|
45
|
+
return stack && STACKS.includes(stack) ? stack : undefined;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
export async function install(options) {
|
|
50
|
+
const { cwd, version } = options;
|
|
51
|
+
const name = await readSkillName(packagedSkillDir);
|
|
52
|
+
const sources = await readSources();
|
|
53
|
+
const stack = options.stack ?? (await readStack(cwd));
|
|
54
|
+
const skill = variantName(name, stack);
|
|
55
|
+
const roots = options.dirs ?? (await detectAgentRoots(cwd));
|
|
56
|
+
const targets = [];
|
|
57
|
+
for (const root of roots) {
|
|
58
|
+
const agent = options.dirs ? 'other' : agentFor(root);
|
|
59
|
+
const target = stack ? { agent, stack } : { agent };
|
|
60
|
+
const files = resolve(sources, target);
|
|
61
|
+
const dir = options.dirs ? root : [root, 'skills', skill].join('/');
|
|
62
|
+
await write(files, join(cwd, ...dir.split('/')));
|
|
63
|
+
targets.push({ dir, agent, ...(stack ? { stack } : {}), hash: hashFiles(files) });
|
|
64
|
+
console.log(`installed ${skill} into ${dir} (resolved for ${agent})`);
|
|
65
|
+
}
|
|
66
|
+
await writeLock(cwd, {
|
|
67
|
+
version,
|
|
68
|
+
skill,
|
|
69
|
+
hash: await hashSkill(packagedSkillDir),
|
|
70
|
+
installedAt: new Date().toISOString(),
|
|
71
|
+
targets,
|
|
72
|
+
});
|
|
73
|
+
console.log(`wrote .trunative/skill.lock (${stack ? `stack ${stack}` : 'no stack recorded yet'})`);
|
|
74
|
+
console.log('run "npx trunative doctor" to check the project briefs');
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { readGraph } from '../graph.js';
|
|
4
|
+
import { packageRoot, packagedSkillDir } from '../paths.js';
|
|
5
|
+
/** Written prose the skill never ships, checked here rather than by eye. */
|
|
6
|
+
const FORBIDDEN = [
|
|
7
|
+
{ pattern: /[—–]/, kind: 'dash', detail: 'em dash or en dash' },
|
|
8
|
+
{ pattern: /<if:|<endif>|<else>/, kind: 'old syntax', detail: 'pre-MDX conditional' },
|
|
9
|
+
];
|
|
10
|
+
async function prose(skillDir, files) {
|
|
11
|
+
const findings = [];
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
let source;
|
|
14
|
+
try {
|
|
15
|
+
source = await readFile(join(skillDir, file), 'utf8');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
source.split(/\r?\n/).forEach((line, index) => {
|
|
21
|
+
for (const rule of FORBIDDEN) {
|
|
22
|
+
if (rule.pattern.test(line)) {
|
|
23
|
+
findings.push({ kind: rule.kind, detail: rule.detail, file, line: index + 1 });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return findings;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The README summarises the rules and cites them by id, so a rename that misses
|
|
32
|
+
* it leaves the entry point of the repository pointing at nothing.
|
|
33
|
+
*/
|
|
34
|
+
async function readmeCitations(graph) {
|
|
35
|
+
const findings = [];
|
|
36
|
+
let source;
|
|
37
|
+
try {
|
|
38
|
+
source = await readFile(join(packageRoot, 'README.md'), 'utf8');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return findings;
|
|
42
|
+
}
|
|
43
|
+
const ids = new Set(graph.rules.map((rule) => rule.id));
|
|
44
|
+
const prefixes = [...new Set(graph.rules.map((rule) => rule.id.split('-')[0] + '-'))];
|
|
45
|
+
source.split(/\r?\n/).forEach((line, index) => {
|
|
46
|
+
for (const match of line.matchAll(/`([a-z0-9][a-z0-9-]*)`/g)) {
|
|
47
|
+
const token = match[1];
|
|
48
|
+
if (ids.has(token) || prefixes.includes(token) || !prefixes.some((p) => token.startsWith(p))) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
findings.push({
|
|
52
|
+
kind: 'dangling id',
|
|
53
|
+
detail: `\`${token}\` is defined nowhere`,
|
|
54
|
+
file: 'README.md',
|
|
55
|
+
line: index + 1,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return findings;
|
|
60
|
+
}
|
|
61
|
+
export async function lint(options) {
|
|
62
|
+
const graph = await readGraph(packagedSkillDir);
|
|
63
|
+
const markdown = [
|
|
64
|
+
'SKILL.md',
|
|
65
|
+
...graph.files.flow,
|
|
66
|
+
...graph.files.heuristics,
|
|
67
|
+
...graph.files.references,
|
|
68
|
+
].filter((file) => file.endsWith('.md'));
|
|
69
|
+
const findings = [
|
|
70
|
+
...graph.findings,
|
|
71
|
+
...(await prose(packagedSkillDir, markdown)),
|
|
72
|
+
...(await prose(packageRoot, ['README.md'])),
|
|
73
|
+
...(await readmeCitations(graph)),
|
|
74
|
+
];
|
|
75
|
+
if (findings.length === 0) {
|
|
76
|
+
console.log(`PASS ${graph.rules.length} rules, ${graph.mentions.length} citations, 0 findings`);
|
|
77
|
+
console.log(` run against ${options.cwd === packageRoot ? 'this repository' : packagedSkillDir}`);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
const width = Math.max(...findings.map((finding) => finding.kind.length));
|
|
81
|
+
for (const finding of findings) {
|
|
82
|
+
const where = finding.file ? `${finding.file}${finding.line ? `:${finding.line}` : ''} ` : '';
|
|
83
|
+
console.log(`FAIL ${finding.kind.padEnd(width)} ${where}${finding.detail}`);
|
|
84
|
+
}
|
|
85
|
+
console.log(`\n${findings.length} finding(s) in the written skill.`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { DETECTORS } from '../detect/rules.js';
|
|
2
|
+
import { packagedSkillDir } from '../paths.js';
|
|
3
|
+
import { readAlwaysInScope, readHeuristics, readIndexFiles, } from '../heuristics.js';
|
|
4
|
+
const FORMATS = ['markdown', 'json', 'ids'];
|
|
5
|
+
function matches(rule, only) {
|
|
6
|
+
return only.some((term) => term === rule.file || rule.id === term || rule.id.startsWith(term));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Base files are in every rubric. Beyond them, whole files when they match,
|
|
10
|
+
* and single rules when the term is an id.
|
|
11
|
+
*/
|
|
12
|
+
function scope(heuristics, base, only) {
|
|
13
|
+
if (only.length === 0) {
|
|
14
|
+
return heuristics;
|
|
15
|
+
}
|
|
16
|
+
return heuristics
|
|
17
|
+
.map((heuristic) => ({
|
|
18
|
+
...heuristic,
|
|
19
|
+
rules: base.includes(heuristic.file)
|
|
20
|
+
? heuristic.rules
|
|
21
|
+
: heuristic.rules.filter((rule) => matches(rule, only)),
|
|
22
|
+
}))
|
|
23
|
+
.filter((heuristic) => heuristic.rules.length > 0);
|
|
24
|
+
}
|
|
25
|
+
function row(rule, source) {
|
|
26
|
+
const flags = [
|
|
27
|
+
rule.always ? 'always' : '',
|
|
28
|
+
rule.evidence === 'device' ? 'device' : '',
|
|
29
|
+
source ? `from ${source}` : '',
|
|
30
|
+
]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(', ');
|
|
33
|
+
const head = `- \`${rule.id}\` (score: _, evidence: _)${flags ? ` [${flags}]` : ''}`;
|
|
34
|
+
const criterion = rule.check || `no Check line in the file, judge it against "${rule.title}"`;
|
|
35
|
+
return `${head}\n ${criterion}`;
|
|
36
|
+
}
|
|
37
|
+
function markdown(all, scoped, base, version) {
|
|
38
|
+
const total = all.reduce((count, heuristic) => count + heuristic.rules.length, 0);
|
|
39
|
+
const inScope = scoped.reduce((count, heuristic) => count + heuristic.rules.length, 0);
|
|
40
|
+
const emitted = new Set(scoped.flatMap((heuristic) => heuristic.rules.map((rule) => rule.id)));
|
|
41
|
+
const missing = all
|
|
42
|
+
.flatMap((heuristic) => heuristic.rules.map((rule) => [heuristic, rule]))
|
|
43
|
+
.filter(([, rule]) => rule.always && !emitted.has(rule.id));
|
|
44
|
+
const extras = scoped
|
|
45
|
+
.map((heuristic) => heuristic.file)
|
|
46
|
+
.filter((file) => !base.includes(file));
|
|
47
|
+
const out = [
|
|
48
|
+
'# Review checklist',
|
|
49
|
+
'',
|
|
50
|
+
`Generated by trunative ${version}. ${inScope + missing.length} rules in scope, of ${total}.`,
|
|
51
|
+
`Base: ${base.join(', ')}. Extra: ${extras.length > 0 ? extras.join(', ') : 'none'}.`,
|
|
52
|
+
'',
|
|
53
|
+
'Score each rule 1 to 5, or mark it n/a with a reason, or unrun. A row marked',
|
|
54
|
+
'[device] may not be graded from the source alone. The anchors, the evidence',
|
|
55
|
+
'values and what happens to the denominator are in `flow/review.md`.',
|
|
56
|
+
];
|
|
57
|
+
if (missing.length > 0) {
|
|
58
|
+
out.push('', '## Always in scope', '', 'Scored on every run, whatever the screen is. Not available as n/a.', '', ...missing.map(([heuristic, rule]) => row(rule, heuristic.path)));
|
|
59
|
+
}
|
|
60
|
+
for (const heuristic of scoped) {
|
|
61
|
+
out.push('', `## ${heuristic.file}, ${heuristic.path}`, '', ...heuristic.rules.map((rule) => row(rule)));
|
|
62
|
+
if (heuristic.device) {
|
|
63
|
+
out.push('', `Not from a diff: ${heuristic.device}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return `${out.join('\n')}\n`;
|
|
67
|
+
}
|
|
68
|
+
export async function rubric(options) {
|
|
69
|
+
const format = options.format ?? 'markdown';
|
|
70
|
+
if (!FORMATS.includes(format)) {
|
|
71
|
+
console.error(`unknown --format "${format}", expected ${FORMATS.join(', ')}`);
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
const only = options.only ?? [];
|
|
75
|
+
const all = await readHeuristics(packagedSkillDir);
|
|
76
|
+
const base = await readIndexFiles(packagedSkillDir, 'base');
|
|
77
|
+
const scoped = scope(all, base, only);
|
|
78
|
+
if (scoped.length === 0) {
|
|
79
|
+
console.error(`no rules match --only ${only.join(', ')}`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
if (format === 'ids') {
|
|
83
|
+
for (const heuristic of scoped) {
|
|
84
|
+
for (const rule of heuristic.rules) {
|
|
85
|
+
console.log(rule.id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
if (format === 'json') {
|
|
91
|
+
console.log(JSON.stringify({
|
|
92
|
+
version: options.version,
|
|
93
|
+
scope: only.length > 0 ? only : 'all',
|
|
94
|
+
base,
|
|
95
|
+
alwaysInScope: await readAlwaysInScope(packagedSkillDir),
|
|
96
|
+
/** Rules "trunative detect" can raise a finding on, as source evidence. */
|
|
97
|
+
detectable: DETECTORS.map((detector) => detector.rule),
|
|
98
|
+
heuristics: scoped,
|
|
99
|
+
}, undefined, '\t'));
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
process.stdout.write(markdown(all, scoped, base, options.version));
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { readIndex, readIndexFiles } from '../heuristics.js';
|
|
4
|
+
import { CONFIG_DIR, packagedSkillDir } from '../paths.js';
|
|
5
|
+
/** The keys a screen brief carries, and no others. */
|
|
6
|
+
const FIELDS = ['target', 'primary_action', 'states', 'scope'];
|
|
7
|
+
/** The six `state-set` names, which are the six keys, always all six. */
|
|
8
|
+
const STATES = ['loading', 'empty', 'error', 'offline', 'partial', 'permission'];
|
|
9
|
+
/**
|
|
10
|
+
* What the brief may never carry. A number with a unit, or a line that reads
|
|
11
|
+
* like a declaration, is the layout format growing inside the brief, and
|
|
12
|
+
* `flow/spec.md` sends both to DESIGN.md.
|
|
13
|
+
*/
|
|
14
|
+
const MEASURED = /(^|[^a-z0-9])\d+(\.\d+)?\s?(px|pt|dp|sp|rem|em|%)\b/i;
|
|
15
|
+
const DECLARED = /^\s*[-*]?\s*(padding|margin|gap|radius|border-radius|font-size|font-weight|line-height|width|height|spacing|shadow|opacity|elevation|z-index)\s*[:=]/i;
|
|
16
|
+
const HEX = /#[0-9a-f]{6}\b/gi;
|
|
17
|
+
const DASH = /[—–]/;
|
|
18
|
+
function split(source) {
|
|
19
|
+
const lines = source.split(/\r?\n/);
|
|
20
|
+
if (lines[0]?.trim() !== '---') {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
|
|
24
|
+
if (end === -1) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
head: lines.slice(1, end),
|
|
29
|
+
offset: 2,
|
|
30
|
+
body: lines.slice(end + 1),
|
|
31
|
+
bodyOffset: end + 2,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Top-level `key: value` pairs, with the nested lines that belong to each. */
|
|
35
|
+
function fields(head) {
|
|
36
|
+
const found = new Map();
|
|
37
|
+
let current;
|
|
38
|
+
head.forEach((line, index) => {
|
|
39
|
+
if (line.trim() === '' || line.trimStart().startsWith('#')) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const top = /^([a-z_]+):\s*(.*)$/.exec(line);
|
|
43
|
+
if (top) {
|
|
44
|
+
current = { value: top[2].trim(), line: index, children: [] };
|
|
45
|
+
found.set(top[1], current);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
current?.children.push(line);
|
|
49
|
+
});
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
/** `[a, b]` on the key's own line, or a block of `- a` under it. */
|
|
53
|
+
function list(value, children) {
|
|
54
|
+
const inline = /^\[(.*)\]$/.exec(value);
|
|
55
|
+
if (inline) {
|
|
56
|
+
return inline[1]
|
|
57
|
+
.split(',')
|
|
58
|
+
.map((item) => item.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
return children
|
|
62
|
+
.map((line) => /^\s*-\s*(.+)$/.exec(line)?.[1]?.trim() ?? '')
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
}
|
|
65
|
+
/** `key: value` pairs nested one level under a parent. */
|
|
66
|
+
function map(children) {
|
|
67
|
+
const found = [];
|
|
68
|
+
let indent = -1;
|
|
69
|
+
children.forEach((line, index) => {
|
|
70
|
+
const pair = /^(\s+)([a-z_]+):\s*(.*)$/.exec(line);
|
|
71
|
+
if (!pair) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const depth = pair[1].length;
|
|
75
|
+
if (indent === -1) {
|
|
76
|
+
indent = depth;
|
|
77
|
+
}
|
|
78
|
+
if (depth !== indent) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
found.push({ key: pair[2], value: pair[3].trim(), line: index });
|
|
82
|
+
});
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
const PREFIX_ROW = /\|\s*`heuristics\/([a-z0-9-]+)\.md`\s*\|\s*`([a-z0-9-]+)`\s*\|/;
|
|
86
|
+
/**
|
|
87
|
+
* Every spelling of an extra file, resolved to its stem. The Extra table prints
|
|
88
|
+
* the stem and the rule prefix side by side and they often differ, so a brief
|
|
89
|
+
* that says "l10n" for `heuristics/localization.md` is naming the row it read
|
|
90
|
+
* rather than making a mistake. `rubric --only` already takes either, and a
|
|
91
|
+
* checker stricter than the generator is a trap with no upside.
|
|
92
|
+
*/
|
|
93
|
+
async function stems(skillDir) {
|
|
94
|
+
const found = new Map();
|
|
95
|
+
for (const stem of await readIndexFiles(skillDir, 'extra')) {
|
|
96
|
+
found.set(stem, stem);
|
|
97
|
+
}
|
|
98
|
+
for (const line of await readIndex(skillDir, 'extra')) {
|
|
99
|
+
const row = PREFIX_ROW.exec(line);
|
|
100
|
+
if (!row) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
found.set(row[2], row[1]);
|
|
104
|
+
found.set(row[2].replace(/-$/, ''), row[1]);
|
|
105
|
+
}
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
108
|
+
/** How far a colour is from grey, 0 to 1. Tinted greys stay low, brand does not. */
|
|
109
|
+
function chroma(hex) {
|
|
110
|
+
const channels = [1, 3, 5].map((at) => Number.parseInt(hex.slice(at, at + 2), 16));
|
|
111
|
+
return (Math.max(...channels) - Math.min(...channels)) / 255;
|
|
112
|
+
}
|
|
113
|
+
async function exists(path) {
|
|
114
|
+
try {
|
|
115
|
+
await stat(path);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function checkBrief(cwd, file, extra, base) {
|
|
123
|
+
const findings = [];
|
|
124
|
+
const add = (kind, detail, line) => {
|
|
125
|
+
findings.push({ kind, detail, file, line });
|
|
126
|
+
};
|
|
127
|
+
const source = await readFile(join(cwd, file), 'utf8');
|
|
128
|
+
source.split(/\r?\n/).forEach((line, index) => {
|
|
129
|
+
if (DASH.test(line)) {
|
|
130
|
+
add('dash', 'em dash or en dash', index + 1);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
const brief = split(source);
|
|
134
|
+
if (!brief) {
|
|
135
|
+
add('frontmatter', 'no frontmatter fenced by --- on the first line', 1);
|
|
136
|
+
return findings;
|
|
137
|
+
}
|
|
138
|
+
for (const [index, line] of brief.body.entries()) {
|
|
139
|
+
const at = brief.bodyOffset + index;
|
|
140
|
+
if (MEASURED.test(line)) {
|
|
141
|
+
add('measured', 'a number with a unit, which belongs in DESIGN.md', at);
|
|
142
|
+
}
|
|
143
|
+
if (DECLARED.test(line)) {
|
|
144
|
+
add('measured', 'a style declaration, which belongs in DESIGN.md', at);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const head = fields(brief.head);
|
|
148
|
+
for (const name of FIELDS) {
|
|
149
|
+
if (!head.has(name)) {
|
|
150
|
+
add('missing field', name, brief.offset);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const [name, field] of head) {
|
|
154
|
+
if (!FIELDS.includes(name)) {
|
|
155
|
+
add('unknown field', name, brief.offset + field.line);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const target = head.get('target');
|
|
159
|
+
if (target) {
|
|
160
|
+
if (!target.value) {
|
|
161
|
+
add('target', 'empty, name the file that implements the screen', brief.offset + target.line);
|
|
162
|
+
}
|
|
163
|
+
else if (!(await exists(join(cwd, target.value)))) {
|
|
164
|
+
add('target', `${target.value} is not on disk`, brief.offset + target.line);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const action = head.get('primary_action');
|
|
168
|
+
if (action) {
|
|
169
|
+
const quoted = /^"(.+)"$/.exec(action.value);
|
|
170
|
+
if (!quoted) {
|
|
171
|
+
add('primary action', 'must be the label the user reads, in quotes, not an identifier', brief.offset + action.line);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const states = head.get('states');
|
|
175
|
+
if (states) {
|
|
176
|
+
const declared = map(states.children);
|
|
177
|
+
for (const name of STATES) {
|
|
178
|
+
if (!declared.some((entry) => entry.key === name)) {
|
|
179
|
+
add('state', `${name} is missing, and all six of state-set are required`, brief.offset + states.line);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const entry of declared) {
|
|
183
|
+
const at = brief.offset + states.line + 1 + entry.line;
|
|
184
|
+
if (!STATES.includes(entry.key)) {
|
|
185
|
+
add('state', `${entry.key} is not one of the six state-set names`, at);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!entry.value) {
|
|
189
|
+
add('state', `${entry.key} has no line saying what the screen shows`, at);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (/^n\/a\b/i.test(entry.value) && entry.value.replace(/^n\/a\b[\s,.:;-]*/i, '') === '') {
|
|
193
|
+
add('state', `${entry.key} is n/a with no reason`, at);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const scope = head.get('scope');
|
|
198
|
+
if (scope) {
|
|
199
|
+
const at = brief.offset + scope.line;
|
|
200
|
+
const parts = map(scope.children);
|
|
201
|
+
const openAt = parts.find((entry) => entry.key === 'open');
|
|
202
|
+
const closedAt = parts.find((entry) => entry.key === 'closed');
|
|
203
|
+
const openChildren = scope.children.slice((openAt?.line ?? -1) + 1, closedAt?.line ?? undefined);
|
|
204
|
+
const open = openAt ? list(openAt.value, openChildren) : [];
|
|
205
|
+
const closed = closedAt ? map(scope.children.slice(closedAt.line + 1)) : [];
|
|
206
|
+
if (!openAt) {
|
|
207
|
+
add('scope', 'no open list, name the extra files this screen touches', at);
|
|
208
|
+
}
|
|
209
|
+
const opened = new Set();
|
|
210
|
+
for (const stem of open) {
|
|
211
|
+
if (base.includes(stem)) {
|
|
212
|
+
add('scope', `${stem} is a base file, which is opened on every screen`, at);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const canonical = extra.get(stem);
|
|
216
|
+
if (!canonical) {
|
|
217
|
+
add('scope', `${stem} is not a file in the Extra table`, at);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
opened.add(canonical);
|
|
221
|
+
}
|
|
222
|
+
for (const entry of closed) {
|
|
223
|
+
const line = brief.offset + scope.line + 2 + closedAt.line + entry.line;
|
|
224
|
+
const canonical = extra.get(entry.key);
|
|
225
|
+
if (!canonical) {
|
|
226
|
+
add('scope', `${entry.key} is not a file in the Extra table`, line);
|
|
227
|
+
}
|
|
228
|
+
else if (opened.has(canonical)) {
|
|
229
|
+
add('scope', `${entry.key} is both open and closed`, line);
|
|
230
|
+
}
|
|
231
|
+
if (!entry.value) {
|
|
232
|
+
add('scope', `${entry.key} is closed with no reason`, line);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const wireframe = file.replace(/\.md$/, '.wireframe.html');
|
|
237
|
+
if (await exists(join(cwd, wireframe))) {
|
|
238
|
+
const drawing = await readFile(join(cwd, wireframe), 'utf8');
|
|
239
|
+
drawing.split(/\r?\n/).forEach((line, index) => {
|
|
240
|
+
for (const match of line.matchAll(HEX)) {
|
|
241
|
+
if (chroma(match[0]) > 0.1) {
|
|
242
|
+
findings.push({
|
|
243
|
+
kind: 'wireframe',
|
|
244
|
+
detail: `${match[0]} is a colour, and a wireframe is greyscale`,
|
|
245
|
+
file: wireframe,
|
|
246
|
+
line: index + 1,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return findings;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Checks the screen briefs in a project. It is the doctor's family rather than
|
|
256
|
+
* the lint's: it reads what the agent wrote into a consumer project, not the
|
|
257
|
+
* skill this package ships.
|
|
258
|
+
*/
|
|
259
|
+
export async function spec(options) {
|
|
260
|
+
const { cwd } = options;
|
|
261
|
+
const dir = join(CONFIG_DIR, 'screens');
|
|
262
|
+
let files = options.paths.map((path) => path.replace(/\\/g, '/'));
|
|
263
|
+
if (files.length === 0) {
|
|
264
|
+
try {
|
|
265
|
+
files = (await readdir(join(cwd, dir)))
|
|
266
|
+
.filter((name) => name.endsWith('.md'))
|
|
267
|
+
.sort()
|
|
268
|
+
.map((name) => `${dir}/${name}`);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
files = [];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (files.length === 0) {
|
|
275
|
+
console.log(`PASS no screen briefs in ${dir}`);
|
|
276
|
+
console.log(' flow/spec.md writes one when a task changes structure.');
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
const extra = await stems(packagedSkillDir);
|
|
280
|
+
const base = await readIndexFiles(packagedSkillDir, 'base');
|
|
281
|
+
const findings = [];
|
|
282
|
+
for (const file of files) {
|
|
283
|
+
if (!(await exists(join(cwd, file)))) {
|
|
284
|
+
findings.push({ kind: 'missing', detail: 'not on disk', file, line: 0 });
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
findings.push(...(await checkBrief(cwd, file, extra, base)));
|
|
288
|
+
}
|
|
289
|
+
if (findings.length === 0) {
|
|
290
|
+
console.log(`PASS ${files.length} screen brief(s), 0 findings`);
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
293
|
+
const width = Math.max(...findings.map((finding) => finding.kind.length));
|
|
294
|
+
for (const finding of findings) {
|
|
295
|
+
const where = `${finding.file}${finding.line ? `:${finding.line}` : ''}`;
|
|
296
|
+
console.log(`FAIL ${finding.kind.padEnd(width)} ${where} ${finding.detail}`);
|
|
297
|
+
}
|
|
298
|
+
console.log(`\n${findings.length} finding(s) in ${files.length} screen brief(s).`);
|
|
299
|
+
return 1;
|
|
300
|
+
}
|